diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e1a99057..d2b52312b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,13 @@ jobs: needs: [build-binary] steps: - uses: actions/checkout@v7 + # full history: the #1346 additive-invariants-gate diffs the enroll/guard + # set vs `git merge-base HEAD ` — a shallow (depth-1) checkout has no + # common ancestor, so the merge-base is empty and the gate (correctly) + # refuses to pass an UNVERIFIABLE AC1 under INVARIANT_STRICT=1. Full history + # lets it actually verify. (#1346) + with: + fetch-depth: 0 - uses: pnpm/action-setup@v6 # version pinned by packageManager in package.json - uses: actions/setup-node@v7 with: { node-version: 20, cache: pnpm } @@ -112,6 +119,16 @@ jobs: run: node packages/extension/scripts/assert_packaged_cli.mjs - name: fleet-gate — guard + tunnel template sane (#324) run: bash packages/extension/scripts/assert_fleet_guard.sh + - name: additive-invariants-gate — peer studios stay additive (ADR 0027, #1346) + env: + INVARIANT_STRICT: "1" # CI must be able to verify AC1 — an unverifiable base is a hard fail + run: | + # AC1 diffs the enroll/guard set vs the MERGE-BASE with the base branch. + # A shallow checkout may not carry the base branch, so fetch it here — + # this also persists the ref for the later vitest subprocess in the + # `pnpm -r run test` step, which runs the same gate. + git fetch --no-tags --quiet origin "${GITHUB_BASE_REF:-feature/free-tier-fleet}" || true + bash packages/extension/scripts/assert_additive_invariants.sh # Pasqal python provisioning gate, source lane: the SHIPPED connector # assets must provision a working interpreter (real venv + pip + SDK # import at the pinned version + validator env-guard smoke). Guards the diff --git a/CONTEXT.md b/CONTEXT.md index 63ea66c22..f074da2cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -175,6 +175,26 @@ source) The per-machine stance for where Sessions are served from, in three values. `standalone` — this machine spawns and owns its own chat server, detached and survivable so it outlives an extension-host reload and is re-adopted rather than dying with the editor (the default; the only mode that ever spawns). `server` — this machine runs the Canonical Server as a system service and the panel attaches to it. `client` — this machine never serves; the panel attaches to the Canonical Server through a Managed Tunnel. Determined by `~/.amico/ops/fleet/fleet.json` (no file = standalone). Machine-scoped, never synced. _Avoid_: profile, spawn vs attach (as concept names) +**Capability**: +An orthogonal, open tag describing what a machine is *for* — distinct from its serve-stance (Server mode). Two known, behavior-adjacent tags: `compute` (a solve-target hint — declared-but-inert until a fleet-peer executor exists, an explicit non-goal today) and `roaming` (a transport hint that defaults a machine to Tailscale). Any other tag is a free descriptive label with no behavioral meaning; it round-trips verbatim. A Capability never overrides the guard/tunnel/hub decision — Server mode stays the only serve-stance authority (ADR 0026). +_Avoid_: role (that is Server mode), type, class + +**Roster**: +The amicode-owned, fleet-wide device list on the Canonical Server (`~/.amico/ops/fleet/roster.json`) — one row per machine, each machine the single writer of its own row (a registry/heartbeat model). A row is `{ machine_id, name, server_mode, capabilities[], sshAlias, transport, last_report, health, device_type? }` with `health ∈ {reachable, degraded, down}`; the UI labels `server_mode` as "role" and `last_report` as "last-seen". Surfaced at `GET /amicode/roster` (read) + `POST /amicode/roster` (self-report), on the proxied `/amicode/*` namespace so a client sees it through the host proxy. Separate from `fleet.json` (untouched — one-parser invariant, ADR 0023) and from the per-machine projection (which carries only this machine's topology). ADR 0026. +_Avoid_: fleet.json (a different, per-machine file), device table, inventory + +**Device identity**: +The self-reported, human-facing half of a Roster row: the machine's friendly **name** (macOS `scutil` ComputerName / Linux `PRETTY_HOSTNAME` / an explicit `amicode.deviceName` override, else the prettified hostname) and its **device type** (`server | desktop | laptop`, an open set — macOS `system_profiler` model / Linux chassis / an `amicode.deviceType` override, else absent). Derived once by logic shared between the enroll producer and the sidebar self-row so a machine and its peers cannot disagree; absent/undefined values fall back honestly (type → `server_mode`), never fabricated. The Canonical Server self-registers its own device identity keyed by `machine_id = canonical.host`. Refreshed by re-enroll. ADR 0028. +_Avoid_: hostname (the raw FQDN, not the friendly name), machine_id (the identity key, not the display name) + +**Machine**: +One computer in the Fleet — the unit a Roster row describes and Enroll brings in. +_Avoid_: device (a Device is a QPU or emulator behind a Connection), node + +**Focused machine**: +The one Machine the window's working surfaces are scoped to, chosen in the FLEET section and defaulting to this machine; a UI selection, never a Roster change. +_Avoid_: active machine, selected device + **Fleet config**: The file at `~/.amico/ops/fleet/fleet.json` that declares this machine's fleet role and the canonical server's coordinates (`host`, `port`, `sshAlias`). No file on disk = standalone. The guard script, extension, and installer all resolve role from this file — never from a hardcoded hostname. _Avoid_: fleet.toml, fleet settings (those are VS Code settings, a different thing) @@ -184,13 +204,17 @@ The one chat server that owns the fleet's Session store — the single writer ev _Avoid_: master, primary, host **Fleet**: -The user's machines acting as one logical studio: exactly one Canonical Server plus zero-or-more clients, all attaching to the same Session store. +The user's machines acting as one logical studio. Two topologies, both "the Fleet": the **single-store** shape — exactly one Canonical Server plus zero-or-more clients, all attaching to the same Session store — and the **independent-peer** shape (ADR 0031/0034), where **Peer studios** each own their own Sessions and reach each other by Observe/Control rather than a shared writer. A keeper/canonical coordinate owns roster and bootstrap authority in both. _Avoid_: mesh, cluster **Go Standalone**: -The user-invoked mode switch from `client` to `standalone` — the machine leaves the fleet and serves itself permanently. Not an escape hatch: a first-class choice. Sessions made locally stay local. Re-enrollment in a fleet is a separate flow (Enroll, deferred). +The user-invoked mode switch from `client` to `standalone` — the machine leaves the fleet and serves itself permanently. Not an escape hatch: a first-class choice. Sessions made locally stay local. Re-enrollment in a fleet is a separate flow (Enroll). _Avoid_: local fallback, offline mode, degraded mode +**Enroll**: +The idempotent, verifiable flow that brings one machine into a fleet — the inverse of Go Standalone. Server-first, the standard join-token pattern: `amico fleet enroll --as-server` provisions the durable hub service, mints the Fleet token, and emits a **join token** (`{ canonical, fleet_token, transport_hint, pin_version }` — a secret, 0600, never logged). A client redeems it with `amico fleet enroll --join-token`: it rejects a pin-mismatched token before writing anything, then writes `fleet.json` (role + canonical only — capabilities go to the roster row), registers its roster row, sets the transport (`tailscale` when the machine roams, else the token's hint), runs the installer (which installs the never-fork guard), and **verify-attaches** — reporting success only after a live probe of the just-set transport passes. A re-run repairs in place; a failed verify surfaces the specific cause with its fix and never reports a false success. `amico fleet enroll` is a distinct verb from the session registry — it writes membership, not session records. +_Avoid_: register (as the verb name), pair, handshake + **Fleet token**: The shared secret authenticating a client to the Canonical Server's data routes — minted when the fleet server is enabled, stored at 0600, handed to clients during the ssh-based setup flow. The sibling of the per-boot server password (ADR 0002): that guards a spawned server its extension owns; this guards the service no extension spawns. _Avoid_: API key, password @@ -215,6 +239,30 @@ _Avoid_: keepalive, lock, busy flag The interval after an extension-host teardown during which a detached standalone server stays alive awaiting re-adoption. On expiry with no adoption and no active-work pin the server self-exits and deletes its handshake; a reload re-adopts well within it, a genuine quit does not. _Avoid_: timeout, linger period +**Peer studio**: +An independent serving peer whose Sessions it owns, observed or controlled from another machine's window. The independent-peer complement to the single-store Canonical Server: Peer studios do **not** share one Session store — each owns its own, and cross-machine access is by Observe/Control, never a shared writer (ADR 0031, 0034). +_Avoid_: remote server (that is transport), replica, node + +**Observe (peer observation)**: +Read-only visibility into a Peer studio's Sessions from another machine — its session list, one session's history and events. Unlocked by **trust** (a reader token seeded at Enroll), not by a Control grant. A peer-owned session's reads route to the owner with the owner's own reader token; a write never routes on the observation path (#1537). +_Avoid_: view, mirror, follow, spectate + +**Control (peer control)**: +The authority to *drive* a Peer studio's Session from another machine — send prompts, archive, delete. A strictly higher unlock than Observe, carried by a persisted per-peer **Control grant** (`control` scope) and re-armed each session (never auto-restored). Fails closed: no grant, revoked, transport-down, or observe-only → the write is denied, never served locally. +_Avoid_: write access, remote admin, takeover + +**Enable control**: +The explicit, per-session act that arms Control on a peer. For a **self-owned** peer with verified management access it is one click on the controlling machine, authorized at Enroll — no target-side prompt, since a headless peer has no window. For a **shared** peer it is the far side of a request→approve handshake routed to the peer's Lifecycle-admin authority. +_Avoid_: grant (that is the record), connect, claim + +**Lifecycle-admin authority**: +The machine/operator that may mint, revoke, and re-admit a Peer studio's grants — the `lifecycle-admin` scope, **not** a superset of Control. Seeded at Enroll (the enroller is recorded as the peer's `authorityIdentityKey`), so a headless peer's Control can be approved from a UI-bearing machine; the peer itself only *enforces* presented tokens, never rendering approval. +_Avoid_: owner, root, keeper (keeper is roster/bootstrap coordination, a different authority) + +**Driving**: +The live state in which one window is authoring turns on a Peer studio's Session under active Control, marked by a persistent "driving ``" banner so control is never ambient. +_Avoid_: remote session, controlling (as a noun), piloting + ### Surfaces **Work Column**: @@ -238,7 +286,7 @@ A sandboxed ES-module card rendered in an iframe within Home. Authored by the ag _Avoid_: Card (ambiguous — the UI has many cards), tile (as the concept name — tile is a size class) **Sidebar**: -The webview in the VS Code activity bar container, showing project navigation and system status. Contains action buttons (open chat, create project), a session-aware unified project tree (Research Projects with lifecycle metadata expanding into file trees; Dev Projects as plain expandable folders), and a collapsible fleet section (deferred). The sidebar is navigation chrome — it follows the active session's project binding but never drives session switching. Single-clicking a file opens it as a tab in Preview (the multi-document file workspace in the side panel); double-clicking opens a native VS Code editor tab. +The webview in the VS Code activity bar container, showing project navigation and system status. Contains action buttons (open chat, create project), a session-aware unified project tree (Research Projects with lifecycle metadata expanding into file trees; Dev Projects as plain expandable folders), and a collapsible fleet section that is **read-only** toward the fleet — a glanceable list of Machines from the host-owned Roster (one row per machine: name, `server_mode` labeled "role" + capability chips, a `reachable`/`degraded`/`down` health indicator, and `last_report` labeled "last-seen"), marking the Focused machine. It offers navigation actions only — connect, focus, Troubleshoot, Add machine, Refresh — and never writes fleet config or Roster rows. The section refreshes on posture-change and shows an honest empty/degraded state when the roster host is down — never a fabricated list (#1321, ADR 0026). The sidebar is navigation chrome — it renders state and navigates, never mutating fleet config or driving session switching. Single-clicking a file opens it as a tab in Preview (the multi-document file workspace in the side panel); double-clicking opens a native VS Code editor tab. _Avoid_: Explorer (VS Code's native file explorer is separate), Panel (the in-app dismissible drawer is a different concept) **Preview**: diff --git a/config/specs/spec-20260914-205414-daemonize-standalone-server.md b/config/specs/spec-20260914-205414-daemonize-standalone-server.md deleted file mode 100644 index aa8c3abde..000000000 --- a/config/specs/spec-20260914-205414-daemonize-standalone-server.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -type: spec -schema_version: "1" -spec_id: spec-20260914-205414-daemonize-standalone-server -task_type: implement-slice -parent_issue: 1142 -feature_branch: jj/1142-server-survives-reload -acceptance: - - daemon_reparented_off_spawner == 1 - - daemon_survives_spawner_exit == 1 - - handshake_records_server_pid == 1 - - adopts_surviving_daemon == 1 - - stop_terminates_daemon == 1 - - stop_before_pid_known_frees_port == 1 - - health_gate_blocks_until_ready == 1 - - targeted_suites_pass == 1 - - typecheck_clean == 1 -invariants: - - the server is spawned loopback-only with the injected OPENCODE_SERVER_PASSWORD — the daemonizer adds no new network exposure - - a foreign process holding the port is never killed — the #1178 reclaim discipline (ours-only, verify-freed) is preserved verbatim, including in the by-port stop() fallback (D6) - - the PID persisted in the handshake, checked by the adopt gate, and killed by stop() is ALWAYS the opencode server's PID (the reparented grandchild) — the intermediate launcher's PID is never persisted, never adopted, never killed - - the daemonizer uses a Node intermediate (process.execPath + ELECTRON_RUN_AS_NODE), never a `setsid` shell binary, so it holds on macOS and the linux-x64/arm64/WSL release targets - - the CI harness runs under plain `node` and proves the POSIX double-fork mechanism ONLY — the Electron-as-node launcher wrapper that ships is exercised solely by the human reload test; the launcher command string gets a construction smoke check, but Electron-as-node reparenting is not machine-proven in this slice - - _ready and url are invalidated by the health/keepalive path, never by a child-exit event (there is no owned handle to emit one) — a crashed daemon must not read as ready - - the true end-to-end proof — an in-flight turn surviving a real VS Code window reload — is human-gated (one Cmd+R after deploy) and is NOT claimed by any machine gate in this slice -baseline: { value: "0% survival — server reaped on every window reload", source: "live Phase-2 test 2026-09-15: survivor pid 26497 dead after Cmd+R; handshake rotated to cold-spawn 29435; confirmed setsid (pgid==pid) yet reaped → PID-tree kill" } ---- - -# Daemonize the standalone server so it survives a VS Code window reload - -## Context - -#1142 keeps the standalone opencode server alive across a VS Code window reload so -an in-flight agent turn continues while the extension reloads with new code. All six -original slices (#1144–#1149) plus the wiring fixes (#1178, #1181) landed on -`jj/1142-server-survives-reload`, and Phase-1 is verified: cold-spawn writes a valid -`0600` handshake and the adopt gate's preconditions all pass. - -**Phase-2 (the actual reload) failed.** Live test 2026-09-15: the survivor `26497` -was dead after a plain Cmd+R; the handshake was rotated (cold-spawn `29435`), meaning -the new window found `pidAlive == false` and cold-spawned. Forensics established that -our code did not kill it (`deactivate()`→`detach()` never kills; kill-by-port runs only -on the no-handshake path; keepalive only deletes the handshake). The server is spawned -`detached:true` + `unref()` + stdio→file — the textbook recipe — and confirmed to be in -its **own process group** (`pgid==pid`), yet it still died. Therefore VS Code reaps by -**PID-tree / recorded-PID**, which a process-group detach does not escape. - -A same-machine proof (safe, no ports) showed the fix: while a mock parent stayed alive, -a single-fork child kept `PPID==parent` (inside the reaped subtree) while a **double-fork -grandchild reparented to `PPID==1` immediately** (outside the subtree). Removing the -server from the ext-host process subtree — not detaching harder — is the fix. - -## Decisions - -- **D1 — Double-fork daemonize.** `ServerManager` (log-file path) spawns an intermediate - launcher `detached`; the launcher spawns `opencode serve …` `detached` and exits - immediately, so the server reparents to init/launchd (`PPID==1`) before any reload. - The launcher is a Node intermediate run via `process.execPath` with - `ELECTRON_RUN_AS_NODE=1` (portable; no `setsid` binary). -- **D2 — PID by discovery, not by report.** After the port passes health, the server's - PID is obtained from the listener via the existing #1178 `pidHoldingPort(port)` (lsof) - primitive. This is the PID written to the handshake. The launcher's PID is never - persisted. Rationale: the listener PID is provably the server; a launcher-reported PID - could be the (already-exited) launcher — the exact "reads a field whose value is wrong" - defect this codebase keeps hitting. -- **D3 — Stop by PID.** With no owned `ChildProcess` handle, `stop()` terminates the - server by its discovered PID (SIGTERM→SIGKILL, verify-gone), reusing the #1178 - kill-and-verify primitive. `detach()` becomes trivially correct: nothing to detach — - the server is already `PPID==1`; `detach()` only stops the log tailer. -- **D4 — Loss of the child-exit event is covered.** Without a handle there is no - `child.on("exit")`. Server-gone detection already lives in the keepalive path - (`server_keepalive.ts` deletes the handshake when pings stop answering); no new - mechanism is added. -- **D5 — Public contract unchanged.** `ServerManager` keeps `start()/stop()/detach()/ - onReady/url`; only the internals change from "own a child handle" to "daemonize → - discover PID → kill by PID". D5 means the *observable behavior* is unchanged (stop() - terminates the server and resolves once it is gone); the implementation changes. The - legacy piped path (no logFile, used by tests) is retained unchanged for backward compat. -- **D6 — stop() has a by-port fallback.** When stop() is called before the PID has been - discovered (server spawned, health not yet passed) or on a start() health-failure, - there is no server PID to kill. stop() then falls back to freeing the PORT via the - #1178 kill-and-verify primitive, which kills ONLY when the holder is our opencode - server (`isOpencodeServer`) and never a foreign process. This is the same discipline - that guards the reclaim path. -- **D7 — PID discovery, primary + fallback.** Primary: `pidHoldingPort(port)` (lsof) after - health — the listener is provably the server, and the launcher (which never binds the - port and has already exited) can never be returned. Fallback for lsof-absent minimal - containers: the launcher writes the SERVER's PID (its own detached child's pid, which it - knows) to a pidfile beside the handshake; the extension reads it after health. The - pidfile holds the server pid, so the "launcher PID is never persisted" invariant holds. - The two must agree when both are available; the lsof listener wins on disagreement. - -## Measurement Protocol - -A real-process harness (Node, ephemeral — no port 43117, no VS Code), runnable on the -CI Linux runner and locally on macOS, establishes the machine-checked criteria: - -- `daemon_reparented_off_spawner == 1` — after a daemonized spawn, the server's PPID is - neither the ServerManager-spawning process nor the intermediate launcher (both known - pids). On macOS and a plain Linux runner this is `PPID==1`; under a Linux child-subreaper - it is the subreaper — still off the spawner. The portable assertion is `ppid(server) ∉ - {spawnerPid, launcherPid}`, which is what defeats the ext-host subtree reap. -- `daemon_survives_spawner_exit == 1` — the spawning process exits; the server PID is - still alive afterward (1 = alive). -- `handshake_records_server_pid == 1` — the handshake written on daemonized spawn records - the **listener's** PID (== `pidHoldingPort(port)`), and `isPidAlive(recorded)` is true. -- `adopts_surviving_daemon == 1` — after the spawner exits, `adoptOrSpawn(handshakePath, - liveDeps)` against the surviving daemon returns `outcome == "adopted"` (1 = adopted). - This is the strongest automatable proxy for survive-reload: a real daemon, the real - gate, the real adopt path. -- `stop_terminates_daemon == 1` — `stop()` on a daemonized server (PID known) leaves the - PID gone. -- `stop_before_pid_known_frees_port == 1` — `stop()` invoked before health/PID-discovery - frees the port via the D6 by-port fallback (port has no listener afterward), and does - not kill a foreign holder (harness plants a non-opencode listener and asserts it lives). -- `health_gate_blocks_until_ready == 1` — `start()` does not fire `onReady`/write the - handshake until the port answers health (guards the extension side of the "not ready" - race). -- `targeted_suites_pass == 1` — the mocked/injected vitest suites (server_manager, - server_lifecycle, server_handshake, server_teardown, cold_spawn_handshake, overlay_cache, - and the new daemonize suite) pass. -- `typecheck_clean == 1` — `tsc` is clean. - -The harness kills every process it spawns on teardown and binds no fixed port. It NEVER -touches the live session's server (port 43117) or the live handshake. - -## Non-goals - -- Automating the real VS Code window-reload survival (human-gated; one Cmd+R post-deploy). -- The engine-side `/keepalive` self-shutdown route (deferred in #1147; unchanged here). -- Any change to the adopt gate's four checks, the #1178 teardown, or #1181 activation - wiring beyond passing the discovered PID and the by-PID stop. - -## Review (by hand — tooling=manual, `amico spec review` absent on this repo) - -No automated critic ran. Three independent lenses were applied by hand — a weaker claim -than tool-run, perspective-isolated critics, and recorded as such. No blocking -contradiction found (no two spec lines that cannot both be true). All findings are -advisories, resolved into the spec (round 2) or carried as plan obligations. - -- **Lens A — cross-module vocabulary (the recurring "reads a field its schema doesn't - carry" defect).** The PID must be the server's across {handshake write, adopt - `pidAlive`, stop kill}. Resolved: D7 (discover the listener via lsof post-health; pidfile - fallback holds the server pid, never the launcher's) + the explicit invariant. D5 wording - clarified: "contract unchanged" = observable behavior, not implementation. -- **Lens B — portability (CI Linux + WSL, not just macOS).** (B1, severe) `daemon_ppid == - 1` is fragile under a Linux child-subreaper → relaxed to `daemon_reparented_off_spawner` - (ppid ∉ {spawner, launcher}). (B2) the CI harness proves the POSIX double-fork under - plain node only; the Electron-as-node wrapper is human-reload-verified — stated as an - invariant + a launcher-command construction smoke check. (B3) lsof-absent → D7 pidfile - fallback. -- **Lens C — regression / #1178 orphan safety.** (C1) stop() before PID-discovery / on - health-fail → D6 by-port fallback + new criterion `stop_before_pid_known_frees_port`. - (C2) the by-port fallback keeps the ours-only kill discipline (foreign holder untouched) - — folded into D6 + the invariant + the criterion's foreign-holder assertion. (C3) - _ready/url driven by health/keepalive, not a child-exit event — stated as an invariant. - -Verdict: **approved-mechanical (by hand)** — tier 1 clean, three lenses applied manually, -all advisories resolved or tracked. Not equivalent to tool-run `approved`. Round 2. diff --git a/docs/adr/0023-base-tier-fleet-projection.md b/docs/adr/0023-base-tier-fleet-projection.md new file mode 100644 index 000000000..1bdf9a8e6 --- /dev/null +++ b/docs/adr/0023-base-tier-fleet-projection.md @@ -0,0 +1,75 @@ +# ADR 0023 — Base-tier fleet projection (a public floor under the amicissimo authority) + +- **Status:** accepted +- **Date:** 2026-09-18 +- **Context refs:** ADR 0005 (managed fleet), the fleet rearchitect (#1068 P3b-1, + #1106 P3b-2, #1194), spec-20260913-114814 (amicissimo the fleet authority), + `spec-20260904-fleet-boundary-and-thin-client` (the split + thin client). + +## Context + +The fleet rearchitect made **amicissimo** the single parser: its Python +`fleet_authority` package publishes a contract-v1 `projection.json`, and every +amicode consumer (extension `fleet_topology.ts`, `tools/fleet/install.sh`, the +`amico-opencode-fleet-guard`) reads **only** that projection — never the raw +`fleet.json`. The publisher is entitlement-gated (`PREMIUM_CODE = "amicissimo"`): +an unentitled or checkout-less machine hits the **bootstrap exception** +(`FLEET_BOOTSTRAP_EXIT = 75`) and every consumer honestly degrades to +base-standalone. + +That coupling has a consequence the rearchitect did not intend to be permanent: +**the base (public) product cannot run a hardened fleet client at all.** The +never-fork guard's `exit 1` fires only when the projection says `role: client`; +with no amicissimo, the projection never says that, so a client machine's guard +falls through and spawns a local server — the exact silent-fork the guard exists +to prevent (the 2026-08-07 incident class, #1227). A base-path user with two +machines (a hub + a roaming client) therefore has no enforced client. + +## Decision + +Add a **base-tier projection producer**: a TS-native path so that when the +amicissimo authority is unavailable (no entitlement, or entitled but no +checkout) **but the machine carries a `fleet.json` declaring a real role +(`client`/`server`)**, `amico fleet status --projection` renders and caches a +minimal contract-v1 `projection.json` **itself**, and returns success — instead +of exiting 75. + +Invariants held: + +1. **One reader, unchanged.** Consumers still read only `projection.json` + through `@amicode/schema`'s `readProjection`. We add a *producer* under the + authority, never a second consumer-side raw read. `assert_fleet_guard.sh` and + the single-parser test stay green by construction. +2. **amicissimo stays canonical.** Entitled + checkout present → the Python + publisher runs exactly as before (health, locks, program, org sections, the + provenance-stamped epoch). The base tier fills only `mode` + `topology` + (role + canonical) + a stable local epoch — the floor, not the rich surface. +3. **Surgical, minimal blast radius.** The base tier engages ONLY for an + enrolled machine (`fleet.json` role `client`/`server`). An unenrolled machine + (no `fleet.json`, or `role: standalone`) keeps the existing bootstrap-75 + behavior verbatim — all existing bootstrap tests are preserved. +4. **Honest provenance.** The base projection stamps + `publisher.identity = "amicode-base-tier"` and section provenance + `source: fleet.json`, so a reader can always tell a base-tier floor from the + amicissimo authority. The verb's JSON carries `base_tier: true`. + +### Freshness + +The base tier has no hub epoch, so it mints a **stable per-machine epoch** +persisted at `~/.amico/ops/fleet/base_epoch` (a UUID; reused across calls so +`freshnessBetween` compares within one epoch and never loops on "unknown"). +The counter is persisted beside the epoch and seeded from the publish +wall-second. Each publish atomically advances it, including when publishes land +within one second or the wall clock moves backwards. + +## Consequences + +- A base-path client is now enforced: guard reads `role: client` → `exit 1` → + the panel rides the tunnel, never forks. The installer stamps guard + settings + + tunnel from the base projection's `canonical`. +- The dual-role hub case (a workstation that is both hub and editor) becomes + expressible: the machine can carry a `client`/`server` role and the consumers + behave accordingly without amicissimo. +- No change to the entitled path, the contract version, or the consumers. +- When the base tier and amicissimo disagree, amicissimo wins (it runs whenever + present); the base tier is strictly the fallback floor. diff --git a/docs/adr/0024-fleet-thin-client-pluggable-transport.md b/docs/adr/0024-fleet-thin-client-pluggable-transport.md new file mode 100644 index 000000000..2df0256d1 --- /dev/null +++ b/docs/adr/0024-fleet-thin-client-pluggable-transport.md @@ -0,0 +1,135 @@ +# ADR 0024 — Pluggable transport under the thin-client fleet + +- **Status:** proposed +- **Date:** 2026-09-18 +- **Context refs:** ADR 0005 (managed fleet — this extends it), ADR 0002 (server-route + seam, per-boot password, loopback graft), ADR 0020 (standalone server survives reload), + ADR 0023 (base-tier projection), #792 (PRD: Fleet thin client — local shell, remote data), + and the interface/server decoupling (the engine / `amicode_service` / iframe three-actor + split, #451/#822/#391/#955). Robustness incidents: #775 (hub wedges under direct fleet + reconnect), #777 (first-attach probe impossible at high RTT). + +## Context + +The interface/server decoupling split the app into three actors: a loopback engine (stock +`opencode serve`, bound `127.0.0.1` by construction), an `amicode_service` that serves the +UI shelf and owns the `/amicode/*` routes and reverse-proxies everything else, and a chat +iframe that frames the *service* origin. The service already runs headless on a host +(`bootAmicodeServiceRunner`) and its `HubProxy` targets an arbitrary upstream URL — the +machinery for a thin client exists. + +**#792 owns the thin-client split** ("local shell, remote data") and this ADR does not +restate it. But #792 explicitly **defers the transport**: *"the tunnel remains the transport +under the relay."* It moves the UI local and leaves the single launchd SSH `-L` forward +underneath, unquestioned. That tunnel is the shared cause of two filed incidents: + +- **#777** — on a ~750 ms-RTT link every request takes ~2.5 s against a 1.5 s attach budget, + so a healthy hub is *unattachable*. Everything rides one high-latency tunnel. +- **#775** — the hub wedges under *direct* fleet reconnect; a front-door proxy is the + deployed mitigation, root cause open. Reconnection pattern, not request volume, is the + trigger. + +The transport also has structural weaknesses independent of those incidents: SSH `-L` wraps +the inner HTTP/TCP flow in a second TCP stream (TCP-over-TCP collapse under loss); a network +roam kills the tunnel and drops every stream; and the launchd agent is macOS-specific +(a Linux host needs systemd — already flagged in ADR 0005's accepted costs). + +Two invariants constrain any transport. **Loopback-only binding** (ADR 0002/0005): the host +binds `127.0.0.1`, and credential/solver **mutation** routes fail closed on any non-loopback +bind — the guard is a positive loopback allowlist (`localhost`/`::1`/`127.0.0.0/8`/`::ffff:127.*`), +so a Tailscale `100.x` address trips it. **Never-fork** (ADR 0005): a client holds no engine +and no store. + +## Decision + +Make the client↔host transport a **pluggable provider behind one config knob** +(`amicode.fleetTransport`), decoupled from the app. A provider yields a base URL + health + +lifecycle; the `HubProxy` already targets whatever URL it is given, so the app needs no +transport-specific code. + +Ship three providers: + +1. **`ssh` (default).** The current launchd `-L` forward, refactored behind the provider + seam; a systemd unit is the Linux implementation of the same provider. Zero new + dependency, works anywhere SSH + keys do. **This is the floor and the only required + provider.** +2. **`tailscale` (opt-in).** The host runs `tailscale serve` fronting its **loopback** + service; the client points the `HubProxy` at the MagicDNS origin. WireGuard gives roaming + (connection migration), NAT traversal (DERP relay fallback), and low latency over UDP — + no TCP-over-TCP collapse. Because the app still binds `127.0.0.1` and Tailscale's on-host + proxy is the only thing that dials it, **the loopback bind-guard is untouched** — this is + why `serve` is chosen over binding the engine to the tailnet IP directly. +3. **`direct` (a URL).** For a host already reachable on a VPN/LAN — the provider is just the + URL plus a health probe, no tunnel manager. + +**Tailscale is never mandatory.** SSH is the default; Tailscale is a recommended upgrade for +the roaming-laptop case, chosen per-machine. This is the honest answer to "do all users have +to run Tailscale" — no. + +The transport carries the full data plane to the host — engine routes, the `/amicode/*` +surface (per the confirmed *host-owns-all-state* decision recorded against #792), the event +stream, and WebSocket upgrades (the terminal). Those routing changes belong to #792; this +ADR governs only the pipe they travel through. + +## Invariants held + +1. **Loopback-only bind + mutation refusal unchanged (ADR 0002/0005).** The host binds + `127.0.0.1`; every provider proxies *to* loopback (`ssh -L` onto localhost, `tailscale + serve` onto localhost, `direct` onto a host that itself binds loopback behind its own + edge). The `100.x` case that would trip the guard is deliberately not the shipped path. +2. **Never-fork (ADR 0005).** The transport gives the client a data-plane connection, never + an engine — the client still holds no store. +3. **One topology reader (ADR 0023).** The provider is new machinery *under* the installer; + the projection/guard/installer contract and `assert_fleet_guard.sh` are unchanged. +4. **Standalone untouched.** The escape hatch and the local-files workflow both remain; the + transport seam engages only for a fleet client. + +## Consequences + +- The launchd tunnel stops being *the* transport and becomes the `ssh` provider's macOS + implementation; the per-platform installer story (launchd / systemd / Tailscale) becomes a + provider matrix rather than a special case. +- The provider seam realizes ADR 0005's aspirational "extension-owned Managed Tunnel" and its + "Fleet token" (the hub token, `~/.amico/fleet-hub.json`) without the launchd duct tape. +- #777's attach-budget fix and #775's front-door mitigation compose with, but are not + replaced by, this work — a better transport narrows the failure envelope both incidents + live in; it does not close either issue. +- Posture/hysteresis detectors (`fleet_posture.ts`, `fleet_poll_hysteresis.ts`) gain a + per-provider dimension (a Tailscale roam and an SSH drop have different signatures). +- **SSE reconnect is lossless only on the per-session stream (#1264, Slice 4).** The + transport carries the event stream, and a blip on it drops every event in the gap unless + the stream is resumable. Only the engine's per-session route + `/api/session/{id}/event?after=` is: it replays durable events after an aggregate + `seq`. The relay therefore tracks that `seq` **per session** and resumes via `?after=` on + reconnect, deduping the boundary (client-side, in `session_event_resume.ts` — no engine + change). The multiplexed `/event` (per-instance) and `/global/event` streams are + **deliberately out of scope**: they emit `id: undefined` (no cursor form) and + `/global/event` rides an in-memory `GlobalBus` with nothing to replay, so they are proxied + **as-is** (never given an `?after=`, never assumed resumable). Making them resumable is + separate engine work — emit SSE ids + a durable/vector-cursor live stream — coordinated + with the `GlobalBus` subscriber-lifecycle root cause (#775), not forked from it. + +## Flip conditions + +- If opencode upstream gains native remote-attach with identity + auth, adopt it and retire + the provider seam (inherits ADR 0005's flip condition). +- If `tailscale serve` latency or its dependency proves worse in practice than a hardened SSH + path, keep `ssh` as default and mark `tailscale` experimental rather than recommended. + +## Accepted costs + +- Three providers to maintain — mitigated: `ssh` is the only required one; `tailscale` and + `direct` are additive and independently disableable. +- `tailscale serve` adds an optional third-party dependency for the machines that opt in. + +## Considered + +- **Bind the engine directly to the tailnet IP (`100.x`)** — rejected as the default: trips + the loopback mutation guard (ADR 0002), forcing a security-invariant loosening. Available + as a documented power-user path, never the shipped model. +- **Keep the single SSH `-L` tunnel (status quo, #792's deferral)** — rejected: TCP-over-TCP + collapse, roam-fragile, macOS-specific, and the shared cause of #775/#777. +- **Mandate Tailscale for all fleet users** — rejected: forces a dependency on everyone incl. + non-mac; the pluggable seam delivers the robustness option without the mandate. +- **A cloud relay / rendezvous** — rejected for now: adds a hop and a hosted dependency the + SSH and Tailscale paths do not need; revisit only if inbound-NAT hosts become common. diff --git a/docs/adr/0025-remote-ssh-default-posture-thin-client-lifeboat.md b/docs/adr/0025-remote-ssh-default-posture-thin-client-lifeboat.md new file mode 100644 index 000000000..9408fd0c0 --- /dev/null +++ b/docs/adr/0025-remote-ssh-default-posture-thin-client-lifeboat.md @@ -0,0 +1,141 @@ +# ADR 0025 — Remote-SSH as the default fleet posture; the thin client as the degraded lifeboat + +- **Status:** proposed +- **Date:** 2026-09-19 +- **Context refs:** ADR 0005 (managed fleet — **this ADR partially supersedes its Remote-SSH + rejection**), ADR 0024 (pluggable transport under the thin client — **this ADR recasts, does + not retire, that work**), ADR 0002 (loopback graft / mutation refusal), ADR 0020 (standalone + survives reload). Program: #792 (thin-client PRD, merged), #1258 (durable hub, merged), #1260 + (pluggable transport, merged), #780 (posture, merged), #1267 (FileSystemProvider host mount). +- **Supersedes:** ADR 0005 lines 24–27 and 56 (Remote-SSH "rejected as the primary pattern" / + "wrong workflow") — overturned as a *direction*, gated as a *rollout* (see Sequencing). + +## Context + +ADR 0005 rejected Remote-SSH "as the primary pattern" with one specific reason (`0005:24-27`): + +> "Remote-SSH moves the user's whole editing context to the server (rejected as the primary +> pattern — 'chat on the laptop about the laptop's files' breaks)" + +and listed it again under Considered (`0005:56`): *"Remote-SSH pivot (zero fleet code, wrong +workflow)."* That objection assumed a **laptop-centric** workflow: the editing context is local, +so moving it to the server would split chat (local) from files (remote). + +**The thin-client program inverted that assumption.** The merged work (#792/#1262: host-owns-all- +state; #1258: durable host service; #1260: the transport carries the *full* data plane per +`0024:69-72`) makes the product **host-centric**: chat, sessions, `/amicode/*`, the event stream, +and the terminal all resolve to the host. The workflow is now "work *on the host* from a thin +panel" — which is exactly the workflow Remote-SSH is built to serve, and it delivers natively what +the thin client cannot: language servers, the integrated terminal on the host, debug, source +control, native search, and the **native Explorer showing host files** (the split reported by the +user; #1267 is the thin-client's partial answer, and an honestly hollow one — a virtual-scheme +mount carries files but none of that tooling). + +**The honest counter, which gates this decision.** ADR 0024 documents the fleet link as genuinely +fragile (`0024:26-35`): at ~750 ms RTT a healthy hub is *unattachable* against a 1.5 s budget +(#777); SSH `-L` collapses under loss (TCP-over-TCP); a network roam drops every stream. Remote-SSH +puts the *entire* editor on that link, and when the link degrades it freezes everything (the +extension host is on the far side), which is **worse** than the thin client — whose whole reason to +exist is to keep the local shell alive on a bad link. So Remote-SSH is the better default **only +when a degraded link falls back to the thin client**, and that fallback is not yet buildable +(`extensionKind: ["workspace"]` puts the switch sensor on the dead far side of the link). + +## Decision + +1. **Adopt Remote-SSH as the *target* default fleet posture** for a client attaching to a host — + the "as if working on the host locally" experience (native Explorer, LSP, terminal, debug, git, + search on the host). +2. **Recast the merged thin-client program as the degraded / roaming lifeboat**, not the default + and not waste: it is the posture the product falls back to when the link cannot carry a live + Remote-SSH session. Everything merged for #792/#1258/#1260/#780/#1264 serves the lifeboat. +3. **Gate the default flip behind its prerequisites (Sequencing).** Until they land, **Remote-SSH + is opt-in and the thin client remains the shipping default** — this ADR sets direction and + sequence, it does not flip the default on merge. + +## Sequencing (the flip is earned, not declared) + +- **P1 — the lifeboat shows host files (#1267, filed).** The `amico-host://` FileSystemProvider + + honest capability label, so dropping to the thin client keeps host files in the native Explorer. + Ships first; independent of the default flip. +- **P2 — a UI-kind extension split.** A companion component that runs on the *client* even under + Remote-SSH (where `extensionKind: ["workspace"]` otherwise puts everything on the host), giving + the link sensor an invariant local home. Prerequisite for any auto-switch; the critics' blocking + finding was that without it the auto-DOWN sensor sits on the dead far side of the link. +- **P3 — posture-driven switch.** Auto-DOWN to the lifeboat on sustained link loss; prompt-UP to + Remote-SSH on sustained recovery. Reuses the merged link-health detector (`fleet_posture.ts`, + founding case "750 ms plane wifi"), retuned for a window-reopen (not a status-bar popup), with a + switch-frequency floor, a dirty-editor guard, and cross-scheme editor-URI carry. The chat is + continuous across the reopen because it is host-owned (#1262); the host-file view is continuous + because both postures show host files (native `file:` under Remote-SSH, `amico-host://` under the + lifeboat). + +**Only when P1–P3 are in does the default flip to Remote-SSH.** If P2 proves infeasible, the flip +does not happen (see Flip/abort). + +## Invariants held + +1. **Loopback-only bind + mutation refusal (ADR 0002/0005).** Unchanged in both postures: under + Remote-SSH the host runs the engine/service on `127.0.0.1` (VS Code's remote channel is the + transport); under the lifeboat the transport providers proxy to loopback per `0024:76-79`. + Remote-SSH does **not** introduce a non-loopback bind. +2. **Never-fork (ADR 0005).** A client still holds no engine and no store in either posture. + Remote-SSH runs the engine on the *host*, adopting the durable hub service (#1258); it does not + spawn a client-side shard. +3. **One topology reader (ADR 0023).** The posture machinery reads the projection; this ADR adds no + second reader. +4. **Window-mode is a separate axis from link-health posture.** `FleetPostureMode` + (fleet/degraded/standalone) is single-writer, transition-only; "which window mode am I in" + (Remote-SSH vs lifeboat) takes its **own** field — never an overload of the link-health mode. +5. **No silent fallback.** An unreachable host is the honest degraded posture; the switch to the + lifeboat is an explicit, surfaced transition, never a silent reroute. + +## Consequences + +- **ADR 0024 is recast, not retired.** The pluggable transport (ssh/tailscale/direct), the durable + hub, the SSE resume, and the `/amicode/*` proxy become the lifeboat's machinery. `tailscale` + gains importance: a roaming client that wants to *stay* in Remote-SSH on a changing network is + best served by WireGuard connection migration, so the transport seam serves Remote-SSH too, not + only the lifeboat. +- **A new dependency on VS Code Remote-SSH** as a first-class, tested posture: the durable hub + (#1258) must be reachable as a Remote-SSH target; the extension must behave correctly when it + relocates to the host (it already declares `extensionKind: ["workspace"]`, as devcontainer/WSL + already exercise). +- **The chat webview rides the Remote-SSH channel** in the default posture: on a good link this is + fine; on a bad link it is the reason P3 exists. This ADR does not claim Remote-SSH makes chat + *faster* on a bad link — it does not; it claims the *default* experience is richer on a good link + and that the product degrades honestly to the lifeboat on a bad one. +- **The reported file-surface split is closed twice over:** natively under Remote-SSH (the default), + and via the FSP mount under the lifeboat (#1267). + +## Flip / abort conditions + +- **Abort the default flip if P2 (the UI-kind split) proves infeasible.** Remote-SSH then remains a + documented opt-in posture and the thin client stays the default — the lifeboat becomes the whole + story, and this ADR's direction is not realized. This is a real possible outcome, stated up front. +- **Retire the provider seam if opencode upstream gains native remote-attach with identity + auth** + (inherits ADR 0005's and ADR 0024's flip condition — adopt rather than maintain). +- **Keep `ssh` as the lifeboat floor** if `tailscale serve` proves worse in practice (inherits + `0024:116-117`). + +## Accepted costs + +- Two first-class postures to test and surface, plus the switch between them — mitigated by + sequencing (P1 ships alone; the default flip waits for P2/P3) and by reusing the merged detector + rather than forking one. +- Remote-SSH's own connect/reconnect UX and host-side server install become part of the supported + surface. +- The merged thin-client program is demoted from "the remote story" to "the degraded story" — an + honest re-scoping of recent work, recorded here rather than smuggled. + +## Considered + +- **Keep the thin client as the default; Remote-SSH opt-in only (the review's recommendation).** + Rejected as the *direction* per the human decision to pursue the richer host-native default — but + retained verbatim as the **abort state** if P2 is infeasible, and as the shipping state until + P1–P3 land. The disagreement between this ADR and the review is therefore about sequencing and + end-state, not about what ships first (#1267 ships first either way). +- **Flip the default to Remote-SSH now.** Rejected: without P2/P3 a bad link freezes the editor with + no lifeboat — the exact fragility (`0024:26-35`) the thin client was built to survive. +- **A virtual FS (#1267) as the whole answer, no Remote-SSH.** Rejected as the end-state: a + custom-scheme mount is a hollow host surface (no LSP/terminal/debug/git/search); it is the + lifeboat's file bridge, not the host-native experience. diff --git a/docs/adr/0026-generalizable-machine-capabilities-and-host-owned-roster.md b/docs/adr/0026-generalizable-machine-capabilities-and-host-owned-roster.md new file mode 100644 index 000000000..b614a8229 --- /dev/null +++ b/docs/adr/0026-generalizable-machine-capabilities-and-host-owned-roster.md @@ -0,0 +1,141 @@ +# ADR 0026 — Generalizable machine capabilities + a host-owned fleet roster + +- **Status:** accepted +- **Date:** 2026-09-20 +- **Context refs:** ADR 0005 (managed fleet — defines `Server mode`), ADR 0023 (base-tier + fleet projection — the one-parser invariant), ADR 0025 (Remote-SSH default / host-owns-all- + state), ADR 0002 (loopback bind / mutation refusal). Program: #792 (thin-client, merged), + #1258 (durable hub, merged), #1260 (pluggable transport, merged), #1262 (`/amicode/*`→host + proxy, merged), #780 (posture, merged). +- **Supersedes:** nothing. Additive to the fleet contract. + +## Context + +Today a machine's role in the fleet is exactly its **Server mode** — the closed union +`standalone | server | client` (ADR 0005) that says *where sessions are served from*. That +union is load-bearing: the never-fork guard, `tools/fleet/install.sh`, three TypeScript +consumers, and the external `amicissimo` parser all branch on it, and ADR 0023 makes +`amicissimo` the single parser of `fleet.json`. + +Users want to say more than serve-stance. The motivating asks — "this laptop is Amicode on the +go," "this machine is the server," "this box runs solve jobs" — decompose into three *different* +kinds of statement: a serve-stance (`client`/`server`, which `Server mode` already covers), a +descriptive intent (`roaming`), and a compute capability that **has no backing infrastructure** +(a solve executes `local` or `remote`=Harmoniqs Cloud; a fleet *peer* as a compute target does +not exist). Overloading one enum to carry all three conflates orthogonal axes and forces a +cross-repo `amicissimo` contract change for every new label. + +Separately, there is **no fleet-wide device roster** anywhere in the contract. The projection's +`topology` section describes only *this* machine's `{role, canonical}`; the `devices[]` list in +the `health` section is a **hardcoded bash array** in `ops/fleet-status.sh`, not derived from +membership. A "show me my fleet" surface has no roster to read. + +## Decision + +1. **Two axes, not a widened enum.** Keep `Server mode` (`standalone|server|client`) as the + serve-stance, unchanged and still the sole driver of the guard/tunnel/hub-service behavior. + Add an **orthogonal, open set of capability tags** per machine. Tags come in two classes: + - **Known, behavior-driving** — `compute` (see #4) and `roaming` (a hint that defaults a + machine's transport to `tailscale`). + - **Descriptive** — any free tag or human nickname, with zero behavioral meaning. + +2. **The capabilities live in an amicode-owned roster, not in `fleet.json`.** Introduce + `~/.amico/ops/fleet/roster.json` on the **Canonical Server**. `fleet.json` is **untouched** + (role + canonical only), so ADR 0023's one-parser invariant holds and this needs **zero + `amicissimo` changes**. Each machine owns its own roster row (a registry/heartbeat model, no + dual-writer conflict); the authoritative per-machine role remains its own `fleet.json`, and + the roster row is the reconciled self-report. + - Row shape: `{ machine_id, name, server_mode, capabilities[], sshAlias, transport, + last_report, health }`; schema-versioned. `health ∈ { reachable, degraded, down }` (a + per-device reachability tri-state — distinct from the *link-health* posture vocabulary + `ok/degraded/hub-down`, which is about this machine's link to the hub, not a peer's + reachability). The UI renders `server_mode` as "role" and `last_report` as "last-seen" — + same fields, display labels. + - Rows are written by the machine itself (a self-report over the authenticated `/amicode/` + data plane — the accept-both service/engine mint of #822, to a loopback-bound host, NOT the + ADR-0005 Fleet token, which guards SSH enrollment) or by the `/create-a-fleet` orchestrator + acting for it during enroll. A write only ever touches the reporting machine's own row. The + reachability status job (`ops/fleet-status.sh`) probes per roster row, retiring its + hardcoded device array. + +3. **Surface the roster via a host route on the proxied `/amicode/` namespace — not the + per-machine projection, and NOT under `/amicode/fleet/*`.** The projection is published + *locally* per machine from that machine's own `fleet.json`, so it is the wrong vehicle for + fleet-wide state. Instead the host serves the roster at **`GET /amicode/roster`** (read) and + accepts the self-report at **`POST /amicode/roster`** (write), and every client obtains the + identical fleet-wide roster through the already-merged `/amicode/*`→host proxy (#1262). + **Critically, the roster must live OUTSIDE `/amicode/fleet/*`:** that prefix is the client's + own *local* honesty surface (its posture/mode/staging), which `shouldProxyAmicodeToHost` + (`amicode_service/server.ts:250`) deliberately refuses to proxy so a client's own status can + never be masked by the host's — verified by `fleet_client_relay.test.ts` (the host never sees + `/amicode/fleet/*`). A roster route under that prefix would 404 on every client. Every OTHER + `/amicode/*` path IS proxied to the host (`server.ts:251` returns `true`), so `/amicode/roster` + is carried unchanged — the read as a GET proxy, the write as a proxied mutation to the + loopback-bound host (the accepted-mutation path `fleet_client_relay.test.ts` covers). This is + consistent with the host-owns-all-state model ADR 0025 adopts, and adds no second topology + reader (ADR 0023 stays green). + +4. **`compute` is declared-but-inert.** The capability model, the enroll flow, and the Fleet + Manager UI all carry `compute`, but it **drives no solve routing**: a fleet-peer executor + (SSH-dispatch a solve to a compute-tagged machine + artifact sync-back + tier/warrant + integration) is a greenfield subsystem and an explicit **non-goal** of this work, named for a + future issue. The UI shows `compute` with an honest "not yet wired" affordance. `compute` is + the first known behavior-driving tag; it proves the open-capability pattern without forcing + the subsystem. + +## Approaches considered + +- **Widen the `Server mode` enum** (add `cluster`, `laptop`, …) — rejected: conflates + serve-stance with capability, breaks the closed union the guard + `amicissimo` + three + consumers depend on, and forces an `amicissimo` contract change per new value. (It also walks + into the glossary's `Fleet` `_Avoid_: cluster`.) +- **Put `capabilities[]` in `fleet.json`** — rejected: cleanest single source per machine, but + forces a cross-repo `amicissimo` parser change (or capabilities vanish on machines that *have* + `amicissimo`), which can block the UI on external work. +- **Capabilities in VS Code machine settings** — rejected: fully amicode-owned, but each machine + sees only itself; there is no cross-fleet roster, so a manager view cannot show peers. +- **Roster in an additive projection section** — rejected in favor of the host route: the + projection is locally published, so it cannot carry fleet-wide state to a client without a new + aggregation path; the host route + #1262 proxy already exists. +- **A roster route under `/amicode/fleet/*`** — rejected: that prefix is the one namespace + `shouldProxyAmicodeToHost` (`server.ts:250`) refuses to proxy (the client's own local honesty + surface), so it would 404 on every client. The roster is host-owned fleet-wide state, the + opposite of a local honesty surface, and belongs on the proxied `/amicode/roster` path. + +## Invariants held + +1. **One topology reader (ADR 0023).** The roster is a *new amicode-owned artifact*, not a second + `fleet.json` parser; the guard-assert gate stays green. +2. **Never-fork (ADR 0005).** Enroll on a client installs the guard (`role: client` → `exit 1`); + no client spawns an engine. Capabilities never change serve-stance behavior. +3. **Loopback bind + mutation refusal (ADR 0002/0005).** The roster self-report write rides the + authenticated `/amicode/` data plane (the accept-both service/engine mint, #822) to a + loopback-bound host — the same inject-the-credential, keep-loopback model #1262 uses; the new + write route calls the `bind_host` loopback check like the other mutation routes + (`solver_mode.ts`, `connections.ts`). +4. **No silent fallback (ADR 0024/0025).** Roster health reflects honest reachability; nothing + reroutes silently. +5. **Server mode is the only serve-stance authority.** Capabilities are orthogonal metadata; a + tag never overrides the guard/tunnel/hub-service decision. + +## Consequences + +- The generalizable role model the product wanted lands without a cross-repo contract change; the + first two useful tags (`roaming`, `compute`) ship immediately, one live and one honestly inert. +- The fleet gains a real device roster; `ops/fleet-status.sh` stops hardcoding membership. +- A future fleet-peer executor can make `compute` live without re-opening this ADR — it only adds + behavior behind an already-modeled tag. +- `amicissimo` may later enrich the roster; amicode owns the public floor, mirroring ADR 0023. + +## Non-goals + +- The fleet-peer solve executor (the subsystem that would make `compute` live). +- Any change to `Server mode`'s union or the `fleet.json` contract. +- `amicissimo` parser changes. + +## Source + +- Design of record: the fleet-setup PRD-parent, #1316 (this branch's + `feature/free-tier-fleet` design-of-record), which decomposes into **five** slice sub-issues: + capability model + roster (#1318) · enroll primitive (#1319) · `/create-a-fleet` orchestrator + (#1320) · sidebar section (#1321) · Fleet Manager tab (#1322). diff --git a/docs/adr/0027-peer-fleet-studios-and-compute-federation.md b/docs/adr/0027-peer-fleet-studios-and-compute-federation.md new file mode 100644 index 000000000..2738990cc --- /dev/null +++ b/docs/adr/0027-peer-fleet-studios-and-compute-federation.md @@ -0,0 +1,189 @@ +# ADR 0027 — Peer fleet studios, with compute federation as the declared end-state + +- **Status:** accepted — Decision #1 (§D1, "Additive peer topology") superseded by ADR 0029 +- **Date:** 2026-09-20 +- **Context refs:** ADR 0005 (managed fleet — defines `Server mode`, the never-fork guard), ADR 0023 + (base-tier projection — the one-parser invariant), ADR 0024 (pluggable transport), ADR 0025 + (Remote-SSH default / host-owns-all-state), ADR 0026 (generalizable capabilities + host-owned + roster), ADR 0002 (loopback bind / mutation refusal). Program: #1316/#1323 (fleet setup + + generalizable roles + Fleet Manager, merged on `feature/free-tier-fleet`). +- **Spec:** `config/specs/spec-20260920-213918-peer-fleet-compute-federation.md` (reviewed: + tier-1 mechanical `approved-mechanical`; judgment critics run by-hand — see that spec's Review). +- **Supersedes:** nothing. **Additive.** It amends ADR 0025's *single-canonical-hub* premise to + "one hub **or** many peers," and leaves the hub path (enroll, guard, Remote-SSH) byte-unchanged. +- **Amended by:** + - ADR 0028 (#1368) — lifts this ADR's byte-freeze of the enroll verb + (`fleet_enroll_verb.ts`) so it can produce self-reported device identity on the roster row. + The additive-invariants gate's AC1 `FROZEN` set drops the enroll verb accordingly; the + never-fork guard freeze and the one-parser / single-writer / no-client-stance invariants + (AC2 / AC3 / AC4 + `assert_fleet_guard.sh`) remain in force. + - ADR 0029 (accepted) — retires Decision #1's "additive, two topologies" framing: there is + **one fleet**, and `serving` is a per-device capability, not a topology or role. Decision #1's + **mechanism** (independent studios, the multiplexing service, the §D2–§D8 substrate seams) is + retained and generalized to the default; only its hub-vs-peer **framing** is superseded. + +## Context + +A fleet today is a **star**. `amico fleet enroll` (#1316/#1323) makes exactly one machine +`role=server` and every other machine a `role=client` whose never-fork guard +(`amico-opencode-fleet-guard`, `exit 1`) forbids it from running an engine — it tunnels to the one +hub and shares the hub's database. ADR 0025 reinforces this (Remote-SSH default, host owns all state). + +Two observations reframe the goal: + +1. **"Host a server" has two senses.** Running an engine *for yourself* is already universal: no + `fleet.json` ⇒ `standalone`, and a standalone machine spawns/adopts its own loopback engine. + Serving *for others* is the designated `role=server`, capped at one, with clients barred from + serving. +2. **The north star is server-to-server agent spooling** — engines dispatching agents/subagents to + one another and streaming results back. This is *impossible on one host by definition* (one + engine, nothing to spool), and the star path provisions the wrong substrate for it (never-fork + clients that cannot accept a dispatched agent). + +The merged branch already built a **topology-agnostic foundation** this ADR reuses: the host-owned, +fleet-wide `roster.json` (`GET/POST /amicode/roster`), the open `capabilities[]` axis +(`compute`/`roaming`; `compute` shipped "declared but inert"), `fleet_discover.ts` (roster + +`~/.ssh/config` + tailnet), the roster-driven `ops/fleet-status.sh`, the Fleet Manager tab, the +`amicode_service` reverse proxy (`shouldProxyAmicodeToHost`, `server.ts:242`), and `fleet_transport.ts`. + +## Decision + +**Two horizons, one substrate.** Ship **peer studios** (Horizon 1) as an additive mode built on that +foundation, and design its substrate deliberately for **compute federation** (Horizon 2), which this +ADR declares but does not build. + +1. **Additive peer topology.** Peers coexist with the hub/star mode; the hub `enroll`, the never-fork + guard, and ADR 0025 remain for the thin-client→server case. A peer stays `standalone` and + *advertises*; it never takes the `client` stance, so the guard is never triggered and never relaxed. + + > **Superseded by ADR 0029 (accepted).** One fleet; `serving` is a per-device capability, not a + > topology. The independent-studios mechanism (§D2) is retained and becomes the default; the thin + > client survives as an explicit `hosted-only` capability, not a topology. See ADR 0029 for the + > authoritative model. + +2. **Independent studios + a multiplexing `amicode_service`.** Each peer runs its own engine on its + own DB (single-writer-per-DB preserved *per machine* by the existing adopt-or-spawn gate). The + loopback service holds N keyed upstreams and re-targets by a **three-way resolver** generalizing + `shouldProxyAmicodeToHost`: the local honesty surface → local; `/amicode/roster` → the **keeper** + (§4); every other `/amicode/*` + engine + SSE → the **attached server**. **When the attachment + pointer is empty (a fresh standalone peer), the studio branch routes to the local engine** — the + attached branch is never undefined. + +3. **Attach/switch is a first-class action.** A **switch-control pointer** (its state and its + never-proxied local endpoint) is owned in one place; the resolver and the switch consume it. + **Switching** flips the pointer and **resets the SSE cursor** (a different server is a fresh + stream, never a `?after=` resume). An **attach/detach action** — an `amico`/service verb *and* a + Fleet Manager "Attach" control on the existing device rows — adds/removes an upstream and drives + the pointer, with the roster as the candidate list. (The baseline Fleet Manager reads + `server.current` but never switches; this is the decision that adds switching.) + +4. **Directory keeper, resolvable and decoupled from the hub.** Reuse the host-owned `roster.json` + and its routes; the "keeper" is a *registry* role, **not** "the only server" — every peer still + serves and can be attached to. The keeper's coordinate is carried by an explicit **keeper + bootstrap pointer** (a resolvable address, sibling to the switch pointer), **not** discovered from + the very `roster.json` the keeper hosts (which would be circular). Peers self-report their row to + the keeper (single-writer upsert, unchanged). + +5. **Serving advertisement = a placement-ready capability.** A peer advertises its already-running + engine via a roster `serving` tag plus reach coordinates, designed as a **placement descriptor** a + future scheduler reads (reachable? serving? headroom?), not a display-only chip. It rides the open + `capabilities[]` axis (ADR 0026); it does **not** change `server_mode` (still the sole serve-stance + authority) and needs **zero** `fleet.json`/`amicissimo` change. + +6. **Per-attachment transport — SSH default, engine-reachable, no forced Tailscale.** Reuse + `fleet_transport.ts`. Each transport is an authenticated, **engine-reachable channel** + (bidirectional-capable) so Horizon 2's engine↔engine RPC reuses it. SSH is the universal default; + `roaming`→tailscale and direct are opt-in. `fleet_discover.ts` already treats `~/.ssh/config` as a + first-class source, so no one is forced onto a tailnet. + +7. **The two recorded federation seams (this ADR is where they are decided):** + - **Peer-trust credential — a NEW peer token.** Horizon 1 injects the existing UI client mint + (#822/#1262) per attachment. For Horizon 2, peer-to-peer engine RPC is a *distinct* trust act + (server A dispatching arbitrary agent compute to server B) and gets its **own** credential: a + per-peer, separately-mintable and **separately-revocable** *peer token*, keyed by the machine's + roster `machine_id`. It is deliberately **not** the ADR-0005 Fleet token (which guards SSH + *enrollment*, a different act) and **not** the UI client mint (which authorizes a human's window, + not a peer engine). Recording this now means Horizon 2 needs no credential re-plumbing. + - **Event-relay seam — the multiplexing service's SSE path.** Horizon 1 exercises only the + cursor-reset-on-switch (§3). For Horizon 2, the relay point is the same service SSE path: a + remote child's events carry the **originating session id** and are **merged into that session's + stream** over the per-attachment channel. The named consumer is the Horizon-2 executor's + result/event path. Recording this now means the stream model does not need re-shaping later. + +8. **Horizon 2 is declared, not built.** The compute-federation executor — server-to-server dispatch + RPC, a placement scheduler (reading the §5 descriptor), artifact/result sync-back, inter-server + trust made live (the §7 peer token), and event relay/merge (the §7 seam) — is the end-state this + ADR names and an explicit **non-goal** of the Horizon-1 build. It makes `compute` live behind the + tag ADR 0026 already ships. **Dispatched work is stateless work owned by the originating session** + — the executor never grows a shadow session the originator must reconcile, so independent studios + (§2) stay intact. + +## The five substrate seams (Horizon-1 obligations for Horizon 2) + +1. **Engine-reachable transport** — §6. +2. **Peer-trust credential** — §7 (the new peer token). +3. **Placement-ready capability descriptor** — §5 (`serving`/`compute`). +4. **Spawn-path placement target** — an optional target on `amicode_session`/Task dispatch, defaulting + to `local`, so Horizon 2 populates it without re-plumbing the spawn path. +5. **Federatable event stream** — §7 (the SSE relay seam). + +## Approaches considered + +- **Keep one host (star only)** — rejected: server-to-server spooling is impossible on one engine, + and the never-fork client is the wrong substrate for it. +- **Replace the star with peers** — rejected: discards the merged `enroll` flow and the thin-client + case ADR 0025 targets. Additive keeps both. +- **Widen `Server mode` to carry "serving"** — rejected: conflates serve-stance with capability and + breaks the closed union the guard + `amicissimo` depend on (the same reasoning ADR 0026 used). The + advertisement is a capability tag, not a new role. +- **Un-pin the webview's native multi-server switcher (client-side N origins)** — rejected for the + data plane: it bypasses the `amicode_service` `/amicode/*` ownership, credential translation, and + the single-origin CSP/auth model. The switcher UI is reused as the front-end that drives the pointer. +- **Force Tailscale for multi-peer reach** — rejected: SSH is the universal default; tailscale is + opt-in via `roaming`. +- **Reuse the ADR-0005 Fleet token (or the UI mint) for peer RPC** — rejected: each conflates a + distinct trust act; Horizon 2 gets its own revocable peer token (§7). + +## Invariants held + +1. **One parser of `fleet.json` (ADR 0023).** Everything new is amicode-owned (roster + service + registry + switch pointer). Zero `amicissimo` changes. +2. **Never-fork + single-writer-per-DB (ADR 0005).** The guard and `enroll` are byte-unchanged; peers + never take the `client` stance; adopt-or-spawn keeps ≤1 writer per machine's DB. Attaching to a + peer is a proxy operation that spawns no engine. (Amended by ADR 0028: the guard stays + byte-unchanged; the `enroll` verb's byte-freeze is lifted for device-identity production — its + never-fork / single-writer semantics remain enforced by the guard freeze + AC2/AC3/AC4.) +3. **Loopback bind + mutation refusal (ADR 0002).** The multiplexer keeps every upstream credentialed + and loopback-bound; the roster self-report and the switch-control write ride the authenticated + `/amicode/` plane to a loopback host. +4. **Local honesty surface.** `/amicode/fleet/*` and the switch-control pointer never proxy, so a + peer's own posture/attachment can never be masked by a keeper's or a peer's. +5. **`Server mode` remains the only serve-stance authority.** The `serving` advertisement is orthogonal + capability metadata. + +## Consequences + +- The product's "any capable machine can serve, and I can hop between them" lands as an additive mode; + the thin-client→hub case and ADR 0025 keep working. *(ADR 0029 reframes: the "additive mode" framing + is retired — there is one fleet; `serving` is a per-device capability, own-engine is the default.)* +- The merged roster/discovery/status/UI are reused as the directory and front-end; the near-term build + is a multiplexing service + a switch pointer + an Attach control, not new plumbing. +- The Horizon-2 executor can make `compute` live without re-opening this ADR — the transport, the peer + token, the placement descriptor, the spawn-path target, and the event-relay seam are all designed for + it here. + +## Non-goals + +- The Horizon-2 compute-federation executor itself (dispatch RPC, placement scheduler, artifact + sync-back, inter-server trust made live, event relay/merge). Named here; specced/built later. +- Making `compute` live (stays "declared but inert"). +- Any change to `FleetConfig`/`fleet.json`, the hub `enroll` flow, the never-fork guard, or the + `amicissimo` contract. (Amended by ADR 0028: the `enroll` verb's device-identity *producer* is + a sanctioned change; the never-fork guard and `fleet.json`/`FleetConfig` contract stay frozen.) +- Replacing the hub/star mode or ADR 0025's Remote-SSH posture. + +## Source + +- Design-of-record spec: `config/specs/spec-20260920-213918-peer-fleet-compute-federation.md` + (15 falsifiable acceptance criteria, Horizon-1 slice order with dependency edges). +- Program precedent: #1316/#1323 (merged foundation), ADR 0026 (the capability + roster substrate). diff --git a/docs/adr/0028-self-reported-device-identity.md b/docs/adr/0028-self-reported-device-identity.md new file mode 100644 index 000000000..1940cf247 --- /dev/null +++ b/docs/adr/0028-self-reported-device-identity.md @@ -0,0 +1,163 @@ +# ADR 0028 — Self-reported device identity (friendly name + device type) in the roster + +- **Status:** proposed +- **Date:** 2026-09-21 +- **Context refs:** ADR 0026 (host-owned roster + capability model — defines the row and the + single-writer self-report), ADR 0023 (one-parser invariant — `fleet.json` untouched), + ADR 0025 (host-owns-all-state). Program: #1318 (roster + capability model, merged), + #1319 (enroll primitive, merged), #1321 (sidebar section, merged), #1359 (`device_type` + field + type pill, merged), #1363 (client peer visibility — proxy-read + server-node + synthesis, merged). +- **Supersedes / amends:** ADR 0027's byte-freeze of the enroll verb. ADR 0027 (#1346) + froze `packages/amico-run/src/fleet_enroll_verb.ts` (+ its test) byte-for-byte to prove + *its* peer-studio work was additive; producing self-reported device identity requires the + enroll producer to write `name`/`device_type` onto the roster row, so this ADR lifts that + whole-file freeze — the additive-invariants gate's AC1 `FROZEN` set drops the enroll verb. + ADR 0027's *actual* safety invariants are untouched: the never-fork guard shims stay + byte-frozen, and one-parser / single-writer / no-client-stance remain enforced (the gate's + AC2 / AC3 / AC4 + `assert_fleet_guard.sh`). Otherwise additive to the roster contract. + +## Context + +The roster row (ADR 0026, since enriched by #1359) already carries a `name` and an optional +`device_type`, and the sidebar already renders them (`typeLabel = device_type ?? server_mode`). +But nothing **produces** meaningful values: the enroll verb writes a row with +`name = hostname()` (the raw FQDN, e.g. `JVs-MacBook-Pro.local`) and no `device_type` at all. + +The consequence is a split identity. Each machine already labels *itself* nicely on its own +sidebar — the extension synthesizes a local self-row from `scutil --get ComputerName` (the +user's chosen name, e.g. "Test Laptop") and a `system_profiler`-derived form factor. But +that derivation lives only in the extension, so it never reaches the **roster row a peer reads**. +A machine and its peers therefore disagree about the machine's name and type: the server sees +`JVs-MacBook-Pro.local / client` while the laptop calls itself `Test Laptop / laptop`. + +The disagreement is symmetric. A client never sees a friendly server either: the client +synthesizes the canonical-server node (#1363) from `fleet.json`'s `canonical.host`, so it renders +the raw host string (`Desktop.local`) with a hardcoded `server` type — because the +server does not self-register a roster row at all. + +## Decision + +1. **One derivation, shared by producer and consumer.** The name+type derivation becomes a + machine's **self-report**, computed by logic shared between the enroll producer + (`amico-run`) and the extension self-row — so a machine and its peers cannot disagree by + construction. The **pure** classification lands in `@amicode/schema` beside + `KNOWN_DEVICE_TYPES` (the vocabulary it targets): `classifyMacModel` (the rehomed existing + classifier), `classifyLinuxChassis`, `normalizeDeviceName`, and `isWslKernel` (the pure core + of the proven `rebuild/host_matrix.ts` WSL check). Every one is pure and unit-testable on any + OS from string inputs. + +2. **The impure shell stays per-package; `@amicode/schema` stays `child_process`-free.** The + `execSync` calls (`scutil`/`system_profiler` on darwin, `hostnamectl`/`/sys` DMI on linux) + live in a thin (~25-line) platform-branching caller in *each* node package (extension host + + `amico-run`), each delegating all judgment to the shared pure functions. Schema must not gain + `child_process`: `sidebar_fleet_section.ts` is browser-bundled and deliberately node-import- + free, and a `child_process` import in a shared contract module risks poisoning that bundle. + +3. **Cross-platform, honest where detection is unreliable.** macOS uses the existing commands; + Linux uses `hostnamectl --pretty` (the `PRETTY_HOSTNAME`, the Linux analog of the macOS + ComputerName) for the name and, for the type, `hostnamectl` chassis **first** then + `/sys/class/dmi/id/chassis_type` **second** then abstain — mapping + `laptop|notebook|portable → laptop`, `desktop|tower → desktop`, `server|rack → server`. + `hostnamectl` absent (minimal container / no systemd) ⇒ prettified hostname + omitted type. + **WSL is detected and abstains** to an undefined type — the WSL VM's chassis does not reflect + the physical machine. Resolution precedence for both fields: **explicit setting → OS detection + → prettified hostname (name) / `undefined` (type)**. The override reads **one namespace both + producers share**: `amicode.device.name` (already read by the extension self-row) plus a new + `amicode.device.type`; naming a *different* key on the producer would reintroduce split + identity on the override path, so this is a load-bearing choice, not a detail. An undefined + type is honest: the sidebar already falls back to `server_mode`. The friendly `name` is + derived on a seam **kept separate from the `canonical.host` derivation** — a friendly name + must never leak into `canonical`/the minted join token. + +4. **The server self-registers, keyed by its canonical host — and its own self-row agrees.** + `enroll --as-server` POSTs its own roster row to the loopback `/amicode/roster` **after + verifying the hub is reachable** (retry-or-honest-fail, like the client verify-attach path — + the pin probe's `dev` fallback tolerates an unreachable hub and is skipped when a version is + injected, so it is *not* proof of reachability), reusing the validated `parseRosterRow` + + single-writer upsert path. The server's row uses **`machine_id = canonical.host`** — the same + identity a client references it by — so the client's existing `maybeAddCanonicalServer` + de-dupe (`d.machineId === server.machineId`) collapses the synthesized node against the real + row with **zero client-side change**. But the client view is only half of it: the server's + OWN self-row is keyed by `os.hostname()`, which differs from `canonical.host` under a + non-default `--host` or macOS FQDN drift (the live fleet's `Desktop.local` is + exactly this case), so the server would render *itself* twice. Slice 2 therefore also + reconciles the server's self-row identity to `canonical.host` (or de-dupes the self-row + against the roster on it). `machine_id` need only match what *other* machines use to reference + a machine; clients keep `machine_id = hostname()`. `canonical.host` is treated as **immutable + after first server enroll** — changing it orphans clients' synthesized node and requires + re-enrolling clients, not just the server. + +## Approaches considered + +- **Duplicate the classifier in `amico-run`, leave the extension's copy** — rejected: the two + producers of a machine's identity would silently drift, which is precisely the split-identity + bug this ADR closes. +- **Put the whole detector (pure + impure) in `@amicode/schema`** — rejected: schema is kept + side-effect-free and `child_process`-free for hygiene. (Note the honest nuance: schema already + imports `node:os`/`node:path`, so it is not browser-safe regardless, and the browser sidebar + module `sidebar_fleet_section.ts` re-declares the roster vocabulary rather than importing + schema — so the risk is not that schema "poisons" that specific module today, but that adding + `child_process` erodes the contract package into a runtime-effect package. "One derivation, + two consumers" is really two *node* consumers: `amico-run` and the extension host.) +- **Enrich the join token / `canonical` with the server's name+type instead of a server row** — + rejected: it stretches the secret join-token contract to carry display data and leaves the + server a non-citizen of the roster, against ADR 0026's "each machine owns its own row." +- **De-dupe the synthesized server node by `server_mode == "server"` instead of id-alignment** — + rejected: it adds client-side matching logic with edge cases (multiple server-role rows) to + avoid a one-line identity rule on the producer. +- **Ship a Linux `hostnamectl`/DMI auto-detector with no override, and detect WSL chassis** — + rejected for WSL type: unreliable from inside the VM; the settings override is the honest + escape hatch. The Linux path itself is kept; only the WSL type auto-guess is dropped. +- **A periodic self-report heartbeat** so identity refreshes without re-enroll — deferred: a + new subsystem, not needed to fix the reported bug. Re-enroll (idempotent upsert) is the + documented refresh path; the heartbeat is a named follow-up. + +## Invariants held + +1. **One topology reader (ADR 0023).** `fleet.json` is untouched; the roster remains the only + new artifact. No new parser. +2. **Single-writer self-report (ADR 0026).** A machine writes only its own row; the server's + self-registration is its own row, keyed by its canonical host. +3. **`@amicode/schema` stays side-effect-free.** Only pure functions are added; the impure shell + stays in the node packages. The browser bundle is unaffected. +4. **No silent fallback (ADR 0024/0025).** An undetectable type is an honest `undefined` that + falls back to `server_mode`; a name with no friendly source falls back to the prettified + hostname; a hub that is not reachable at server self-register time is an honest failure, never + a false success. Nothing is fabricated. +5. **Never-fork (ADR 0005).** No engine spawn is added on any path. +6. **The friendly `name` never feeds `canonical.host`** or the minted join token — the display + seam and the canonical-host derivation are kept separate. + +## Consequences + +- A machine and its peers agree on its name and type by construction — the split-identity bug is + closed on both halves (client-as-seen-by-server and server-as-seen-by-client). +- The server becomes a first-class roster row, making ADR 0026's "each machine owns its own row" + literally true and letting the client render the real server (`Test Desktop / desktop`) + instead of a synthesized raw-host node. +- Linux/WSL clients get real detection with an explicit override; the extension's existing + `classifyDeviceType` is rehomed to schema and imported back, removing a soon-to-drift copy. +- Already-enrolled machines keep their old rows until they **re-enroll once** (idempotent) — the + documented refresh path until a self-report heartbeat lands. +- ADR 0027's additive-invariants gate (`assert_additive_invariants.sh`) drops the enroll verb from + AC1's `FROZEN` set (the never-fork guard shims stay frozen; AC2/AC3/AC4 unchanged). The enroll + verb's never-fork / single-writer semantics are still enforced — by the guard freeze and the + one-parser / single-writer / no-client-stance checks — so relaxing the whole-file byte-freeze does + not weaken any real invariant. + +## Non-goals + +- The periodic self-report heartbeat (auto-refresh without re-enroll) — a named follow-up. +- A Linux chassis auto-guess under WSL (the override covers it). +- Any change to `Server mode`, the `fleet.json` contract, `amicissimo`, or the roster schema + (the `name`/`device_type` fields already exist). + +## Source + +- Design of record: **#1368** (this ADR's paired GitHub issue), decomposed into two vertical + slices — the client-row producer (+ the shared schema derivation) and the server + self-registration (+ the `machine_id = canonical.host` reconciliation). +- Prior art in-tree: `packages/schema/src/fleet_roster.ts` (the row + vocabulary), + `packages/amico-run/src/fleet_enroll_verb.ts` (the producer), and the extension's + `sidebar_view.ts` / `sidebar_fleet_section.ts` (the existing derivation + the consumer). diff --git a/docs/adr/0029-unified-fleet-serving-capability.md b/docs/adr/0029-unified-fleet-serving-capability.md new file mode 100644 index 000000000..e11d87360 --- /dev/null +++ b/docs/adr/0029-unified-fleet-serving-capability.md @@ -0,0 +1,177 @@ +# ADR 0029 — One fleet, differentiated by a `serving` capability (retire the hub-vs-peer topology split) + +- **Status:** accepted +- **Date:** 2026-09-21 +- **Context refs:** ADR 0005 (managed fleet — `Server mode`, the never-fork guard), ADR 0023 + (base-tier projection — the one-parser invariant), ADR 0025 (Remote-SSH default / thin-client + lifeboat / host-owns-all-state), ADR 0026 (generalizable capabilities + host-owned roster — + `server_mode` is the *closed* serve-stance union, `capabilities[]` the *open* axis), ADR 0027 + (peer fleet studios — chose "additive, two topologies"), ADR 0028 (self-reported device + identity in the roster). Program: #1316/#1319/#1341–#1346 (#1353, merged), #1369 (the doc PR + that documents the current two-topology reality this ADR proposes to unify). +- **Amends:** **ADR 0027 Decision #1 (§D1).** ADR 0027 shipped peer studios as a *second topology + additive alongside* the hub/star, leaving "am I a hub or a peer?" as a user-facing distinction. + This ADR reframes that distinction out of the product: there is **one fleet**, and whether + others may attach to a device is a **per-device `serving` capability**, not a topology or a + role. It changes the *model and the surface*, not the merged mechanism — the never-fork guard, + `enroll`, and single-writer-per-DB are preserved (see Invariants). + +## Context + +After ADR 0027 the fleet has **two** things a user must distinguish: + +- **Hub / star** — one `role=server`; the rest are never-fork `role=client` machines that tunnel + to the hub and share its DB (ADR 0005/0025). +- **Peer studios** — each machine `standalone`, running its own engine, *advertising* `serving`, + attachable from the Fleet Manager (ADR 0027). + +The distinction is the wrong **user-facing** seam. "Is this a hub fleet or a peer fleet?" is a +question about our implementation history, not about what the user wants ("some of my machines +should host, some shouldn't"). It is also actively misleading: the stale docs that described +only the hub/star topology led a fresh read of the tree (and this very session) to conclude peer +support did not exist — the fix for which is #1369, but the *framing* is what made the error +possible. + +Two facts make the unification tractable rather than a rewrite: + +1. **The substrate is already capability-shaped.** ADR 0026 gives every device a row on a + host-owned roster with an **open** `capabilities[]` axis; `serving` is already a known tag + (`fleet_roster.ts`), and `placementDescriptor(row)` already answers "reachable + serving." + ADR 0028 makes a device's identity (name, type — and, naturally, its capabilities) + **self-reported** into that row. "Which devices can host" is therefore already expressible as + data, without a new topology. +2. **"Hub" was never really a role — it was two capabilities wearing one hat.** A hub is a device + that (a) others may attach to (`serving`) and (b) holds the roster (the **keeper**, ADR 0027 + §D7). Both are already separable facts. The only thing genuinely distinct about the old + `client` is that it runs **no local engine at all** — a real capability difference, not a + topology. + +## Decision + +**One fleet. Every device is an independent studio. A capability says who may host for others.** + +1. **No hub/peer topology in the model.** A fleet is a set of devices sharing one roster. The + words "hub fleet" and "peer fleet" are retired from the product surface, docs, and the Fleet + Manager. What remains is a device list where each device advertises what it can do. + +2. **Own-engine is the default (this resolves the sharp question).** Every device runs its own + engine on its own DB by default — the ADR 0027 §D2 "independent studios" invariant, generalized + from "peer mode" to "the default." A device is therefore resilient by default: **no single + device's loss stops the fleet.** The old never-fork thin client is *not* the default for + non-hosting machines; it is a distinct, explicit opt-in (§4). + +3. **`serving` (can-host) is the attachability capability.** Whether others may attach to a + device is exactly its `serving` tag on the roster row — nothing else. The old "hub" is just a + device that is `serving` **and** is the current keeper. Attach targets **any** `serving` + device in the roster (the ADR 0027 attach path generalized); the hub tunnel becomes one case + of "attach to a serving device," not a privileged mode. + +4. **The thin client survives as an explicit capability, not a topology.** ADR 0025's lifeboat — + a weak or locked-down machine that *should not* run an engine — is preserved as an explicit + **`hosted-only`** device stance: it runs no local engine and attaches to a designated + `serving` device (its `canonical`), guarded by the unchanged never-fork guard. It is honestly + rare and never the default; choosing it is opting *out* of the own-engine default, not the + fallback for "can't host." + +5. **`server_mode` stays the closed guard decision; attachability leaves it.** ADR 0026 keeps + `server_mode` as the *closed* serve-stance union that the never-fork guard and `amicissimo` + depend on. This ADR does **not** widen it. The guard's local question — *do I fork an engine?* + — is still answered by the closed union (`standalone`/`server` fork; `hosted-only`/`client` + does not). What moves off `server_mode` is the **product-facing** question "can others attach + to me," which becomes the `serving` capability. `server_mode` becomes an implementation-level + guard input, not a user-chosen topology. + +6. **The keeper is an assignment, not a role.** Exactly one `serving` device holds the roster, + named by the keeper bootstrap pointer (ADR 0027 §D7). Any serving device can be the keeper; it + is a designation, not a special kind of machine. "Hub" ≡ "the serving device that is currently + keeper." + +7. **`fleet.json` is reinterpreted, not re-parsed.** The one parser (ADR 0023) is untouched. The + existing `role` values map onto the new model with no schema change: `standalone` ⇒ own-engine + device (advertise `serving` or not); `server` ⇒ own-engine + `serving` + keeper; `client` ⇒ + the `hosted-only` stance (needs `canonical`). Existing hub/client fleets keep working + verbatim — this is a relabelling of what those values *mean* to the user, plus the surface + changes in §8, not a migration. + +8. **The surface collapses to one flow.** Fleet Manager becomes a single device list; each row + shows the device's identity (ADR 0028) and a **serving** toggle + an **Attach** control — no + "enroll as hub vs client" mode choice. `enroll` becomes "add this device to the fleet and set + its capabilities" (the same primitive, minus the hub/client fork in the *interview*); + `Go Standalone` becomes "drop `hosted-only`" (run your own engine). The `create-a-fleet` skill + loses its "exactly one server, the rest clients" step in favour of "which devices should be + `serving`." + +9. **The live-attach enabler is named, not built here.** Uniform "attach to any serving device + and drive its engine" needs the live proxy re-target that #1353 left intentionally unwired + (`server.ts` byte-identical; `resolveAmicodeTarget` has no live caller). This ADR **depends on** + that follow-up for the full attach UX but does not build it — the model and surface changes + here stand on their own (own-engine default + capability advertisement are already real). + +## Approaches considered + +- **Keep two topologies (ADR 0027 status quo)** — rejected: "hub or peer?" is an + implementation-history question, not a user goal, and its framing is what made peer support + invisible in the docs (the bug behind #1369). +- **Retire the thin client entirely (pure peer mesh, every device runs an engine, no exceptions)** + — rejected: ADR 0025's locked-down / weak-machine lifeboat is a real, supported case; dropping + it to get a tidier model removes a capability people rely on. Demote it to an explicit + capability instead (§4). +- **Widen `server_mode` into the open capability axis (fold `serving` into the role union)** — + rejected for the same reason ADR 0026/0027 rejected it: the never-fork guard and `amicissimo` + depend on a *closed* serve-stance union. Keep the guard's closed decision; move only + attachability to the open `capabilities[]` axis. +- **Rename in docs only, leave the two-mode artifacts** — rejected: that is #1369 (which honestly + documents *today's* reality). The point of this ADR is to make it one fleet **in the model and + the surface**, so a user never picks a topology — not merely to relabel the prose. + +## Invariants held + +1. **One parser of `fleet.json` (ADR 0023).** No schema change and no new parser; the existing + `role` values are reinterpreted (§7). Everything capability-shaped rides the roster row + (ADR 0026) and the keeper/attachment pointers (ADR 0027). +2. **Never-fork + single-writer-per-DB (ADR 0005).** The guard and `enroll` mechanism are + unchanged; `hosted-only` is exactly today's `client` stance (no engine spawn), own-engine + devices adopt-or-spawn one writer per machine. Attaching to a serving device is a proxy op that + spawns nothing. +3. **`Server mode` remains the closed serve-stance authority (ADR 0026).** It is not widened; + attachability moves to the `serving` capability, orthogonal to it. +4. **The thin-client lifeboat is preserved (ADR 0025).** It becomes an explicit `hosted-only` + capability rather than the meaning of "non-hosting device" — no supported case is dropped. +5. **No silent fallback (ADR 0024/0025).** A non-serving, non-`hosted-only` device runs its own + engine honestly; a `hosted-only` device with an unreachable `canonical` is an honest failure, + never a fabricated attach. +6. **Additive to ADR 0028.** A device's capabilities are part of its self-reported roster + identity; this ADR adds no new identity producer, it reuses that seam. + +## Consequences + +- The user never chooses a topology. They add devices to one fleet and toggle which ones are + `serving`; the "hub" is just whichever serving device is keeper. This is the model your machines + already physically have (own engine each) minus a misleading label. +- **No single point of failure by default** — every device is an independent studio; a machine + opts *into* dependence (`hosted-only`) rather than being made dependent by being a "client." +- Existing hub/client fleets keep working unchanged; migration is opt-in relabelling, not a break. +- The Fleet Manager, `enroll` interview, `create-a-fleet` skill, and the fleet README all + simplify to one flow (device list + `serving` toggle + Attach). +- The full "attach and drive any serving device's engine" UX is gated on the #1353 live-re-target + follow-up; this ADR makes that the single named enabler instead of one of two topologies. + +## Non-goals + +- **Wiring the live proxy/SSE re-target** (`server.ts` / `resolveAmicodeTarget`) — named as the + enabler (§9), specced/built separately (the #1353 follow-up). +- **Horizon-2 compute federation** — unchanged from ADR 0027; `compute` stays inert. +- **Any change to the never-fork guard mechanism, the `fleet.json` schema, or the `amicissimo` + contract** — this is a model/surface reframe over the merged substrate. +- **Forced migration** of existing hub/client fleets — they keep working; adoption is opt-in. + +## Source + +- Prior art in-tree: `packages/schema/src/fleet_roster.ts` (`serving` tag + `placementDescriptor`), + `packages/amico-run/src/fleet_enroll_verb.ts` (the enroll primitive + role writer), + `packages/extension/src/amicode_service/{attachment_pointer,keeper_pointer,attach_action}.ts` + (the attach substrate), the Fleet Manager (`packages/app/src/pages/session/fleet-manager.ts`), + and `tools/fleet/README.md` (the two-topology reality documented by #1369). +- Design-of-record follow-ups (if approved): a spec decomposing (1) the surface collapse (Fleet + Manager + enroll interview + skill), (2) the `hosted-only` capability rename over `client`, and + (3) dependence on the #1353 live-re-target enabler. diff --git a/docs/adr/0030-connect-to-fleet-device-from-sidebar.md b/docs/adr/0030-connect-to-fleet-device-from-sidebar.md new file mode 100644 index 000000000..e98a55fdd --- /dev/null +++ b/docs/adr/0030-connect-to-fleet-device-from-sidebar.md @@ -0,0 +1,203 @@ +# ADR 0030 — Connect to fleet device from the sidebar + +- **Status:** proposed (reviewed: round 2, `approved-mechanical` — three manual critics, no tooling) +- **Date:** 2026-09-21 +- **Context refs:** ADR 0026 (host-owned roster — device rows, sshAlias, transport), + ADR 0027 §D3 (attach/switch as a first-class action, the switch-control pointer), + ADR 0025 (Remote-SSH default — the hub-only `connectRemoteSsh` command), + ADR 0029 (unified fleet serving — `serving` capability replaces topology roles) +- **Review:** round 1 returned 1 contradiction + 15 advisories (manual, three critics, + one lens each). This revision addresses the contradiction (boot-time pointer recovery) + and folds in the advisories that change the design surface. + +## Context + +The fleet sidebar (ADR 0026, #1321) renders device rows from the roster but they are +read-only — clicking a device does nothing. The existing remote-connection paths are +indirect: Remote-SSH (`amicode.fleet.connectRemoteSsh`) connects only to the hub, +and the attach API (`POST /amicode/fleet/attach`) is reachable only through code or +the CLI, not the sidebar. + +Users see their devices listed but have no gesture to *work on* one. A researcher +with a Mac Studio (server) and a MacBook (client) should be able to click the Studio +in the sidebar and start viewing its sessions, without leaving the Amicode window or +remembering the CLI. + +**Boot-time recovery gap.** ADR 0029 §9 explicitly states that boot-time recovery of +the attachment proxy from the on-disk pointer is "named, not built." After `POST +/amicode/fleet/attach` writes the pointer and the window reloads, the D3 resolver in +`server.ts` never enters the `attached` branch because no `HubProxy` is live — the +user would see their own local sessions, not the target device's. This gap makes the +Thin Client path inoperative without new boot-time wiring. + +**Synthesized canonical-server row.** On a CLIENT machine, the hub row in the sidebar +is synthesized from `fleet.json`'s `canonical` field, not from the roster. The attach +API (`attachActionResponse`) refuses `unknown_machine` when no roster row matches. +The primary use case — a client clicking the hub — fails the roster gate. + +## Decision + +### §D1 — Device rows become clickable + +A left-click on any device row in the fleet sidebar posts a `connect-to-device` +message through the existing typed bridge. This is a navigation action (like the +existing Manage and Troubleshoot buttons), not a roster write — ADR 0026's read-only +contract for the *roster* is preserved. The sidebar's header comments are updated to +reflect the accurate contract: "navigation actions only, no roster-write path." + +A one-shot click guard prevents rapid double-clicks from stacking Quick Picks, +matching the existing file-tree debounce pattern in `sidebar_webview.ts`. + +### §D2 — Quick Pick with two connection modes for remote devices + +On click, the host shows a VS Code Quick Pick titled "View sessions on [device name] +remotely?" with two items: + +| Option | Label | Mechanism | +|---|---|---| +| **Thin Client** | "Thin Client (Poor Connection)" | `POST /amicode/fleet/attach` with the target's `machine_id` + credential provisioning → window reload. Reuses ADR 0027 §D3's attach/switch action and §D9's per-attachment transport. | +| **Remote SSH** | "Remote SSH (Strong Connection)" | Generalise ADR 0025's `resolveRemoteSshTarget` to accept any device's `sshAlias` → `vscode.openFolder` in a new Remote-SSH window. | + +**Preconditions (disable with inline note when unmet):** + +| Condition | Disables | Note shown | +|---|---|---| +| Target lacks `serving` capability (ADR 0029) | Thin Client | "Not running a server" | +| Target `health === "down"` | Thin Client | "Device unreachable" | +| Target has no `sshAlias` | Remote SSH | "No SSH alias configured" | +| `ms-vscode-remote.remote-ssh` extension absent | Remote SSH | "Requires Remote-SSH extension" | + +A confirmation dialog precedes the window reload for Thin Client ("This will reload +the window and connect to [device]. Any in-progress chat will restart. Continue?"), +matching the existing `goStandalone` confirmation pattern. + +### §D3 — Boot-time pointer recovery (the blocking prerequisite) + +This feature **includes** wiring boot-time pointer recovery as part of its +implementation scope. Specifically: + +1. On extension activation, read the attachment pointer from + `~/.amico/ops/fleet/attachment.json` (via `resolveAttachmentPointer`). +2. When a valid, non-empty pointer is found and this machine is not a hub-connected + `client` (i.e., it's a standalone or peer with an attachment), spin up the + per-attachment transport (§D9 of ADR 0027) for the pointed-to device. +3. Register the resulting `HubProxy` on `fleetPlane.attached` so the D3 resolver + enters the `attached` branch on the next request. + +This is the minimum wiring that makes `attach → reload → proxied sessions` work +end-to-end. It does NOT build the live re-target (switching without reload) that +ADR 0029 §9 defers to #1353 — the reload is still required. + +### §D4 — Two-branch device resolution (roster + topology) + +The host-side handler for `connect-to-device` resolves device coordinates through +**two sources**, because the canonical-server row on a client is synthesized from +`fleet.json`, not from the roster: + +1. **Roster lookup** — `roster.rows.find(r => r.machine_id === machineId)`. Carries + `sshAlias`, `transport`, `capabilities`, `health`, `server_mode`. +2. **Topology fallback** — when the roster lookup misses, read `fleet.json`'s + `canonical` for the `sshAlias` and derive `serving = true` (the canonical server + is serving by definition). The `transport` field defaults to `"ssh"` (the + canonical's `sshAlias` implies SSH transport — the universal floor per ADR 0027 §D9). + +The attach API (`attachActionResponse`) is amended to accept the canonical server as +a valid target: when `machine_id` is not in the roster but matches the topology's +canonical, resolve coordinates from the canonical instead. This closes the +synthesized-row gap. + +### §D5 — Credential provisioning on Thin Client attach + +The sidebar's Thin Client path includes `base_url` and `token` in the attach request +body — not just `machine_id`. The handler resolves these from: + +1. **Roster row** — `base_url` from the roster's transport-resolved URL. +2. **Existing credential** — if a per-attachment credential for the target already + exists (from prior enrollment or Fleet Manager attach), reuse it. +3. **Topology canonical** — for the canonical server, `base_url` is derived from + `canonical.host` + `canonical.port`. + +When no credential can be resolved, the Thin Client option is disabled with the note +"No credentials available — use the Fleet Manager to connect." + +### §D6 — Separate write seam (not FleetSectionDeps) + +The existing `FleetSectionDeps` interface is read-only by documented contract. The +connect-to-device handler is wired through a **separate injectable** — either a new +`FleetConnectDeps` interface or a method on the `SidebarViewProvider` — injected from +`extension.ts` where the service URL, auth headers, and roster are all available. +This keeps the read-only fleet section contract intact. + +### §D7 — Local device shows "Go Standalone" with hub-aware warning + +Clicking the "(This Machine)" row offers the existing `amicode.fleet.goStandalone` +command. When this machine is the hub (topology role is server AND other devices exist +in the roster), the confirmation dialog is upgraded to warn: "This machine is the hub +— going standalone will disconnect [N] other device(s). Continue?" + +### §D8 — Remote SSH resolver generalised, not forked + +A new `resolveDeviceRemoteSshTarget(sshAlias, workspacePath?)` entry point accepts a +raw alias; the existing hub path (`connectToHubOverRemoteSsh`) calls it internally. +One resolver, two callers. Before showing the Remote SSH option, the handler probes +`vscode.extensions.getExtension("ms-vscode-remote.remote-ssh")` — absent means +disabled. + +## Approaches considered + +- **Context menu (right-click)** — rejected: webview context menus are unreliable + across platforms; the webview `contextmenu` event is suppressed by VS Code on some + platforms. A left-click Quick Pick is the standard VS Code pattern. +- **Inline button per row** — rejected: adds visual clutter to a section whose + strength is its clean device list. A click on the row itself is discoverable via + cursor change. +- **Full per-device proxy (thin client to any device simultaneously)** — rejected for + now: ADR 0027's attach pointer is single-target by design. The re-attach path gets + the job done without new proxy infrastructure. +- **Defer to #1353 for boot-time recovery** — rejected: shipping the sidebar gesture + without a working Thin Client path would be a dead button. The minimum wiring + (read pointer at boot, spin up transport, register proxy) is scoped here; the live + re-target (switch without reload) stays with #1353. +- **`server_mode != "client"` as Thin Client gate** — rejected: ADR 0029 establishes + `capabilities.includes("serving")` as the authoritative predicate for attachability. + +## Invariants held + +- **ADR 0026 §read-only roster** — the sidebar emits no roster-write message. The new + message is a navigation action. The `FleetSectionDeps` read-only contract is + preserved; the write path uses a separate seam (§D6). +- **ADR 0027 §D3 single-writer pointer** — the Thin Client path drives the existing + attach API; no second pointer writer. +- **ADR 0027 §D3 roster-as-sole-candidate — amended.** §D4 adds the topology canonical + as a second trusted coordinate source when the roster lookup misses. The security + property ("the caller cannot forge a coordinate") is preserved: coordinates are + resolved from `fleet.json` (a trusted local config file), never from the request body. +- **ADR 0025 §Remote-SSH authority** — Remote-SSH opens via the same + `vscode-remote://ssh-remote+/` URI; the generalisation adds no new authority + scheme. +- **ADR 0029 §serving capability** — the Thin Client gate uses + `capabilities.includes("serving")`, not `server_mode`. + +## Consequences + +- Every device in the fleet sidebar becomes a first-class navigation target. +- The Thin Client path makes the attach/switch action (ADR 0027 §D3) user-accessible + from the sidebar for the first time — previously it was CLI-only or Fleet Manager-only. +- **Boot-time pointer recovery is wired** (§D3), unblocking this feature and any + future attach UX. The reload is still required; live re-target stays with #1353. +- The generalised Remote-SSH resolver is independently useful for any future "connect + to device X" surface (Fleet Manager, command palette). +- The attach API is amended to accept the canonical server as a valid target (§D4), + closing a gap that would also affect the Fleet Manager's attach gesture. + +## Non-goals + +- Multi-target attachment (connecting to two devices simultaneously as thin client). +- Per-device workspace path configuration (uses the same `hubWorkspacePath` setting). +- Live re-target (switching attached device without window reload) — stays with #1353. +- Full credential lifecycle in the sidebar (enrollment, rotation) — the Fleet Manager + owns that; the sidebar reuses existing credentials or disables the option. + +## Source + +Implementing issue: #1409. diff --git a/docs/adr/0031-fleet-studio-in-window-multi-machine-sessions.md b/docs/adr/0031-fleet-studio-in-window-multi-machine-sessions.md new file mode 100644 index 000000000..9b556e43a --- /dev/null +++ b/docs/adr/0031-fleet-studio-in-window-multi-machine-sessions.md @@ -0,0 +1,282 @@ +# ADR 0031 — Fleet Studio: in-window, multi-machine sessions and workspaces + +- **Status:** proposed (review: manual adversarial critics — `amico spec review` tooling absent on this machine; see the design-of-record PRD for the recorded review round) +- **Date:** 2026-09-22 +- **Context refs:** ADR 0023 (one-parser projection), ADR 0024 (pluggable transport), + ADR 0025 (Remote-SSH default posture / thin-client lifeboat — **reframed here**), + ADR 0026 (host-owned roster; read-only fleet section — **amended here**), + ADR 0027 (peer fleet studios & compute federation — its **multi-target non-goal is + reversed here**, its §7 event-relay seam is **consumed here**), + ADR 0029 (unified serving capability), ADR 0030 (connect-to-fleet-device Quick Pick — + **superseded here**), ADR 0032 (peer-trust credential — the trust substrate this rides on). + +## Context + +The fleet today is a set of machines you **switch between**, not a studio you work +*across*. Clicking a device (ADR 0030) shows a Quick Pick — "View sessions on X +remotely?" — whose two options both **swap the whole window** at the target: Thin +Client re-attaches the panel and reloads; Remote-SSH opens a new window. The app reads +sessions from a **single** server connection, so you see one machine's sessions at a +time, and a `Session.Info` record carries **no machine identity** — nothing says where a +session runs. + +JJ's own fleet is two **`serving`-capable** machines (a MacBook and a Mac Studio) — +exactly one of which is the keeper / Canonical Server at any time, per ADR 0029's model +(`serving` is a many-valued capability; the keeper is one). The researcher wants what the +product name already implies: **one window that sees and drives every session and workspace +across the fleet, as if local** — watch a solve run on the Studio while chatting on the +MacBook, open any machine's session in place, and always be able to tell which machine a +session belongs to. (Session interaction is orthogonal to serving topology: a machine need +only be reachable and hold a live engine, not be *the* Canonical Server.) + +This is the multi-target line ADR 0027 drew with its single, single-target attachment +pointer, and that ADR 0030 named as a non-goal ("Multi-target attachment … rejected for +now", ADR 0030 §Non-goals). This ADR decides to build the Horizon-1-usable core of it now. + +## Decision + +### §D1 — Multi-target, both live: a session-aware multiplexing proxy + SSE relay + +The local `amicode_service` becomes a **session-aware multiplexer**. Each *session-scoped* +request — messages, prompt submit, tool-permission responses, the engine `file` API, and +the SSE subscription — routes to the **machine that owns that session**, resolved by a +session→machine map (from the §D2 fan-out). Concurrent per-peer SSE streams are **merged +into the one event stream** the app consumes, each event tagged with its originating +session id (ADR 0027 §7's event-relay seam, made live). The app keeps talking to its +**single local origin** — the native multi-origin switcher stays rejected for the data +plane (ADR 0027), because credential translation, CSP, and the accept-set (ADR 0032) all +depend on the single-origin model. + +Multiple sessions from multiple machines are interactive **simultaneously**. This +supersedes ADR 0027's multi-target non-goal for *interaction*; it does **not** introduce +multi-target *attachment* of the whole panel — the single attachment pointer is retired +for session routing (routing is now per-session, not one global pointer). + +The single-origin invariant is **purchased by** this multiplexer: because the app cannot +itself tell a session on machine A from one on B (they share one origin), *all* +cross-machine correctness lives in the service's per-session routing — which is only as +sound as §D1a. + +### §D1a — The session→owner routing key (the load-bearing mechanism) + +The engine `file` API (`file.read` / `file.write` / `file.status`) — which the Preview tab +and Files-changed use (§D7) — **carries no session id** in its request, and today's +resolver routes by URL **path** to a **single** target (`/amicode/fleet*` → local, +`/amicode/roster*` → keeper, everything else → the one attached pointer). So "routes to the +owner" is **not free** and is **not** "the same plane the agent uses" without new work — a +path-only `file.write` from two open remote sessions would hit the *same* single target. + +Decision: every session-scoped request must carry an **explicit owner key**, resolved as +follows. + +1. **Session-pathed requests** (`/session//…`, prompt submit, permission replies) — the + owner is looked up in the session→machine map by the id already in the path; a map miss + triggers an on-demand fan-out refresh before routing. +2. **Path-less engine requests** (`file.read`/`file.write`/`file.status`) — the app **must + attach the active session's owner `machine_id`** as a routing header/param, sourced from + the Work Column's bound session (the Work Column is session-scoped; it knows its owner). + The service's resolver is **extended** from the single path-only pointer to a per-request + owner lookup keyed on that header. Absent the header, the request resolves **local** + (fail-safe), never to an arbitrary attached target. + +Both legs — the engine router's key extraction and the app SDK call sites (the Preview +`serverSDK` write in particular, which today carries **neither** a session id **nor** a +directory) — are real changes and are explicit acceptance criteria of the multiplexer slice +(PRD slice 3), not an assumed property of the existing plane. + +### §D2 — Fleet-wide session list, machine-badged, decoupled from focus + +A **session fan-out** reads `GET /session` from every `serving` roster peer over the +heartbeat transport idiom (SSH `ssh curl ` / HTTPS `peer_origin`), +authenticated with that peer's peer token (ADR 0032), and tags each session with +`{ owner machine, directory }`. The app's session surfaces (the titlebar Sessions dropdown +and the Chats rail) render the **fleet-wide** list, each row **badged by machine**. Session +ids are globally unique, so merge-and-badge is safe (unlike folder-named surfaces — §D4). +The list is **decoupled from the sidebar focus** (§D4): it always shows all machines, so +cross-machine monitoring survives; a separate, optional per-machine **filter** is offered. + +### §D3 — Home base local; only sessions and their files are fleet-routed + +Identity and knowledge stay on **this** machine, always: the researcher profile, the vault +mounts, the Library. Only a **session's runtime** (its agent, tools, `cwd`) and its +**Work Column** (Files-changed, Preview, Context tree — which describe where it actually +runs) follow the owning machine. Mental model: *"my window, remote-controlling agents that +live on other machines."* A remote session's Files-changed reflects the **owner's** +filesystem while the Vault panel reflects **yours** — those can legitimately disagree, and +that is correct, not a bug. + +### §D4 — Focused machine: the sidebar working-surface selector + +A new UI concept, the **focused machine**, governs the *working* surfaces. The FLEET +sidebar section becomes a single-select **focus selector** (default: this machine). +Research, Development, and the Workspace browser show **only the focused machine's** roots. +Focus is set by clicking a device in FLEET **or** by focusing a session tab (→ that +session's owner, with the session's folder revealed in the now-focused view). A +**project-roots fan-out** lets each machine report its Research/Dev roots so the sidebar +can render a remote machine's projects. Collisions (two machines with an `amicode` repo) +never arise: only one machine's roots are shown at a time, and the focus header names it. + +This **amends ADR 0026**: the fleet section gains a navigation/focus action. It writes **no +roster row** — ADR 0026's read-only-roster invariant holds; focus is a UI selection, not a +roster mutation. Working-surface focus is distinct from, and never re-homes, the local +identity/knowledge of §D3. + +### §D5 — Fleet-wide workspace browser via `amico-host://` + +The `amico-host://` FileSystemProvider (the thin-client Explorer, single-host today) is +**generalized to a per-machine authority** — `amico-host:///…` — routing by +that authority to each peer's file client. The focused machine's workspace is browsable in +the Explorer. For humans it is **read/review only**; its write routes stay unbuilt (issue +#1267) — see §D7. + +This is **not** a small reuse of the existing routes: the current host client is built on +the **vault-browser** contract (`/amicode/vaults`, `/amicode/vault-files`, +`/amicode/vault-file`), which models *mounts* and reads **text only** (binary reports +`not_text`). Extending it to arbitrary *workspace roots* is new host routes + provider +re-keying + binary support. It therefore splits into (a) per-machine authority routing over +the existing read contract, and (b) the workspace-root and binary read extensions — +**binary is a follow-up**, not v1. + +### §D6 — Session provenance lives in the session header, not the titlebar + +The **session header** — the sticky session-title bar at the top of the Chat timeline +(which already hosts the title, the context-usage ring, and Compact) — is the home for +session provenance: + +- a **computer icon** to the left of the title on **remote** sessions (hover → machine + name); absent on local sessions (absence = local); +- a **provenance caret** next to the title opening a menu: **Machine · Workspace · Branch** + (+ Open folder / Copy path). A remote session's branch/worktree is a git-status read + routed to the owner via the multiplexer. + +The **titlebar is left as implemented** (tabs, `+`, and the portalled Sessions/Status/ +Side-Panel controls stay). There is **no per-tab machine glyph** — machine denotation lives +in the session header (active session) and the fleet-wide list badges (all sessions). +For a **new** session, the machine picker is the **first** control in the composer's +selector cascade — Machine ▸ Project ▸ Worktree/branch — defaulting to the focused machine, +overridable to any machine. + +### §D7 — Remote workspace file mutation + +| How | Edit existing file content | Create / delete / rename files & folders | +|---|---|---| +| **Agent** (in a session) | ✓ in-place on owner | ✓ in-place on owner | +| **Preview tab** (engine `file.write`, multiplexed to owner) | ✓ in-window, as if local | ✗ (content editor, not a file manager) | +| **Native Explorer** (`amico-host://`) | ✗ read/review only (#1267) | ✗ read/review only | +| **Remote-SSH** window | ✓ native | ✓ native | + +In-window human editing of remote file **content** is available through the **Preview +tab**, which writes via the opencode engine `file` API — the *same engine plane the agent +uses*, so it is governed by the same peer-trust credential and accept-set (ADR 0032) and +needs no `#1267` host-FS write route. **Two honest caveats** the design must carry: + +- It is **not free routing**: the engine `file` API is path-less, so it depends entirely on + the owner-routing key of §D1a. "Same plane the agent uses" governs *authorization*, not + *routing*. +- That plane permits **arbitrary absolute-path writes** on the owner (the engine handler + mkdir-creates files outside the workspace). So the "content editor, not a file manager" + containment is a **UI convention, not a plane property**, and a peer token therefore + confers **arbitrary-path write** on the owner (shell-equivalent) unless constrained. + Decision: the multiplexed **human** write path (Preview) is **gated server-side to + workspace-relative paths** (reject `isAbsolute`); the broader "no new blast radius" claim + is corrected to: *a peer token's write reach equals the agent's today, which is + arbitrary-path — so the human Preview surface is deliberately narrowed below it.* + +Human **structural** operations (new/delete/rename, folders) go to the agent (in-place) or +Remote-SSH. Building a host-FS write plane over `amico-host://` (call it Option B) is **out +of scope** — the Preview tab covers in-window content editing without it. + +### §D8 — Thin-client and Remote-SSH reframed as transport, not a choice + +The ADR 0030 "which connection mode?" Quick Pick is **removed**. In its place: + +- **Thin-client** stops being a user decision — it is the **background transport** the + multiplexer and the workspace browser use to reach peers; its sub-kind (SSH / tailscale / + direct) is per-machine config resolved by `fleet_transport.ts`, never a per-click prompt. +- **Remote-SSH** is a **secondary, explicit per-device escape hatch** (a hover/overflow + action on the FLEET device row, mirrored in the provenance menu's *Machine* entry) for + native full-editor / structural / heavy work, and the **degraded fallback** when the + multiplexer cannot reach a focused machine in place. It opens a **separate VS Code + window** on the host (it inherently cannot be "backgrounded" — a window has one remote + authority), in which the extension runs on the host (a full second studio there). + +This **supersedes ADR 0030 §D2** and **reframes ADR 0025** (Remote-SSH is the escape +hatch/lifeboat, not the default posture). + +**Disposition of ADR 0030 §D3–§D5** (so they are not silently orphaned): §D2's removal +retires the "attach to view sessions" purpose, which §D1/§D2 now subsume. The single +attachment pointer is retired **for session routing** (§D1a); its **boot-time recovery** +(0030 §D3, already partly built) and **credential provisioning** (0030 §D5) are +**subsumed by** the per-peer trust exchange (ADR 0032) and the per-session routing key +(§D1a) — the recovered pointer no longer routes sessions. Fleet Studio keeps **global** +`/amicode/*` reads (profile, problems, catalog) **local** (§D3, home base local), so no +residual single-target pointer is needed for them either. Net: the 0030 pointer machinery +is retired, not repurposed; the Remote-SSH resolver (0030 §D8) survives as §D8's escape +hatch. + +### Staging (delivery order — foundation first, vertical thereafter) + +1. Peer-trust credential + accept-set (ADR 0032) — the readiness-gated `auth=open` close is + an explicit acceptance criterion. +2. Fan-outs + fleet-wide session list + machine badges. +3. Session multiplexer + SSE relay (routes session traffic **and** the engine file API). +4. Focused-machine sidebar + fleet-wide `amico-host://` read/review browser. +5. Session-header provenance + new-session machine picker + Preview-tab remote editing + + Remote-SSH escape hatch. + +## Approaches considered + +- **Keep per-device window swap (ADR 0030 as-is)** — rejected: it is the exact pain the + researcher named; one machine's sessions visible at a time, a reload to switch. +- **Native multi-origin switcher (the app talks to N servers directly)** — rejected for the + data plane (as ADR 0027 already rejected): it bypasses `/amicode/*` ownership, credential + translation, and the single-origin CSP/auth the accept-set depends on. +- **Single-target, seamless-switch (live re-target, one machine live at a time, #1353)** — + rejected as the target: it cannot stream a solve on one machine while you work on + another, which is the defining Amicode use. Kept only as the interim open behavior until + the multiplexer lands. +- **Full host-FS read/write plane over `amico-host://` (Option B)** — rejected for now: the + Preview tab already gives in-window content editing on a governed plane; Option B is a + broader, security-sensitive write surface whose only added value is native-Explorer human + structural edits, which Remote-SSH and the agent already cover. +- **Per-tab machine glyph** — rejected: it would modify the titlebar tab strip, which the + researcher chose to leave untouched; the session header + list badges denote machine. + +## Invariants held + +- **Single app origin (ADR 0027).** The app never opens N origins; the local service + multiplexes. +- **One-parser `fleet.json` (ADR 0023).** Everything new is amicode-owned (routing map, + fan-outs, focus state). +- **Read-only roster (ADR 0026).** The focus selector writes no roster row. +- **Local honesty surface (ADR 0027 §4).** `/amicode/fleet/*` and the posture surfaces stay + local and never proxy. +- **No silent local fallback.** A remote read/write that cannot reach its owner surfaces the + honest degraded outcome (HubDown / Unavailable), never a fabricated success or a local + read (the `host_file_client` guarantee). + +## Consequences + +- The fleet becomes one studio: every session and workspace is visible and (per §D7) + workable in one window. +- Sessions gain durable, visible machine identity (badge + provenance header) — the + researcher's original ask. +- The single attachment pointer is retired for session routing; routing is per-session. +- Remote-SSH's role narrows to an explicit escape hatch + fallback; thin-client becomes + invisible transport. +- New substrate seams are made live that ADR 0027 reserved: the multiplexing service, the + per-attachment transport (now per-peer, read + interactive), and the event-relay seam. + +## Non-goals + +- The Horizon-2 compute-federation executor (server-to-server dispatch RPC, placement + scheduler, artifact sync-back) — still ADR 0027's non-goal. +- A host-FS write plane over `amico-host://` (Option B, §D7). +- Multi-target *attachment* of the whole panel (routing is per-session; the panel is not + "attached" to two machines). +- Moving a running session between machines (a migration, not in scope). + +## Source + +Design-of-record: the Fleet Studio PRD (drafted from the `brainstorming` → `grill-with-docs` +session of 2026-09-22). Paired trust ADR: 0032. diff --git a/docs/adr/0032-peer-trust-credential-and-accept-set.md b/docs/adr/0032-peer-trust-credential-and-accept-set.md new file mode 100644 index 000000000..558ec2e85 --- /dev/null +++ b/docs/adr/0032-peer-trust-credential-and-accept-set.md @@ -0,0 +1,210 @@ +# ADR 0032 — Peer-trust credential and the accept-set: replacing server `auth=open` + +- **Status:** proposed (review: manual adversarial critics — `amico spec review` tooling absent on this machine; see the design-of-record PRD for the recorded review round) +- **Date:** 2026-09-22 +- **Context refs:** ADR 0002 (per-boot server password), ADR 0005 (managed fleet / Fleet + token), ADR 0026 (host-owned roster), ADR 0027 §7 (the **declared, not-built** peer-trust + credential — D10b — **promoted to built here**), ADR 0031 (Fleet Studio — the consumer), + `#955` (the `auth=open` service posture on servers), `#1267` (the host write-route gap). + +## Context + +Today the fleet's trust boundary is the **transport**, not an application token. On a +`server`, the amicode service runs with **`auth=open`** behind the SSH/tailscale tunnel +(`#955`), and the engine itself validates a **single per-boot password**. The **Fleet +token** (ADR 0005) is a **shared secret** — every machine authenticates to every peer with +the same value — which means distrusting one machine requires **re-minting and re-enrolling +the whole fleet**. There is no per-machine revocation, no least-privilege scoping, and no +attribution: to a peer, every caller looks identical. + +ADR 0027 §7 anticipated this and **reserved** — but explicitly did **not build** — a +per-peer, separately-mintable and separately-**revocable** *peer token*, keyed by +`machine_id`, as the distinct trust act for peer-to-peer engine access (deliberately *not* +the Fleet token, which guards *enrollment*, and *not* the UI client mint, which authorizes +*a human's window*). + +Fleet Studio (ADR 0031) makes this necessary now: it drives **full session interaction** +(read + write + tool execution) across every serving peer. The researcher chose to build +the real thing — per-peer revocable trust, validated at the boundary — rather than reuse +the shared secret. + +## Decision + +### §D1 — The peer-trust credential + +Each **serving** machine mints a **per-peer, separately-revocable token** granting access +**to itself**, keyed by the requesting machine's `machine_id`, and records each issuance in +a **revocable issued-token registry** (`~/.amico/fleet-peer-tokens.json`, `0600`, atomic +via the shared credentials writer). Revocation = drop the registry entry. This is the +credential ADR 0027 §7 (spec D10b) reserved — **not** the Fleet token, **not** the UI client +mint. The **reader** side stores each target's token in a **dedicated peer-token store** +(mirroring the minter registry), **not** the existing `attachment_credential.ts` store — +whose own header explicitly disclaims the peer-trust identity ("NOT the Horizon-2 +peer-trust identity … explicitly NOT built here"). Overloading one store with two +credential kinds of different lifecycle risks a revoke clearing the wrong entry; the two +stores stay distinct (or `attachment_credential.ts` is generalized *and its contract note +updated* so the kinds are distinguishable at read time). + +### §D2 — The accept-set replaces full `auth=open` + +The server's `/amicode/*` + engine boundary stops being fully open and instead validates +every caller against an **accept-set**: + +``` +accept ⟺ caller presents (local service/engine mint) + OR (a non-revoked peer token from the issued-registry) + OR (the transitional hub credential) [until §D3 close] + OR (the enrollment nonce — the /…/peer-token MINT ENDPOINT only, §D4) +``` + +Every accepted credential is thus a **named** accept-set member — including the mint +endpoint's enrollment nonce, enumerated here so the mint-honesty invariant below holds +literally rather than by prose exception. The framed app authenticates with the local mint +(its `?auth_token=`), so it is unaffected. + +**Two boundaries, one accept-set.** Peer traffic terminates at the loopback port the +transport forwards to; whichever process owns that port — the extension `amicode_service` +(`authorized()`) and/or the vendored engine's per-boot-password middleware — **both** +implement the *identical* accept-set. A partial rollout (one boundary flipped, the other +not) is the failure this clause forbids: an anonymous request to **each** boundary must be +refused after close (an explicit AC). + +Validation is **constant-time and per-request**: the peer-token check iterates the issued +registry with a constant-time compare and no early exit (preserving the existing +timing-safe discipline), and reads the registry fresh enough that a revocation takes effect +on the immediately following request (write-through to any in-memory accept-set, or a +per-request read). + +### §D3 — Staged rollout: additive first, then a readiness-gated close + +The posture change ships in two steps so a half-migrated fleet can **never lock itself +out**: + +1. **Additive** — the boundary begins **accepting** peer tokens *in addition to* `auth=open`. + Nothing breaks; peer-token auth starts working. +2. **Close** — the boundary flips to **require** an accept-set member, gated on a + **precise, single-vocabulary, locally-observable readiness predicate**: + + > Resolve every roster row with `capabilities ∋ serving` **and** `health = reachable` to + > its `machine_id`. Close is permitted **iff**, for every such `machine_id`, **both** this + > machine's issued-token registry **and** this machine's reader peer-store contain a + > non-revoked entry. A serving row whose `machine_id` cannot be resolved **refuses** close + > (never satisfies it vacuously). A serving peer not yet in the roster likewise **blocks** + > close (it is not stranded — it is waited for). + + Both sides of the comparison are keyed on **`machine_id`** (the roster's alias/`peer_origin` + is resolved to `machine_id` first) — never a mixed-vocabulary join. The predicate is + **local**: it reads only this machine's two stores, so "closeable here" is a fact this + machine can prove without querying peers. (Fleet-wide mutual reachability is achieved by + *each* machine gating its own close on the same local predicate — not by one machine + asserting the whole mesh.) At close, the **transitional hub credential is withdrawn** in + the same step — after close, only the local mint and non-revoked peer tokens are accepted + (the mint endpoint's enrollment nonce excepted, §D4). + +Until the gate passes, the posture stays additive-open. A **documented rollback** re-opens +the additive posture if a peer is stranded post-close. Both the readiness gate and the +rollback are explicit acceptance criteria of the slice, not afterthoughts. + +### §D4 — Distribution: mutual exchange at enroll + heartbeat reconcile + +Peer tokens are exchanged **mutually at `amico fleet enroll`** and **reconciled on the +heartbeat** when a new serving peer appears (via the existing `pushToPeers` transport +dispatch), so the fleet converges with no manual step. Token material is exchanged **only +over an authenticated, encrypted transport** (SSH / tailscale); a plaintext `direct` hop is +refused for token exchange. + +**Bootstrap without a standing skeleton key.** A joining machine obtains its first peer +token via a **short-TTL, single-use enrollment nonce** minted for that specific join — *not* +the standing Fleet token. This closes the revocation hole: if the bootstrap were the +long-lived, machine-scoped Fleet token, a **compromised** machine could simply re-mint a +fresh peer token after `revoke`, defeating containment (the exact case §Consequences +names). The mint endpoint therefore (a) accepts only an unexpired, unused enrollment nonce, +and (b) **refuses to mint for a `machine_id` that appears on any peer's revocation list** — +so revoking a machine also bars its re-enrollment until an operator explicitly re-admits it. + +### §D5 — Revocation and rotation surface + +`amico fleet revoke ` drops the entry (a revoked token 401s on its next +request; the reader surfaces that peer distinctly as **revoked** — a `NoPermissions` +outcome, never conflated with an unreachable peer's `HubDown`, so a suspected compromise +reads as revocation, not an outage). A Fleet Manager control mirrors it. + +**Fleet-wide expulsion is a fan-out, not one write.** Because a machine is the single writer +of the registry granting access **to itself** (invariant below), expelling machine X means +**every** serving peer drops *its own* issued entry for X. `amico fleet revoke ` +therefore **fans the revocation out** to each peer (each peer applies it to its own +registry; no machine writes another's), and X is added to the revocation list §D4's mint +endpoint consults. The Consequences headline is read accordingly: "revoke a machine" is a +fan-out of per-registry drops, not one atomic entry deletion. + +Rotation is **on demand / on re-enroll**; a scheduled rotation is not built for v1 +(revocation, not rotation cadence, is the load-bearing property). + +### §D6 — Scope: full session interaction, shaped for reuse + +The peer token grants **full session interaction** on the owner's engine — the *same plane +the agent already uses* (`file.read`/`file.write`, prompts, tools, SSE). Its write reach is +therefore the agent's: **arbitrary-path write** on the owner (the engine handler +mkdir-creates outside the workspace). Fleet Studio's *human* Preview write path is +deliberately narrowed **below** that by a server-side workspace-relative gate (ADR 0031 +§D7); the peer token itself is not so narrowed, so a leaked peer token is shell-equivalent +on its owner — state it plainly rather than as "no new blast radius." + +The minted token carries an explicit **scope claim** shaped so the Horizon-2 engine↔engine +RPC (ADR 0027 §7) can reuse the same credential without re-plumbing. **In v1 the scope claim +is recorded but inert** — the accept-set (§D2) checks membership only and does **not** read +it, so every non-revoked peer token grants the full plane regardless of the claim (the same +"declared-but-inert" posture ADR 0026 gives the `compute` tag). Least-privilege is a +**future** capability the claim enables, not a v1 control. + +## Approaches considered + +- **Reuse the Fleet token for peer RPC** — rejected (ADR 0027 §7 already rejected it): it + conflates enrollment trust with peer-interaction trust and has no per-machine revocation. +- **Transport-layer revocation only (deauthorize the SSH key / tailscale ACL), keep + `auth=open`** — rejected: ADR 0027 §7 specifies an **app-layer** peer token as a distinct + trust act; transport-layer revocation lives outside Amicode (ssh/tailnet config) and + cannot express least-privilege or attribution. +- **Build the token but defer the accept-set close** — considered as a smaller first cut; + rejected as the *end state* because without the close, revocation is not enforced at the + boundary. Retained only as the additive first step of §D3's staged rollout. + +## Invariants held + +- **Loopback bind + credentialed upstream (ADR 0002).** Every peer hop stays credentialed; + the accept-set is the credential check, not a new listener. +- **Local honesty surface (ADR 0027 §4).** `/amicode/fleet/*` and posture surfaces stay + local; the accept-set never gates them behind a *peer* token (a machine must always be + able to report its own posture) — but an **external** caller still needs the local mint, + so "not gated behind a peer token" is not "anonymously readable" after close. +- **No silent mint fallback (the D5 mint-honesty rule).** Every accepted credential is a + named accept-set member; an unrecognized token is refused, never silently allowed. +- **Single-writer registry.** A machine is the sole minter/revoker of tokens granting access + to itself. + +## Consequences + +- Losing/retiring/compromising one machine → **revoke it** (a fan-out of per-registry drops + plus a mint-list bar, §D5), no fleet-wide re-enroll. Because the bootstrap is a single-use + nonce and a revoked `machine_id` is barred from re-minting (§D4), revocation **contains a + compromise** rather than being re-mintable around. +- The peer relationship is **attributable** (per-machine token, not a shared secret). + Least-privilege is **enabled but inert in v1** (the scope claim is recorded, not enforced — + §D6). +- A leaked peer token compromises **one** peer relationship — but on that owner it is + **shell-equivalent** (arbitrary-path write, §D6), so it is not a small credential; the + human Preview path is narrowed below it (ADR 0031 §D7), the raw token is not. +- Servers stop being fully `auth=open` — a **security-invariant change** with fleet-wide + reach, which is why the close is readiness-gated and rollback-documented (§D3). +- Horizon-2 engine RPC inherits this credential unchanged. + +## Non-goals + +- Scheduled/automatic rotation cadence (revocation is the property that matters for v1). +- The Horizon-2 compute-federation executor itself (ADR 0027 non-goal). +- A host-FS write plane over `amico-host://` (ADR 0031 §D7 non-goal). + +## Source + +Design-of-record: the Fleet Studio PRD (2026-09-22). Consumer ADR: 0031. Promotes ADR 0027 +§7 (D10b) from *declared* to *built*. diff --git a/docs/adr/0033-fleet-studio-per-session-sse-fanout-and-resume.md b/docs/adr/0033-fleet-studio-per-session-sse-fanout-and-resume.md new file mode 100644 index 000000000..a440b020c --- /dev/null +++ b/docs/adr/0033-fleet-studio-per-session-sse-fanout-and-resume.md @@ -0,0 +1,112 @@ +# ADR 0033 — Fleet Studio: per-session SSE fan-out and lossless resume + +- **Status:** **accepted** (design gate #1450 resolved by human review 2026-09-24 — D1–D4 accepted as-is; the three open questions resolved A / A / B, see "Decision record" below; unblocks ONE implementation issue behind `AMICO_FLEET_MULTIPLEX`) +- **Date:** 2026-09-23 (accepted 2026-09-24) +- **Context refs:** ADR 0031 (Fleet Studio in-window multi-machine sessions — §D1 retires the single attachment pointer, which W1b/#1449 implemented), ADR 0027 (§7 event-relay seam — **consumed here**; single-origin invariant — **preserved here**), ADR 0032 (peer-trust credential — the per-peer auth this relay will need, see Open Questions), #1264 (global lossless-reconnect cursor — **generalized here**). +- **Supersedes/blocks:** unblocks a future *implementation* issue for per-session SSE fan-out, to be opened only after this design is reviewed. + +## Context + +W1a (#1448) put the session multiplexer into dispatch behind `AMICO_FLEET_MULTIPLEX` (default OFF), exposing only `resolveTarget` — the AC4 structural guard forbids any SSE crossing the multiplexer. W1b (#1449) made per-session **request + upgrade** routing real: an owned session's non-stream requests route to `resolved.url`, an owned-but-unreachable peer degrades to a `FLEET_PEER_UNREACHABLE` 503 (never a silent local fallback, the #1382 invariant), and the `SessionOwnerMap` is fed by a timer. + +**What is still missing is the event stream.** "Interact with a peer's session as if local" is not real until the peer's *events* reach this window. The existing pieces do not compose: + +1. **Wrong stream.** The app's live feed is a **single global `/event` stream** opened once per window (`packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx:254-277`). The multiplexer's `openSseStream` (`packages/extension/src/amicode_service/session_multiplexer.ts:284-357`) connects instead to a peer's **per-session** `/api/session/{id}/event`. Nothing bridges per-session peer streams into the one global stream the app actually consumes. +2. **No `res` adapter.** `openSseStream` returns an in-memory `SseStreamHandle` (`next()`/`close()`) designed for a test to pull events one at a time. It is **not** an HTTP response writer — it cannot emit SSE frames onto the global `/event` `res` the app is attached to. +3. **`id:` is dropped.** The relay parses only the `data:` line (`session_multiplexer.ts:323`) and discards the `id:` line. The app's lossless reconnect (#1264) depends on the server id-ing every event and the client tracking `lastEventID` (`server-sdk.tsx:243-253`). A relay that drops `id:` silently breaks reconnect for peer-owned sessions. +4. **A single scalar cannot track N peers.** The client cursor is one window-scoped string persisted in `sessionStorage` (`CURSOR_KEY = "amicode.sse.lastEventID"`). Peer A and peer B each have their **own** monotonic event id-space; one scalar cannot encode where we are in N independent streams. Merging N id-spaces into one cursor is the unsolved core. +5. **No local arm.** `openSseStream` only connects when a peer URL exists. A single-machine user (fleet-of-one, the common case) would get **zero events** if dispatch ever routed the global stream through the multiplexer. The local machine's own event source must remain the spine. + +The single-origin invariant (ADR 0027) holds: the app keeps exactly one server connection. The question is what happens *behind* that origin. + +## Decision + +**Aggregate at the origin; keep the app on its single global `/event` subscription; make the cursor a namespaced composite.** Concretely, the origin's multiplexer fans **in** each owned session's peer event stream and the local event source into the one global `/event` response the app already reads. Four mechanisms, each independently testable: + +### D1 — Stream inversion: fan-in, not per-session subscription + +The app does **not** move to N per-session subscriptions. It keeps its single global `/event` GET. On the origin, the handler for `/event` becomes a **fan-in aggregator**: it opens (a) the local event source and (b) one upstream SSE connection per **reachable owner peer** that owns at least one session in the current `SessionOwnerMap`, and writes all of their frames onto the single downstream `res`. Peers are added/removed as the owner-map timer changes the owned-peer set. An unreachable owner contributes no frames and is surfaced honestly (a `FLEET_PEER_UNREACHABLE`-style comment frame, not a silent gap — mirrors the W1b request-path invariant). + +*Rationale:* preserves the single-origin model and the app's one-cursor client with the smallest app-side change; per-session subscription (the rejected alternative, see below) multiplies connections and rewrites the client's whole reconnect model. + +### D2 — `id:`-verbatim, namespaced relay + a real `res` adapter + +Replace the `data:`-only parse with a **frame-preserving relay**: read each upstream SSE frame and re-emit it onto the downstream `res` with its `event:`/`data:`/`retry:` lines intact, and its `id:` line **namespaced by owner**: `id: \u001f` (unit-separator-joined; the local arm uses a reserved `local` namespace). Introduce an `SseStreamHandle → res` adapter (a `pipeToResponse(handle, res)` that writes real SSE bytes and flushes), so the relay drives the actual global-stream response rather than a test's `next()`. The `data:` payload is passed through unmodified except for the existing `session_id` attribution tag, so the reconcile-based reducers are unaffected. + +### D3 — Per-peer cursor set (a composite cursor, parsed at the origin) + +Generalize #1264's scalar. On reconnect the client still sends **one** `?lastEventID=` (no client change beyond value opacity), but that value is a **composite** the origin parses into a per-namespace map: `local=;=;=`. The origin re-subscribes each upstream with **its own** resume param (`/api/session/{id}/event?lastEventID=` or the peer's equivalent) and replays the local arm from ``. The client's `trackEventID` (`server-sdk.tsx:243`) keeps overwriting the whole composite string as the latest namespaced `id:` arrives — the origin is the only component that needs to understand the composite's structure. Over-delivery on replay remains tolerated by the reducers, exactly as #1264 already assumes. + +### D4 — Local arm always present + +The aggregator **always** includes the local event source, whether or not any peer is owned. Fleet-of-one → the aggregate is byte-identical to today's single local `/event` (one namespace, `local`), so single-machine users are unaffected — this is the invariant the implementation's first test must pin. + +## Test plan + +Unit-testable on a single host (no fleet required): +- **D2 relay:** feed a synthetic upstream frame sequence (with `id:`, multi-line `data:`, comments, `retry:`) through the relay; assert the downstream bytes preserve every line and namespace only the `id:`. Assert `pipeToResponse` writes well-formed SSE and flushes per frame. +- **D3 cursor:** round-trip `format(parse(composite)) === composite`; assert a reconnect with a composite cursor issues the correct per-namespace resume params (spy the upstream opener); assert an id-less frame does not advance its namespace (matches #1264). +- **D4 local arm:** with zero peers, assert the aggregate stream equals the local stream frame-for-frame (fleet-of-one byte-identity) and that `?lastEventID=local=` resumes the local arm. +- **D1 fan-in membership:** drive the `SessionOwnerMap` timer; assert peers are added/removed from the upstream set as owned-session ownership changes, and that an unreachable owner emits the honest comment frame rather than a silent gap. + +Requires a real fleet (deferred, human-in-the-loop, needs the Mac Studio): +- Actual cross-machine event delivery end-to-end; per-peer **authentication** of the upstream SSE connection (see Open Questions); reconnect across a real peer flap. + +## Alternatives considered + +- **Move the app to per-session subscriptions (N streams).** Rejected as primary: multiplies connection count, discards the single global-cursor model, and is a large app-side rewrite for the same observable behavior. Kept as the fallback if origin-side fan-in proves to have unacceptable head-of-line coupling. +- **One cursor, last-writer-wins (ignore N id-spaces).** Rejected: silently loses the reconnect gap for all but one peer — reintroduces exactly the data-loss #1264 fixed. + +## Constraints & invariants + +- No SSE crosses the multiplexer until this design is reviewed and its implementation issue lands behind `AMICO_FLEET_MULTIPLEX` (still default OFF). The AC4 guard (`resolveTarget`-only) stays until then. +- W1a/W1b must not regress the existing global lossless-reconnect cursor for the flag-OFF / fleet-of-one path — D4's byte-identity test is the guard. + +## Open questions (for the reviewer) + +1. **Per-peer auth (ADR 0032).** `ResolvedTarget` carries no token today; W1b's request path reuses the attached hub-mint credential (H1). The upstream SSE connections in D1 need per-peer credentials (H2) before this is real cross-machine. Does the fan-in aggregator read the peer token from the peer-store per upstream, or is a hub-mediated relay credential preferable? +2. **`GET /amicode/fleet/focus` snapshot.** W4b (#1453) noted the machine picker cannot seed on a cold webview because focus is a best-effort push with no read-back. If this design adds a fleet-state channel, exposing current focus on connect would close that gap cheaply — worth folding in or keeping separate? +3. **Head-of-line / back-pressure** across N upstreams sharing one downstream `res`: acceptable at expected fleet sizes (2–4 machines), or does D1 need per-namespace buffering bounds? + +## Decision record (human review, 2026-09-24) + +The reviewer accepted **D1–D4 as-is** and resolved the three open questions with **robustness under a poor network connection as the stated priority**. The core resume mechanism (D2 `id:`-verbatim relay + D3 composite cursor) is the lossless-reconnect foundation and stands unchanged. + +1. **Per-peer auth → Option A: the aggregator reads each peer's token from the peer-store per upstream.** Each upstream SSE authenticates *as itself* (H2), matching the independent-peer topology the rest of Fleet Studio adopts (#1477–#1487). Rejected Option B (hub-mediated relay credential): it makes the hub a single trust *and* connectivity chokepoint — a degraded link to the hub would kill every peer stream at once. With A, peer connections fail independently: one dark peer never takes down another's stream or the local arm. + +2. **Focus snapshot → Option A: fold a focus-on-connect snapshot into the fan-in channel.** Under a flaky link the webview reconnects often; folding the current focus/picker state into the connect snapshot makes each reconnect **self-healing** (the cold-seed gap W4b flagged closes on every reconnect), rather than leaving a stale picker after a drop. The snapshot is emitted as the first frame on the `local` namespace at connect/reconnect. + +3. **Back-pressure → Option B: per-namespace buffering bounds (the robustness-priority choice).** Each peer namespace gets its own bounded buffer on the shared downstream `res`; an overflowing (slow/flapping) peer degrades **in isolation** and resumes from its D3 cursor on recovery, instead of head-of-line-blocking the shared response and freezing every other session (including local). This is deliberately more machinery than the ADR's "defensible at 2–4 machines" Option A, chosen because the reviewer prioritized consistent behavior on a bad connection over v1 simplicity. Buffer-overflow drops are safe precisely because D3 replays the gap on reconnect — B and the composite cursor compose into graceful degradation + lossless catch-up. + +**Honest boundary:** this makes a bad connection *recover correctly* (no lost/duplicated events, no one peer freezing the rest), NOT *feel instant* — a peer on a poor link still lags and catches up on reconnect. The first implementation test remains D4's fleet-of-one byte-identity guard. + +## Source + +Part of #1436. W1c / #1450 (labeled `hitl`; the design gate). Blocked-by W1b (#1449, landed). This ADR is the design deliverable; the AC's "reviewed before implementation" gate is a **human** review — no implementation issue is opened by the overnight campaign. + +## Amendment 1 — base-observation fan-in is a separate, non-multiplexer path (2026-09-24, via ADR 0034 D6) + +ADR 0034 (base peer-studio control) pulls live event streaming onto the **observation path**, which raised an apparent conflict with this ADR: D-gate line 3 and the constraint at line 62 tie the fan-in to `AMICO_FLEET_MULTIPLEX` and forbid "SSE crossing the multiplexer until behind the flag," while ADR 0034 D6 wants a base-observation fan-in *not* behind the premium flag. This amendment resolves it: + +- The base-observation fan-in is a **new, separately-armed `/event` interception**, armed on **observation readiness** (holding `observe` on ≥1 reachable session-owning peer). It **reuses this ADR's mechanics as a library** (the `SseFanIn*` aggregator/relay/composite-cursor/per-namespace-buffering — D1–D4), but **not** the premium wiring. +- It is **not behind the multiplexer** and **not** gated by `AMICO_FLEET_MULTIPLEX`. The existing premium `/event` fan-in (`server.ts`, `fleetMultiplexEnabled() && fleetPlane?.eventFanIn`) is **untouched**. +- Therefore the "no SSE crosses the multiplexer until behind the flag" invariant (line 62) **still holds** — because this path does not cross the multiplexer at all. The flag remains the gate for the *premium/armed* fan-in only. +- The fleet-of-one **byte-identity** invariant (D4 / #1264) is preserved by the aggregator's **zero-peer verbatim relay** (the §D4 path writes local frames byte-for-byte when no peer arm is active), and holds only when no focus-snapshot provider is installed (the aggregator emits a focus frame ahead of the local arm when one is). This is the first implementation test for the base-observation path. (Superseded detail: an earlier draft said the driver *declines at zero non-local owners*; #1565 made `handle()` always-accept so a peer can join mid-stream, and byte-identity is now the aggregator's verbatim relay, not a decline to the proxy.) + +Net: two independently-armed fan-in paths sharing one library — premium (flag + `fleetPlane`) and base-observation (readiness) — neither behind the other's gate. + +## Amendment 2 — the backpressure contract the original design under-specified (2026-09-28, #1617) + +Open question 3 and Decision-record item 3 chose **per-namespace bounded buffering (Option B)** for back-pressure, but under-specified the flow-control that arms and *re-arms* it. The relay implementation replaced the transparent engine proxy — whose one-line stream `pipe` managed backpressure for free — with a hand-rolled fan-in whose `flowing` flag flipped false on a `write()`-false but **never came back**: the aggregator's `resume()` (the only thing that re-arms `flowing` and drains the buffers) had **no production caller** — its sole call site was a unit test that faked the drain by hand. One large frame (a shell command or a write/edit tool call) tripped backpressure and the relay wedged: every subsequent frame — including the `session.status: idle` that ends a turn and clears the rail — was buffered indefinitely, healing only on a manual window reload. On `main` (no interception) the same stream fell straight through to the transparent `pipe`, so the wedge was structurally impossible. It bit **local** sessions because the fan-in always owns `/global/event` (#1565) and composite mode routes the local namespace through the same `flowing`-gated path once any peer is active. + +The contract this amendment adds (the two halves the transparent `pipe` gave implicitly, re-derived by hand): + +1. **Downstream `drain` → `resume()`.** The fan-in driver wires the downstream response's `drain` edge to the aggregator's `resume()`, so `flowing` re-arms and the per-namespace buffers flush **in arrival order**. This is the primary fix; the wedge needs *no overflow* — a single trip-frame plus one stranded frame behind it reproduces it. +2. **Upstream pause/resume.** `SseFrameSource` gains optional `pause()`/`resume()`; the aggregator gains an `onBackpressure` callback the driver wires to **pause every live upstream** while the downstream is backpressured, resuming them on `drain`. This keeps the bounded per-namespace buffers from overflow-dropping — the overflow is otherwise **unrecoverable**, because the live global event route has no replay cursor (unlike a reconnect, which D3 resumes). + +Defense-in-depth for any *future* silent stall past the buffer bound: + +3. **The `amicode.sync.gap` frame.** When `resume()` observes `dropped(ns) > 0` for any namespace, it emits **exactly one** id-less synthetic SSE frame naming the affected namespaces and zeroes those drop counters. It is emitted **at resume time, never into a backpressured sink** (a signal written while the sink is full would itself buffer). Two **hard shape requirements** for the client interception (both met by the modeled `amicode.fleet.focus` frame): it MUST carry a `data:` line (the SSE parser drops a data-less frame) and its yielded JSON MUST NOT have a top-level `payload` key (the client routes on `"payload" in event`; a `payload`-less bare-JSON frame takes the non-legacy adapter path where the gap/focus type match lives). Being id-less, it never advances a namespace cursor. +4. **Client forced bootstrap + broadened rail self-heal.** The client intercepts the gap frame where it intercepts the focus frame and drives a **forced bootstrap refetch that bypasses the #1289 reconnect-storm debounce** (the debounce keys on `server.connected`; the gap rides a distinct type in the unconditional-refetch branch). The rail self-heal (`shouldRefetchOnReconnect`, which fires only on the stream-status `false→true` edge) is broadened with `shouldRefetchOnResync` on a monotonic resync token bumped on the gap — because a wedge keeps the socket **nominally connected** and never produces the disconnect→connect edge. + +Invariants preserved: the zero-peer aggregate stream stays **frame-for-frame identical to the local source** (the #1264 byte-identity guard, unchanged — the fleet-of-one path never enters the `flowing`-gated relay); no frame is reordered; a dropped frame leaves its cursor at the last **delivered** id so a future replay resumes the gap rather than skipping it; and **no engine-side change** — the live event route is untouched, the whole fix lives in the extension-host relay and the app client. A **single** `flowing` flag remains correct (one downstream socket, backpressured as a whole); the bug was that it never re-armed, not that it was shared. Narrowing the interception so a genuinely solo/local stream keeps the transparent `pipe` (declining in `handle()` when no peer arm is active) is a real blast-radius improvement but a **separate follow-up** — it does not fix composite mode (the actual failure) and would regress the mid-stream peer-join #1565 introduced. diff --git a/docs/adr/0034-base-peer-studio-control.md b/docs/adr/0034-base-peer-studio-control.md new file mode 100644 index 000000000..f71115e48 --- /dev/null +++ b/docs/adr/0034-base-peer-studio-control.md @@ -0,0 +1,98 @@ +# ADR 0034 — Complete the base peer-studio; reserve premium for managed autonomy + +**Status:** **accepted** (design review with JJ, 2026-09-24 — the grill-with-docs pass in the B2b brainstorm resolved the seven open decisions D1–D7 recorded below; the proposed sketch stands as the intent, the decision record makes it buildable). Blocks the B2b implementation issue. + +**Date:** 2026-09-24 + +**Context refs:** ADR 0031 (Fleet Studio in-window multi-machine sessions — the independent-peer/keeper shape this completes), ADR 0033 (per-session SSE fan-in — **consumed here** for the live-events plane; its `AMICO_FLEET_MULTIPLEX` gate is reconciled in D6), ADR 0026/0028 (Roster, Device identity), ADR 0002/0005 (per-boot password, Fleet token). Substrate PRs: #1480 (identity-bound Observe bootstrap), #1482 (`ControlGatedResolver`), #1484 (`evaluateRemoteWriteGate`), #1486 (grant lifecycle state machine), #1537 (the observation read plane — the read half B2b extends). + +Fleet Studio is completed by base-enabling and finishing the dormant #1455/#147x slices, not by rebuilding them. Independent serving peers own their Sessions; a keeper/canonical coordinate owns roster and bootstrap authority. Existing N-peer projection, owner routing, provenance, focus, picker, and write-confirmation foundations remain the substrate. Base Amicode owns verified peer observation and explicit peer control; Amicissimo remains an additive tier for managed autonomy, governance, premium methods, tuned routing, and managed fleet infrastructure. + +Trust unlocks remote Session observation. A persisted, explicit per-peer Control grant unlocks interaction only after a mechanical readiness gate verifies stable identity, managed peer transport, bilateral token state, projection, owner routing, and SSE. The grant fails closed on revocation, identity change, transport loss, or disable; remote file writes remain per-action confirmed. + +## Considered options + +- **Keep the existing premium data-plane gate** — rejected: it prevents self-owned peer control while the underlying security primitives already live in base code. +- **Rebuild Fleet Studio from scratch** — rejected: #1455 already landed the majority of the correct substrate; replacing it would waste tested work and create drift. +- **Automatic control after roster discovery** — rejected: peer reachability is not authority. + +## Consequences + +- The work decomposes into base activation, peer lifecycle, peer transport/identity hygiene, SSE/interactive completion, and UI/E2E wiring (the slices in the Source section — five tracked sub-issues, plus docs that ride slice 1). +- Existing premium staging becomes a managed-overlay policy mechanism rather than the sole route-mount gate for self-owned peer control. +- The glossary reconciles its single-store Fleet language with the independent-peer/keeper model — see the `CONTEXT.md` `Fleet & serving` additions (Peer studio, Observe, Control, Enable control, Lifecycle-admin authority, Driving) that ship with this ADR. + +## Decision record (grill-with-docs, 2026-09-24) + +The B2b brainstorm resolved the following, in dependency order. Each decision is grounded against the substrate code cited. + +### D1 — Scope: control **and** live streaming, together + +B2b delivers remote prompt / archive / delete **and** live event streaming of the remote response. Rationale (the reviewer's, verbatim in spirit): a prompt you cannot watch land is not control. Live events are part of the control experience, not a follow-on — so ADR 0033's fan-in is pulled into B2b's boundary rather than deferred. + +### D2 — Path: extend the observation path into the base peer-studio (no premium arming) + +Control + streaming are added to the **observation path** (`baseStudioActivates`, `observationOnly`), not the armed premium multiplex. The observation path already carries grant-free reads (#1537). We keep those unchanged and add a write plane and a fan-in aggregator beside them. + +- **Rejected — arm the premium multiplex:** contradicts this ADR's own first rejected option, and undoes the reason the #1537 read fix exists (the observation path routes *around* the premium gate). +- **Rejected — unify read + write onto `ControlGatedResolver`:** that resolver requires an active grant (observe scope minimum) or returns `unavailable`; unifying regresses the working **grant-free** read path the moment no grant exists (which is the default — the grant store is absent). Reads stay reader-token-based; only writes require a grant. + +Two trust levels, two mechanisms: **trust / reader-token unlocks Observe; an explicit `control` grant + per-action confirm unlocks writes.** + +**Grant-key correction (deliberate review):** `evaluateRemoteWriteGate` reads `grantReader(ownerMachineId)`, but `fleet_control_lifecycle` persists/reads grants keyed by **`requesterMachineId`**. In the self-owned case the controlling machine is the *requester* and the driven session's owner is the *target*, so a naïve `grantReader(ownerMachineId)` returns `undefined` and every write denies `no-control-grant`. Resolution: the write plane's `grantReader` resolves the controlling machine's own **`control`** grant by **`targetMachineId === ownerMachineId`** (a `LifecycleGrant` already carries `targetMachineId`) — i.e. it composes `readAllLifecycleGrants().find(g => g.targetMachineId === ownerMachineId && g.scope === "control" && g.state === "active")`, not a `requesterMachineId`-keyed lookup. #1541 delivers this owner/target-resolvable read and persists the issued grant (incl. its `token`) on the controlling machine; #1542 presents that `token` via its own `proxyToPeer` (see D6-adjacent note in #1542), **not** through `ControlGatedResolver`, whose adapter drops the peer credential today. + +### D3 — Establishment: self-owned fast-path + shared handshake; authority seeded at Enroll + +- **Self-owned peer** (the operator owns both machines): Control is armed by **one explicit "Enable control" act on the controlling machine**, authorized by *verified management access* established at Enroll. This is a **net-new** control-scoped bootstrap decision (`evaluateControlBootstrap`, structurally mirroring `evaluateObserveBootstrap`) — **not** an extension of `evaluateObserveBootstrap`, which *explicitly refuses* a control outcome ("there is NO control outcome here — Control scope is #1486; a natural Control seam is … deliberately NOT implemented"). *Verified management access* must be a **defined predicate**, not the bare `managementVerified` boolean the observe path takes on trust (that boolean is set true only in tests today): #1541 names the identity/transport/token check that establishes it. Not automatic (an explicit act; and re-armed each session), so "reachability is not authority" holds. +- **Shared peer** (a different operator's machine): the full **request → approve handshake**, routed to whoever holds that peer's **Lifecycle-admin authority**. +- **`lifecycle-admin` authority is NET-NEW persistence, seeded at `amico fleet enroll`:** today `authorityIdentityKey` exists *only* as an injected parameter to the pure `isLifecycleAuthority` predicate — there is **no store, no writer, no resolver**. #1541 therefore defines (a) a persisted authority record, (b) a writer invoked at Enroll that records the enroller as the target's authority, and (c) a resolver answering *who holds authority for machine X* from another machine. This is what makes a **headless** peer approvable — the approval act runs on a UI-bearing authority machine; the headless peer only **enforces** the presented token (`enforceScopeForRequest`) and never renders an approval prompt. + +### D4 — Confirmation: authorize at the service, confirm with the closest existing UI + +The write gate (`evaluateRemoteWriteGate`) **authorizes** (active `control` grant + reachable transport); **human-intent confirmation is rendered by the UI closest to the human**, so a write is never double-confirmed. + +- **Enable control** is the one net-new confirmation surface — a VS Code **native modal** — plus a persistent **"driving ``" banner** so control is never ambient. Per-session, re-armed (`fleet_headless_rehydration.ts` never auto-restores control). +- **prompt** flows freely under enabled control (no per-message confirm). +- **archive** takes **no discrete confirm** — it is reversible (an unarchive action sits beside it). +- **delete** reuses the **existing local confirmation** (the dropdown's arm→confirm / the timeline's modal). Note the existing delete surfaces are **local-only** today (a peer/remote row renders no delete affordance, and the timeline delete calls the local SDK keyed on `sessionID`), so #1544 must *build* the remote-delete surface and route it by owner — "reuse" means reuse the arm→confirm *interaction*, over a new owner-routed action. + +**Confirmation is a consumer policy, not a gate output (deliberate review):** `evaluateRemoteWriteGate` returns `requiresConfirmation: true` for *every* remote write with an active grant — it does not branch on action-kind. So the gate is not "reused unchanged to get prompt-free / delete-confirmed"; instead the gate **authorizes**, and the **consumer applies confirmation policy keyed on the `action` the gate already carries** in `WriteConfirmationContext` (prompt → none under enabled control; archive → none; delete → the existing arm→confirm). The gate's `requiresConfirmation` flag is therefore *advisory metadata* the consumer interprets, not a mandatory second modal. + +**Reason-vocabulary source of truth (deliberate review):** three modules emit overlapping-but-different reason strings — notably `evaluateRemoteWriteGate` **collapses `revocation-pending` → `grant-revoked`**, while `remote_session_state` / `control_gated_routing` keep them distinct. The UI's single source of truth for the fail-closed chip is **`remote_session_state.ts`** (the only session-UI-state type); the raw write-gate reason is not surfaced directly. + +This is a deliberate reading of "remote file writes remain per-action confirmed": the per-session control-enable is the authorization for the prompt stream; discrete confirmation is reserved for the destructive, irreversible act (`delete`), rendered once by the UI that already owns it. + +### D5 — Fail-closed surface + +The UI honors the existing `read-only` / `unavailable` states (`remote_session_state.ts`, `control_gated_routing.ts`) with their named reasons (`no-control-grant`, `revocation-pending`, `transport-down`, `insufficient-scope`). Write affordances (composer→peer, delete, archive) are **disabled with a visible reason chip** when control is not held, with an **Enable control** (self-owned) / **Request control** (shared) affordance when eligible. **Never** a live button that 500s; **never** a silent local fallback (the #1382 invariant, already upheld on reads). + +### D6 — Live events on the observation path; `AMICO_FLEET_MULTIPLEX` reconciled + +ADR 0033's fan-in mechanics (D1 aggregator · D2 `id:`-namespaced relay · D3 composite cursor · D4 always-local arm · per-namespace buffering) are **reused as a library** (`SseFanIn*`) on a **NEW, separately-armed `/event` interception** on the observation path. This is *not* a re-gate of the existing fan-in: the only wired `/event` fan-in today is `server.ts` `fleetMultiplexEnabled() && this.fleetPlane?.eventFanIn` (premium flag + premium plane), and the observation branch deliberately wires none ("NO SSE fan-in — that stays flag-gated/premium"). The new interception is armed on **observation readiness** — *holding `observe` on ≥1 reachable session-owning peer* — and the premium `server.ts` gate is left untouched. Each upstream authenticates **as itself** (ADR 0033 Decision 1, Option A). Because this path is **not behind the multiplexer at all**, it does not violate ADR 0033's "no SSE crosses the multiplexer until behind the flag" invariant — see **ADR 0033 Amendment 1**, which records this reconciliation. Fleet-of-one byte-identity is guarded by the **driver declining at zero non-local owners** (not merely "an always-present local arm"), and holds only when **no focus-snapshot provider is installed** (the aggregator emits a focus frame ahead of the local arm when one is); that guard is the first implementation test. + +### D7 — Terminus & documentation + +This ADR (accepted) + the `CONTEXT.md` `Fleet & serving` glossary additions are the durable record; the implementation is tracked by a GitHub issue decomposed into **five** sub-issues (#1541–#1545); the sixth "slice" (docs) is this ADR + `CONTEXT.md`, which ride slice 1's PR rather than a separate issue. The app-surface glossary (`packages/app-bundle/overlay/packages/app/CONTEXT.md`) keeps the sidebar fleet section **read-only**; the control affordances land on the **session surface** + the **Fleet Manager** tab and are documented there when the UI slice (slice 4) lands. + +## Constraints & invariants + +- **Fail-closed, never local:** a known remote owner whose grant/transport/scope forbids a write returns a named denial, never a local execution and never a silent local read fallback (#1382). +- **Grant-free reads preserved:** Observe continues to work with the reader token and no lifecycle grant; the write plane's grant requirement must not regress it. +- **Control is never auto-restored:** every service restart / reconnect leaves control suspended until an explicit re-enable (`fleet_headless_rehydration.ts` AC2). +- **Byte-identity for fleet-of-one / flag-off:** with zero non-local owners the driver declines and `/event` stays frame-for-frame the local stream (guarded in the driver, and only absent a focus-snapshot provider); the write plane is inert for local-owned and unowned sessions. +- **Headless peers enforce only:** a peer with no UI never renders approval; authority to mint/approve lives on a UI-bearing Lifecycle-admin machine, seeded at Enroll. +- **`lifecycle-admin` ≠ `control`, even for the holder:** the route matrix authorizes `lifecycle-admin` only for mint/revoke/re-admit — an authority holder cannot drive a peer's session. To drive, the holder must **self-issue a `control` grant** separately. (`control` does subsume `observe` routes at the matrix; `lifecycle-admin` does not.) +- **Fan-in is not behind the multiplexer:** the observation `/event` interception is separately armed on observation readiness; the premium `server.ts` fan-in gate (`fleetMultiplexEnabled()` + `fleetPlane`) is untouched (ADR 0033 Amendment 1). + +## Source + +Part of the Fleet Studio line (#1436, #1455). Grounds the B2b implementation issue **#1540**. Slices (vertical tracer bullets): + +1. **Grant foundation** — Enroll seeds `lifecycle-admin`; self-owned `control` issuance; per-session re-arm. (unit-testable, no UI) +2. **Observation write plane** — gated writes route to the owner with the control token; honest deny otherwise; enable→delete/archive end-to-end for a self-owned peer. +3. **Fan-in** — ADR 0033 D1–D4 on the observation `/event` aggregator (the largest; may sub-split per-D). +4. **UI** — Enable control + driving banner + fail-closed states + write affordances (session surface); Fleet Manager grant management. +5. **Shared handshake** — request→approve routed to the Lifecycle-admin authority; pending-request surface. +6. **Docs** — this ADR + `CONTEXT.md`, landed with slice 1 (not a separate issue; a single-artifact deliverable). The tracked implementation is **five** sub-issues, #1541–#1545. + +Sequencing option (reviewer to confirm at decomposition): slices **1 → 2 → 4** ship self-owned control (prompt/delete/archive, results seen on session re-fetch) *before* the fan-in weight of slice 3, which then upgrades it to live streaming — a smaller first PR that briefly trades away D1's "streaming is part of control" until 3 lands. diff --git a/docs/fleet/p2-companion-go-no-go-1274.md b/docs/fleet/p2-companion-go-no-go-1274.md new file mode 100644 index 000000000..52ed84383 --- /dev/null +++ b/docs/fleet/p2-companion-go-no-go-1274.md @@ -0,0 +1,125 @@ +# P2 go/no-go — UI-kind always-local companion (extension split) (#1274) + +> **STATUS: DECISION PENDING — the kill-switch is not yet thrown.** This is the ADR 0025 +> **P2 feasibility gate** and its recorded outcome (AC4). The three *unit-provable* halves +> are **PROVEN in CI** and cited below. The one *runtime* half (survives a LIVE +> main-extension relocation under Remote-SSH) is **PENDING a live remote**, and the +> **final feasible / abort decision is PENDING a human** — it is deliberately left +> unmarked. Do not read a "feasible" verdict into the proven proofs: P2 is feasible only +> when the live proof is observed AND a human signs the verdict at the bottom. + +- **Issue:** #1274 — "P2: UI-kind always-local companion (extension split) — feasibility + spike + go/no-go" (part of #1269). +- **Design of record:** ADR 0025 (Remote-SSH default posture; thin client as lifeboat). + This slice is the **load-bearing feasibility gate for the entire default-flip**: the + auto-switch's link sensor must run on the CLIENT and survive the host going unreachable, + but the main extension declares `extensionKind: ["workspace"]` and relocates to the host + under Remote-SSH — a single manifest runs in exactly one location. If P2 is infeasible, + the flip never happens and Remote-SSH stays opt-in. +- **The artifact under test:** a SEPARATE `ui`-kind extension, `packages/companion` + (`amicode-companion`, `extensionKind: ["ui"]`). It bundles its own probe (`src/probe.ts`) + and reopen driver (`src/reopen.ts`); it holds **no engine and no store** (never-fork, + ADR 0025 inv. 2). It is a NEW workspace package with working `build` / `typecheck` / + `test` scripts, green in `pnpm -r`. + +## The Approach, restated (so the verdict is against the right claim) + +A separate always-local `ui`-kind companion — NOT a manifest change to the existing +extension (a `["workspace"]` manifest runs in exactly one place; you cannot make one +manifest run on both sides). "Reuse the existing detector in place" was **rejected**: +under Remote-SSH it runs on the host — the dead side of the link. The companion is the +live side. + +## Feasibility proofs + +Three proofs are unit-provable and one is not. Each unit proof cites the test that +establishes it, in `packages/companion/test/companion.test.ts` (25 tests, all green; +`pnpm --filter amicode-companion test` → `25 passed`). + +### (a) Activates on the CLIENT — **PROVEN (unit)** + +- **Claim:** a `ui`-kind companion activates on the client — `activate()` runs, registers + its command, and wires its probe + reopen. +- **Proof:** `describe("activate (AC1 — client-side activation)")` — `activate` registers + `amicode.companion.reopenWindow` and pushes a disposable to `context.subscriptions`; the + returned API wires the client-side probe and both reopen directions; `deactivate` is a + safe no-op. `extensionKind: ["ui"]` in `packages/companion/package.json` is what places + that activation on the client under Remote-SSH. +- **Not covered here:** that the activation *remains running* through a LIVE relocation — + that is the runtime proof below. + +### (b) Executes a probe from the CLIENT SIDE, independent of any host-side instance — **PROVEN (unit)** + +- **Claim:** the companion runs its own probe, reading only the client-configured hub URL + and an injected/global fetch — never the host-side running instance, posture file, or + projection. +- **Proof:** `describe("probeHubHealth (AC2 — client-side probe)")` — it dials EXACTLY the + client base URL + `/global/health` and nothing else (`f.hits` assertion), treats any + answered status (incl. 5xx) as reachable, a transport error / timeout / empty URL as an + honest unreachable, and never throws. That the only inputs are the client URL and the + injected fetch **is** the host-independence. +- **Seam for #1275:** the full unified detector bundles ON TOP of `probeHubHealth`; this + slice ships the minimal probe + the seam only. + +### (c) Programmatically triggers a Remote-SSH↔local window reopen — **WIRING PROVEN (unit); LIVE reopen pending** + +- **Claim:** the companion can programmatically drive a window reopen in BOTH directions. +- **Proof (wiring):** `describe("resolveRemoteSshReopenTarget …")`, + `describe("resolveLocalReopenTarget …")`, and `describe("reopenWindow …")` — the pure URI + builders produce `vscode-remote://ssh-remote+/` (local→remote) and + `file://` (remote→local), reject a blank alias / relative path (never a + half-window), and `reopenWindow` fires `vscode.commands.executeCommand("vscode.openFolder", + …)` **exactly once** with the resolved URI + `forceNewWindow`, or opens NOTHING and + surfaces the honest message on an unresolvable target. The activation tests further show + the wired `reopen()` picks direction from `vscode.env.remoteName` (a remote window flips + to local, a local one to remote). +- **Pending (runtime):** the actual window reopen — VS Code tearing down and re-opening the + window under a real Remote-SSH authority — is not unit-testable; it is part of the live + proof below. +- **Duplication note (judgment call):** the ~10-line pure Remote-SSH URI builder is + DUPLICATED from the main extension's `src/fleet_connect_remote_ssh.ts` + (`resolveRemoteSshTarget`) rather than imported — the companion is a separate package and + must not take a (cyclic) dependency on the workspace extension. The contract fields + (`authority` / `path` / `uri`) are kept identical so #1275 can extract a shared pure + helper trivially if the surface grows. + +### (LIVE) Survives the main extension relocating to the host under Remote-SSH — **PENDING a live remote (runtime / HITL)** + +- **Claim:** the companion, activated on the client, **remains running** while the main + extension (`["workspace"]`) is relocated to the host under Remote-SSH — i.e. the client + side of the link stays alive when the host side moves away. +- **Why not unit-testable:** it requires a real VS Code client, a real Remote-SSH window + onto a live hub, and observation that the `ui` extension host on the client keeps the + companion alive across the relocation. No unit test reaches this. +- **Manual check (fill on a live run):** open a workspace locally (companion active on the + client — confirm via its command in the palette and an Output line); connect that window + to the hub over Remote-SSH (`Amicode: Connect to Hub over Remote-SSH`, #1271); confirm the + MAIN extension is now host-side (`extensionKind: ["workspace"]`) while the COMPANION is + still active on the client (its command still resolves; a `probeHub()` still executes from + the client). Then invoke `amicode.companion.reopenWindow` and confirm it drives the window + back to local. + - [ ] PASS [ ] BROKEN — Notes: `__________` + - Environment: client OS = `__________`, hub host = `__________`, extension version = + `__________`, `vscode.env.remoteName` in the connected window = `__________`. + +## Release-matrix flag (for release owners — NOT wired here) + +The companion is a **second VSIX** — a NEW release-matrix artifact. Per the Key Decision and +this slice's scope, it is **deliberately NOT wired into `release.yml`, the main `amicode` +VSIX, or the packaging scripts**, and is inert to `vsix-gate` / `packaging.test.ts` (those +test the main VSIX only). Before the default-flip ships, **release owners must decide** how +`amicode-companion` is built, versioned, and published (its own VSIX on the Marketplace vs. +bundled distribution) and add it to the release matrix. This is a follow-up, gated on the +verdict below being "feasible". + +## VERDICT (fill and sign — the ADR 0025 P2 kill-switch) + +- Unit proofs green: **(a) activates client-side ✓, (b) client-side probe ✓, (c) reopen + wiring ✓** (cited above; `pnpm --filter amicode-companion test` → 25 passed). +- Live proof observed: [ ] yes [ ] no (from the LIVE section above). +- **Decision — PENDING human:** + - [ ] **P2 FEASIBLE → proceed** to the classified sensor (#1275) and the switch + orchestration (#1276–#1278); flag the second VSIX to release owners. + - [ ] **P2 INFEASIBLE → ABORT per ADR 0025** — the default-flip does not happen; + Remote-SSH stays opt-in (no flip). Record why here: `__________`. +- Decider: `__________` Date: `__________` Commit/branch under test: `__________`. diff --git a/docs/fleet/relocation-audit-1270.md b/docs/fleet/relocation-audit-1270.md new file mode 100644 index 000000000..844caea3e --- /dev/null +++ b/docs/fleet/relocation-audit-1270.md @@ -0,0 +1,153 @@ +# Relocation audit — Remote-SSH host-side attach to the durable hub (#1270) + +> **STATUS: TEMPLATE — awaiting live results.** This is the HITL review artifact for +> issue #1270 (ADR 0025). It is a checklist to be **filled by a human** running a real +> VS Code Remote-SSH window onto a live durable-hub host. **No result below is filled +> in.** Every checkbox is unchecked by construction — checking one is a claim about an +> observed live run, and this document contains none yet. Do not mark a box PASS from +> reasoning; mark it only from the observed output of the stated manual check. + +- **Issue:** #1270 — "Attach the editor to the durable hub over Remote-SSH — host-side + adoption, never-fork at the handshake seam" (part of #1268). +- **Design of record:** ADR 0025 (Remote-SSH default posture; thin client as lifeboat) — + invariants 1 (loopback-only bind), 2 (never-fork; adopt the durable hub), 5 (no silent + fallback). Also ADR 0005 (managed fleet / never-fork), ADR 0020 (standalone server + survives reload — the adopt-or-spawn machinery), ADR 0023 (one topology reader). +- **What automated tests already prove (so the live audit can focus on runtime):** + `packages/extension/test/adopt_live_server.test.ts` → `#1270 host-side relocation` + composes the three seams and pins, against a live loopback survivor, that the + host-side path **adopts** a live durable-hub engine at the handshake (no second + engine/store) and **surfaces** an unadoptable handshake as `foreign-error` (never a + silent cold-spawn). The live audit below is the part a unit test **cannot** reach: a + real Remote-SSH window, real host activation, and the real surfaces. + +## Preconditions for the live run + +Record the environment so a BROKEN result is reproducible. + +- [ ] Durable-hub host is an SSH target with key trust (the fleet `ssh` transport's + existing host + key — **no new credential handling**; #782 is out of scope). +- [ ] The host runs the durable-hub service (#1258): `amico fleet status` on the host + shows role **server**, and the launchd/systemd-user unit is loaded (RunAtLoad + + KeepAlive). +- [ ] The host's engine is live on the canonical port bound to loopback: + `lsof -nP -iTCP:43117 -sTCP:LISTEN` shows the opencode server on `127.0.0.1:43117`. +- [ ] The host's adoption handshake exists: `~/.amico/ops/server/standalone.json` is + present and readable (mode 0600), recording that server's port/pid/password. +- [ ] Fill in: hub host = `__________`, client OS = `__________`, extension version = + `__________`, `vscode.env.remoteName` observed in the window = `__________` + (expected to start with `ssh-remote`). + +## Surface-by-surface audit + +Each surface: the **host-correct** expectation, the **exact manual check**, and one +box. `PASS` = the check's stated output was observed; `BROKEN` = it was not (write what +happened instead in Notes). + +### 1. Extension activation host-side — **AC1 (runtime / HITL — not unit-testable)** + +- **Host-correct:** opening a Remote-SSH window onto the hub host activates the Amicode + extension **on the host** (`extensionKind: ["workspace"]` puts the extension host on + the far side), not on the client. +- **Manual check:** open the Remote-SSH window onto the hub → Command Palette → + "Amicode: Open Amicode Terminal" resolves, and the Output → Amicode channel shows boot + lines. Confirm host-side execution: in that terminal `hostname` returns the **hub's** + hostname, and the extension's boot log is being written on the host + (`~/.amico/ops/server/server.log` grows on the host). +- [ ] PASS [ ] BROKEN — Notes: `__________` + +### 2. Engine — adopt vs spawn — **AC2 (adopt; no second engine, no second store)** + +- **Host-correct:** the host-side activation **adopts** the running durable-hub engine + at the canonical handshake — it spawns **no** second engine and opens **no** second + store. The Amicode channel prints `[boot] ADOPTED surviving server on port 43117 …` + (never `[boot] … cold-spawning`). +- **Manual check (one canonical writer):** + - Amicode channel shows the `ADOPTED` line above (not a cold-spawn line). + - `lsof -nP -iTCP:43117 -sTCP:LISTEN` still shows **exactly one** listener, and its PID + equals the pid in `~/.amico/ops/server/standalone.json` (the pre-existing hub, not a + new one). + - `pgrep -af "opencode.*serve"` on the host shows **exactly one** engine process. + - The chat DB / store has a single writer: no second SQLite file opened, no + `database is locked` errors in `server.log`. +- [ ] PASS [ ] BROKEN — Notes: `__________` + +### 3. Engine — unadoptable handshake is surfaced — **AC3 (surfaced, not silent cold-spawn)** + +- **Host-correct:** if the running hub does **not** present an adoptable handshake + (stale/foreign recorded password, or protocol mismatch), the failure is **surfaced** + (an error notification + an Amicode-channel line naming the port/pid conflict) and the + extension spawns **no** rival engine on the occupied port. +- **Manual check (destructive — do on a scratch hub, then restore):** with the hub live + on 43117, corrupt the recorded password in `~/.amico/ops/server/standalone.json` (or + point it at a foreign occupant), reload the Remote-SSH window, and confirm: a VS Code + error notification appears ("Amicode: …occupied…"), the Amicode channel logs the same, + and `pgrep -af "opencode.*serve"` still shows **one** process (no rival spawned). + Restore the handshake afterward. +- [ ] PASS [ ] BROKEN — Notes: `__________` + +### 4. Durable-hub service — the #1258 runner it adopts + +- **Host-correct:** the adopted engine **is** the durable-hub service's engine; adoption + did not orphan or duplicate the service. After the window attaches, the service is + still the process holding 43117 (KeepAlive did not restart a fight). +- **Manual check:** `launchctl list | grep amico` (macOS) or + `systemctl --user status ` (Linux) on the host shows the hub unit running + with the **same** PID as the 43117 listener; the service log shows no restart storm + coinciding with the window attach. +- [ ] PASS [ ] BROKEN — Notes: `__________` + +### 5. Native Explorer — host files (the reported split, closed natively) + +- **Host-correct:** under Remote-SSH the native VS Code Explorer shows the **host's** + files via real `file:` (not the `amico-host://` lifeboat FSP, which is the thin-client + bridge). Opening/editing/saving a host file works with full tooling. +- **Manual check:** the Explorer root is a host path; open a host file, edit, save → + the change lands on the host (`stat`/`git status` on the host reflects it). Confirm the + scheme is `file:` (not `amico-host:`) via the editor's file URI. +- [ ] PASS [ ] BROKEN — Notes: `__________` + +### 6. Integrated terminal — on the host + +- **Host-correct:** the integrated terminal is a shell **on the host**; `amico` / + `amico run` resolve, and it talks to the same adopted engine. +- **Manual check:** open the integrated terminal → `hostname` = the hub; `which amico` + resolves on the host; `amico fleet status` shows role server; `pnpm sync --check` runs + against the host checkout. +- [ ] PASS [ ] BROKEN — Notes: `__________` + +### 7. Runs — the host's run artifacts + +- **Host-correct:** the Run Inspector and `~/.amico/runs/…` resolve to the **host's** + runs (one canonical runs root, the adopted engine's), not an empty client-side root. +- **Manual check:** the Run Inspector lists the host's existing runs; a new solve writes + under the host's `~/.amico/runs/default//` (verify the dir appears on the host). +- [ ] PASS [ ] BROKEN — Notes: `__________` + +### 8. Chat / sessions / event stream — host-owned, continuous + +- **Host-correct:** chat sessions and the `/amicode/*` event stream are the **host's** + (host-owns-all-state, #1262); a session opened on the host is visible in the + Remote-SSH window, and turns stream from the adopted engine. +- **Manual check:** the Sessions list shows the host's sessions; send a message and + confirm the turn streams; confirm the session persists in the host's chat DB (survives + a window reload — the adopt-on-reload path, ADR 0020). +- [ ] PASS [ ] BROKEN — Notes: `__________` + +## Invariant spot-checks (ADR 0025) + +- [ ] **Loopback-only bind (inv. 1):** the engine binds `127.0.0.1:43117` on the host; + Remote-SSH is the transport, not a non-loopback bind. Verify: `lsof` shows + `127.0.0.1`, never `0.0.0.0`/`*`. +- [ ] **Never-fork (inv. 2):** exactly one engine and one store across the whole attach + (covered by surfaces 2 + 4). +- [ ] **No silent fallback (inv. 5):** any degradation (unreachable hub, unadoptable + handshake) is a **surfaced** transition, never a silent reroute or cold-spawn + (covered by surface 3). + +## Verdict (fill after the live run) + +- Surfaces host-correct: `__ / 8`. Invariants held: `__ / 3`. +- Overall: [ ] host-correct (ready to seed S2/S3) [ ] broken (file follow-ups; list + the broken surfaces and their issue numbers here: `__________`). +- Auditor: `__________` Date: `__________` Commit/branch under test: `__________`. diff --git a/ops/fleet-status.sh b/ops/fleet-status.sh index 3648055cd..e24d4f4b0 100755 --- a/ops/fleet-status.sh +++ b/ops/fleet-status.sh @@ -8,9 +8,48 @@ OUT="$HOME/.amico/ops/fleet-status.json" TMP="$OUT.tmp" DB="$HOME/.local/share/opencode/opencode-dev.db" SYNCLOG="$HOME/.amico/sync.log" +ROSTER="$HOME/.amico/ops/fleet/roster.json" -# --- devices: alias|host pairs (edit here as the fleet grows) --------------- -DEVICES=("mini:127.0.0.1" "macbook:macbook" "erlich:erlich") +# --- devices: derived from the host-owned roster (#1318, ADR 0026) ---------- +# The fleet's device list is the roster's rows (each machine self-reports its +# own row), NOT a hardcoded array — a machine tagged/enrolled shows up here +# without editing this script. Each device is `name:host`, host = the row's +# sshAlias (the ssh target the probe uses), falling back to loopback. When the +# roster is absent/empty the job probes only this machine (a loopback self-row), +# never an invented peer. +roster_devices() { + if [ -f "$ROSTER" ] && command -v python3 >/dev/null 2>&1; then + python3 - "$ROSTER" <<'PY' +import json, sys +try: + doc = json.load(open(sys.argv[1])) + rows = doc.get("rows", []) if isinstance(doc, dict) else [] +except Exception: + rows = [] +for r in rows: + if not isinstance(r, dict): + continue + name = r.get("name") or r.get("machine_id") or "unknown" + host = r.get("sshAlias") or "127.0.0.1" + # keep the pair delimiter clean (name:host is split on the first colon) + print(f"{name}:{host}") +PY + fi +} + +# The derived device pairs, with a loopback self-row fallback when the roster +# yields nothing (honest "this machine only", never the retired hardcoded peers). +DEVICE_PAIRS="$(roster_devices)" +if [ -z "$DEVICE_PAIRS" ]; then + DEVICE_PAIRS="this-machine:127.0.0.1" +fi + +# --emit-devices: a dry-run seam (the AC5 test surface) — print the derived +# device list and exit BEFORE any ssh/sqlite/curl/launchctl work. +if [ "${1:-}" = "--emit-devices" ]; then + printf '%s\n' "$DEVICE_PAIRS" + exit 0 +fi # Bounded ssh probe — no GNU timeout on macOS, and ConnectTimeout alone does # NOT bound an in-band stall (observed 2026-08-08: Tailscale SSH to erlich @@ -27,7 +66,8 @@ ssh_probe() { } dev_rows="" -for pair in "${DEVICES[@]}"; do +while IFS= read -r pair; do + [ -z "$pair" ] && continue name="${pair%%:*}"; host="${pair#*:}" if [ "$host" = "127.0.0.1" ]; then reachable=true; detail="this machine" @@ -39,7 +79,9 @@ for pair in "${DEVICES[@]}"; do fi fi dev_rows="$dev_rows{\"name\":\"$name\",\"reachable\":$reachable,\"detail\":\"$detail\"}," -done +done <, + id: string, + source: DiscoverySource, + fields: { + name: string; + sshAlias?: string; + address?: string; + preferAddress?: boolean; + inRoster?: boolean; + enrolled?: boolean; + serverMode?: string; + health?: RosterHealth; + }, +): void { + const existing = acc.get(id); + if (existing === undefined) { + acc.set(id, { + id, + name: fields.name, + sources: [source], + sshAlias: fields.sshAlias, + address: fields.address, + inRoster: fields.inRoster ?? false, + enrolled: fields.enrolled ?? false, + serverMode: fields.serverMode, + health: fields.health, + }); + return; + } + if (!existing.sources.includes(source)) existing.sources.push(source); + existing.sshAlias ??= fields.sshAlias; + if (fields.address !== undefined && (fields.preferAddress || existing.address === undefined)) { + existing.address = fields.address; + } + // Roster facts (only the roster fold supplies these, and it folds first, so + // these are normally set at creation). Never downgrade a fact already set. + if (fields.inRoster) existing.inRoster = true; + if (fields.enrolled) existing.enrolled = true; + existing.serverMode ??= fields.serverMode; + existing.health ??= fields.health; +} + +/** + * Enumerate the candidate machines from the three discovery sources, deduped by + * normalized machine name, mutating nothing. Read-only: the returned candidates + * are fresh objects; the inputs are never touched. + */ +export function discoverFleetCandidates(input: DiscoverInput): FleetCandidate[] { + const acc = new Map(); + + // Fold in canonical source order so a candidate's `sources` list is already + // ordered and its display name/reach hints prefer the richer roster source. + for (const row of input.roster ?? []) { + fold(acc, normalizeIdentity(row.name), "roster", { + name: row.name, + sshAlias: row.sshAlias || undefined, + inRoster: true, + // The honesty bar (#1319): enrolled ONLY when verify-attach passed, which + // the roster mirrors as health "reachable". A `down`/`degraded` row is a + // machine that has a row but is not yet trustworthy — not enrolled. + enrolled: row.health === "reachable", + serverMode: row.server_mode, + health: row.health, + }); + } + for (const host of input.sshConfig ?? []) { + fold(acc, normalizeIdentity(host.alias), "ssh-config", { + name: host.alias, + sshAlias: host.alias, + address: host.hostName, + }); + } + for (const peer of input.tailnet ?? []) { + fold(acc, normalizeIdentity(peer.hostName), "tailnet", { + name: peer.hostName, + address: peer.tailscaleIP, + preferAddress: true, + }); + } + + for (const c of acc.values()) c.sources.sort((a, b) => SOURCE_ORDER.indexOf(a) - SOURCE_ORDER.indexOf(b)); + return [...acc.values()].sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/packages/amico-run/src/fleet_enroll_verb.ts b/packages/amico-run/src/fleet_enroll_verb.ts new file mode 100644 index 000000000..91fc10c51 --- /dev/null +++ b/packages/amico-run/src/fleet_enroll_verb.ts @@ -0,0 +1,805 @@ +// `amico fleet enroll` (#1319) — the FLEET ENROLL PRIMITIVE. A NEW subcommand, +// DISTINCT from the session-registry `amico fleet` verbs (fleet_verb.ts) and +// their "write verbs don't write the record" invariant: enroll writes the +// machine's `fleet.json` membership record + a host-owned roster row, a +// different domain entirely. It is the idempotent, verifiable way to bring one +// machine into a fleet, and the primitive the `/create-a-fleet` orchestrator +// (#1320) drives. +// +// Two paths, the standard server-first join-token pattern (k3s/tailscale/swarm): +// · `--as-server` — provision the durable hub service (the installer, role +// server), mint the Fleet token (ADR 0005), and EMIT a join token bundling +// { canonical{host,port,sshAlias}, fleet_token, transport_hint, pin_version }. +// The join token is a SECRET, written at 0600, NEVER logged. +// · client redeem (`--join-token ` | `--join-token-json `): +// 1. PIN CHECK first — probe the host version and reject a token whose +// pin_version disagrees (reusing the pure versionSkewVerdict) BEFORE any +// file is written (AC4). +// 2. write fleet.json (role + canonical ONLY — capabilities live on the +// roster row), register the roster row via POST /amicode/roster (#1318), +// set amicode.fleetTransport (tailscale when this machine's capabilities +// include `roaming`, else the token's hint), run the installer (which +// installs the never-fork guard + the client tunnel). +// 3. VERIFY ATTACH — probe the just-set transport (GET /global/health, +// expecting 200 + a pin matching the host). Success is reported ONLY +// after verify-attach passes; on failure the specific cause is reported +// with its fix, the roster row's health reflects the failure, and +// success is NOT reported (ADR 0024/0025 no-silent-fallback). +// Re-runnable: a second enroll REPAIRS in place — the roster POST is a +// single-writer upsert (no duplicate row), the installer + token mint are +// idempotent (no duplicate tunnel/unit). +// +// Never-fork (ADR 0005): enrolling as a client installs the guard and spawns NO +// engine — this verb has no engine-spawn path at all; the `spawnEngine` dep is a +// tripwire the tests assert is never touched. +// +// Async: the verb does real HTTP (roster POST + health probes). The `fleet` verb +// router (fleet_verb.ts) already returns `VerbResult | Promise` and +// both callers (amico.ts, mcp_serve.ts) await it. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { homedir, hostname } from "node:os"; +import { randomUUID } from "node:crypto"; +import { + versionSkewVerdict, + writeFleetConfig as schemaWriteFleetConfig, + recordLifecycleAuthority as schemaRecordLifecycleAuthority, + fleetTopologyPath, + classifyMacModel, + classifyLinuxChassis, + normalizeDeviceName, + isWslKernel, + type FleetConfig, + type LifecycleAuthorityRecord, + type RosterRow, + type RosterHealth, +} from "@amicode/schema"; +import type { VerbResult } from "./verbs.js"; + +// ── the join token contract (a secret — 0600, never logged) ─────────────────── +export interface JoinTokenCanonical { + host: string; + port: number; + sshAlias: string; +} +export interface JoinToken { + canonical: JoinTokenCanonical; + fleet_token: string; + transport_hint: string; + pin_version: string; +} + +/** Parse a raw join token — tolerant Result (null on malformed), so a bad token + * is refused as data before any side effect. */ +export function parseJoinToken(raw: string): JoinToken | null { + let doc: unknown; + try { + doc = JSON.parse(raw); + } catch { + return null; + } + if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return null; + const o = doc as Record; + const c = o.canonical; + if (c === null || typeof c !== "object" || Array.isArray(c)) return null; + const cc = c as Record; + if (typeof cc.host !== "string" || cc.host.trim() === "") return null; + if (typeof cc.port !== "number" || !Number.isFinite(cc.port)) return null; + if (typeof cc.sshAlias !== "string") return null; + if (typeof o.fleet_token !== "string" || o.fleet_token.trim() === "") return null; + if (typeof o.transport_hint !== "string" || o.transport_hint.trim() === "") return null; + if (typeof o.pin_version !== "string" || o.pin_version.trim() === "") return null; + return { + canonical: { host: cc.host, port: cc.port, sshAlias: cc.sshAlias }, + fleet_token: o.fleet_token, + transport_hint: o.transport_hint, + pin_version: o.pin_version, + }; +} + +// ── the enroll-result JSON (the exact shape #1320 consumes) ──────────────────── +export interface EnrollVerifyAttach { + ok: boolean; + cause?: string; +} +export interface EnrollResult { + machine_id: string; + name: string; + server_mode: string; + capabilities: string[]; + transport: string; + verify_attach: EnrollVerifyAttach; +} + +// ── the transport→origin resolution for verify-attach ───────────────────────── +export type ProbeOriginResult = { ok: true; origin: string } | { ok: false; cause: string; fix: string }; + +// ── injectable seams (production defaults do the real thing; tests stub) ─────── +export interface FleetEnrollDeps { + /** This machine's stable id (single-writer roster key). Default: hostname(). */ + machineId?: () => string; + /** This machine's OS-detected friendly display name — the middle tier of the + * name precedence (setting override → THIS → prettified hostname). Default: + * the OS detector (scutil ComputerName / hostnamectl --pretty) via the + * command-runner, falling back to the prettified hostname. NOTE: this is the + * DISPLAY name only — it is deliberately kept OUT of the `canonical.host` + * derivation so a friendly name never leaks into the join token (ADR 0028). */ + machineName?: () => string; + /** This machine's declared capability tags (roaming → tailscale). Default: []. */ + capabilities?: () => string[]; + /** This build's version pin — the enroll-time pin check + the minted token's + * pin_version. Default: AMICO_CLIENT_VERSION env, else "dev". */ + clientVersion?: () => string; + /** ISO stamp for the roster row. Default: now. */ + now?: () => string; + /** The HTTP impl for the roster POST + health probes. Default: globalThis.fetch. */ + fetchImpl?: typeof fetch; + /** The fleet.json writer. Default: the hoisted @amicode/schema writeFleetConfig. */ + writeFleetConfig?: (config: FleetConfig, p: string) => void; + /** Seed the lifecycle-admin authority (#1541, ADR 0034 D3): record the + * enroller as THIS machine's authority at client redeem — net-new + * persistence, the same injectable-with-real-default pattern as + * writeFleetConfig/mintFleetToken (enroll stays byte-identical when + * defaulted; a test injects a spy). Default: the shared @amicode/schema + * authority-store writer. */ + recordLifecycleAuthority?: (record: LifecycleAuthorityRecord) => void; + /** Where fleet.json lives. Default: fleetTopologyPath(). */ + fleetConfigPath?: string; + /** Write the join token at 0600. Default: atomic 0600 write. */ + writeJoinToken?: (p: string, token: JoinToken) => void; + /** Read a join token file (0600). Default: read-or-null. */ + readJoinTokenFile?: (p: string) => string | null; + /** Where the minted join token lands (--as-server). Default under ~/.amico. */ + joinTokenOutPath?: string; + /** Run the fleet installer for a role (guard/tunnel/hub-service; idempotent). + * Default: shell tools/fleet/install.sh (honest failure if unlocatable). */ + runInstaller?: (role: string) => { ok: boolean; detail?: string }; + /** Set amicode.fleetTransport. Default: merge into the VS Code User settings.json. */ + setTransport?: (kind: string) => void; + /** Mint or reuse the Fleet token (0600, idempotent). Default: read-or-mint. */ + mintFleetToken?: () => string; + /** NEVER called — the never-fork tripwire (a client spawns no engine). */ + spawnEngine?: () => void; + /** Resolve a transport + canonical to the origin verify-attach probes, or the + * sshAlias-unresolved cause. Default: local/tailscale/direct → http host:port; + * ssh with an empty alias → sshAlias-unresolved. */ + resolveProbeOrigin?: (transport: string, canonical: JoinTokenCanonical) => ProbeOriginResult; + /** Retry delays in ms for verify-attach transport-down probes. + * Default: [1000, 2000, 4000] — up to 3 retries with exponential backoff. + * Only `{ error: "unreachable" }` retries; auth rejections and pin mismatches + * fail immediately. Tests inject [0, 0, 0] for instant retries. */ + retryDelayMs?: number[]; + /** The ONE injectable command-runner seam for the impure OS device-identity + * detection (macOS `scutil`/`system_profiler`, Linux `hostnamectl` + `/sys` + * DMI + `/proc/version`). Default: an execFile-based runner that returns "" + * on any failure (detection is best-effort). Tests feed canned command output + * through it into the shared @amicode/schema classifiers (#1371 AC6/AC7). */ + commandRunner?: CommandRunner; + /** This machine's OS-detected device_type (form factor) — the middle tier of + * the type precedence (setting override → THIS → undefined). Default: OS + * detection via `commandRunner` → the shared schema classifier. `undefined` + * is an honest abstain (the sidebar's type pill falls back to server_mode). */ + deviceType?: () => string | undefined; + /** Read a device-identity setting override (`amicode.device.name` / + * `amicode.device.type`) — the SAME namespace the extension self-row reads + * (ADR 0028: one override namespace, both producers). Default: read the VS + * Code User settings.json (the same path `setTransport` writes). */ + readDeviceSetting?: (key: string) => string | undefined; +} + +/** The impure command-runner seam signature: run a command with args and return + * its stdout (or "" on failure). ONE per node package (ADR 0028) — the shell + * lives here; all judgment is delegated to the shared @amicode/schema pure + * classifiers. */ +export type CommandRunner = (cmd: string, args: string[]) => string; + +// ── flag parsing ────────────────────────────────────────────────────────────── +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function fail(errors: string[], extra: Record = {}, code = 64): VerbResult { + return { json: { verb: "fleet", subcommand: "enroll", ok: false, errors, ...extra }, code }; +} + +// ── production defaults ───────────────────────────────────────────────────── +function defaultWriteJoinToken(p: string, token: JoinToken): void { + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(tmp, JSON.stringify(token, null, 2) + "\n", { mode: 0o600 }); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, p); + fs.chmodSync(p, 0o600); + } finally { + fs.rmSync(tmp, { force: true }); + } +} + +function defaultReadJoinTokenFile(p: string): string | null { + return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null; +} + +function defaultFleetTokenPath(): string { + return path.join(homedir(), ".amico", "ops", "fleet", "fleet-token"); +} + +/** Read-or-mint the Fleet token (0600). Exclusive create so a re-run REUSES the + * existing token (idempotent mint, AC5). */ +function defaultMintFleetToken(): string { + const p = defaultFleetTokenPath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const token = randomUUID().replace(/-/g, ""); + try { + fs.writeFileSync(p, token, { encoding: "utf8", flag: "wx", mode: 0o600 }); + fs.chmodSync(p, 0o600); + return token; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; + const existing = fs.readFileSync(p, "utf8").trim(); + if (existing.length === 0) throw new Error(`fleet-token file is empty: ${p}`); + return existing; + } +} + +/** Locate + run tools/fleet/install.sh for the role (idempotent by the script's + * own design). AMICO_FLEET_INSTALLER overrides the path; an unlocatable script + * is an honest failure, never a silent success. */ +function defaultRunInstaller(role: string): { ok: boolean; detail?: string } { + const candidates = [ + process.env.AMICO_FLEET_INSTALLER, + path.join(homedir(), "harmoniqs", "amicode", "tools", "fleet", "install.sh"), + ].filter((c): c is string => typeof c === "string" && c.length > 0); + const script = candidates.find((c) => fs.existsSync(c)); + if (!script) { + return { ok: false, detail: `fleet installer not found (set AMICO_FLEET_INSTALLER); role=${role} not provisioned` }; + } + const { spawnSync } = require("node:child_process") as typeof import("node:child_process"); + const r = spawnSync("bash", [script], { encoding: "utf8" }); + return r.status === 0 ? { ok: true } : { ok: false, detail: (r.stderr || "").trim() || `installer exited ${r.status}` }; +} + +/** VS Code User settings.json path per-OS (mirrors tools/fleet/install.sh). */ +function defaultSettingsPath(): string { + if (process.platform === "darwin") { + return path.join(homedir(), "Library", "Application Support", "Code", "User", "settings.json"); + } + return path.join(homedir(), ".config", "Code", "User", "settings.json"); +} + +/** Merge amicode.fleetTransport into the VS Code User settings.json (the surface + * the transport-provider selector reads — amico-run has no VS Code API). */ +function defaultSetTransport(kind: string): void { + const p = defaultSettingsPath(); + let doc: Record = {}; + try { + doc = JSON.parse(fs.readFileSync(p, "utf8")) as Record; + } catch { + doc = {}; + } + doc["amicode.fleetTransport"] = kind; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(doc, null, 2) + "\n"); +} + +// ── device identity: the impure detector (ONE command-runner seam) ───────────── +/** The default command runner: execFile the command, return stdout, and swallow + * any failure into "" — detection is best-effort (a missing binary, a non-zero + * exit, a timeout all read as "nothing detected"). The ONE place amico-run + * shells out for device identity; all classification is the shared schema fns. */ +function defaultCommandRunner(cmd: string, args: string[]): string { + try { + const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); + return execFileSync(cmd, args, { encoding: "utf8", timeout: 2000 }).toString(); + } catch { + return ""; + } +} + +/** Read a device-identity setting override from the VS Code User settings.json + * (the same file `setTransport` writes `amicode.fleetTransport` into). Returns + * the string value or undefined (absent file / non-string / parse error). */ +function defaultReadDeviceSetting(key: string): string | undefined { + try { + const doc = JSON.parse(fs.readFileSync(defaultSettingsPath(), "utf8")) as Record; + const v = doc[key]; + return typeof v === "string" && v.trim() !== "" ? v : undefined; + } catch { + return undefined; + } +} + +/** Map a `/sys/class/dmi/id/chassis_type` NUMERIC code to the device-type + * vocabulary (SMBIOS System Enclosure types) — the Linux second source, after + * `hostnamectl` chassis and before abstaining. Kept in the node package (this + * is platform plumbing over a numeric code, not the string-chassis judgment the + * shared `classifyLinuxChassis` owns). */ +function dmiChassisType(code: string): string | undefined { + const n = code.trim(); + if (["8", "9", "10", "11", "12", "14", "30", "31", "32"].includes(n)) return "laptop"; + if (["3", "4", "5", "6", "7", "13", "15", "16", "34", "35", "36"].includes(n)) return "desktop"; + if (["17", "23", "28"].includes(n)) return "server"; + return undefined; +} + +/** OS device_type detection through the command-runner seam → the shared schema + * classifiers. macOS: `system_profiler` "Model Name" → classifyMacModel. Linux: + * WSL abstains (the VM chassis is not the physical machine); else `hostnamectl` + * chassis → classifyLinuxChassis FIRST, then `/sys/class/dmi/id/chassis_type` + * numeric, then abstain (#1371 AC7 / ADR 0028). Any other platform abstains. */ +export function detectDeviceTypeVia(run: CommandRunner, platform: string): string | undefined { + if (platform === "darwin") { + const out = run("system_profiler", ["SPHardwareDataType"]) ?? ""; + const m = /Model Name:\s*(.+)/i.exec(out); + return classifyMacModel((m?.[1] ?? "").trim()); + } + if (platform === "linux") { + if (isWslKernel(run("cat", ["/proc/version"]) ?? "")) return undefined; // WSL abstains + const byChassis = classifyLinuxChassis((run("hostnamectl", ["chassis"]) ?? "").trim()); + if (byChassis) return byChassis; + const byDmi = dmiChassisType((run("cat", ["/sys/class/dmi/id/chassis_type"]) ?? "").trim()); + if (byDmi) return byDmi; + return undefined; + } + return undefined; +} + +/** OS friendly-NAME detection through the command-runner seam. macOS: `scutil + * --get ComputerName`. Linux: `hostnamectl --pretty` (the PRETTY_HOSTNAME). Any + * empty/failed detection falls back to the prettified raw hostname — never a + * fabricated name (#1371 AC7 / ADR 0028). */ +export function detectDeviceNameVia(run: CommandRunner, rawHostname: string, platform: string): string { + if (platform === "darwin") { + const name = (run("scutil", ["--get", "ComputerName"]) ?? "").trim(); + if (name) return name; + } else if (platform === "linux") { + const pretty = (run("hostnamectl", ["--pretty"]) ?? "").trim(); + if (pretty) return pretty; + } + return normalizeDeviceName(rawHostname); +} + +function defaultResolveProbeOrigin(transport: string, canonical: JoinTokenCanonical): ProbeOriginResult { + if (transport === "ssh" && (!canonical.sshAlias || canonical.sshAlias.trim() === "")) { + return { + ok: false, + cause: "sshAlias-unresolved", + fix: "the join token carries no canonical.sshAlias — add it to the token (and to ~/.ssh/config) so the ssh tunnel can resolve the hub host", + }; + } + return { ok: true, origin: `http://${canonical.host}:${canonical.port}` }; +} + +// ── HTTP helpers ─────────────────────────────────────────────────────────── +type HealthProbe = { status: number; version: string | null } | { error: "unreachable" }; + +async function probeHealth(origin: string, authHeader: string | undefined, fetchImpl: typeof fetch): Promise { + const url = `${origin.replace(/\/+$/, "")}/global/health`; + try { + const res = await fetchImpl(url, { + ...(authHeader ? { headers: { Authorization: authHeader } } : {}), + }); + if (!res.ok) return { status: res.status, version: null }; + const body = (await res.json()) as { version?: unknown }; + return { status: res.status, version: typeof body.version === "string" ? body.version : null }; + } catch { + return { error: "unreachable" }; + } +} + +async function postRosterRow( + canonical: JoinTokenCanonical, + authHeader: string | undefined, + row: RosterRow, + fetchImpl: typeof fetch, +): Promise<{ ok: boolean; status: number } | { error: string }> { + const url = `http://${canonical.host}:${canonical.port}/amicode/roster`; + try { + const res = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json", ...(authHeader ? { Authorization: authHeader } : {}) }, + body: JSON.stringify(row), + }); + return { ok: res.ok, status: res.status }; + } catch (e) { + return { error: (e as Error).message }; + } +} + +// ── the server path ──────────────────────────────────────────────────────── +/** Verify the hub is reachable on the given origin, retry-or-honest-fail (#1372 + * AC1). Retries ONLY on `{error:"unreachable"}` (a transient loopback race); + * any answer (the hub responded at all) proves it is up. Returns ok:false only + * after exhausting retries. Distinct from the dev-fallback pin probe, which + * TOLERATES an unreachable hub and is therefore NOT proof of reachability. */ +async function verifyHubReachable( + origin: string, + fetchImpl: typeof fetch, + retryDelays: number[] = [1000, 2000, 4000], +): Promise<{ ok: boolean }> { + const maxAttempts = 1 + retryDelays.length; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const probe = await probeHealth(origin, undefined, fetchImpl); + if (!("error" in probe)) return { ok: true }; // the hub answered ⇒ reachable + if (attempt < retryDelays.length) { + await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt])); + continue; + } + } + return { ok: false }; +} + +/** The pin_version to stamp on a minted join token. Precedence (#1354 follow-up): + * an explicit injection or AMICO_CLIENT_VERSION env wins (operator override); + * otherwise probe the just-provisioned local hub's /global/health and pin its + * REAL engine version — NOT the placeholder "dev", which fails the enroll-time + * pin-check against the server's own 1.18.x engine. "dev" survives only as the + * last-resort fallback when the hub is unreachable at mint time. */ +async function resolveServerPinVersion( + deps: FleetEnrollDeps, + canonical: JoinTokenCanonical, + fetchImpl: typeof fetch, +): Promise { + if (deps.clientVersion) return deps.clientVersion(); + const envPin = process.env.AMICO_CLIENT_VERSION; + if (typeof envPin === "string" && envPin.trim() !== "") return envPin; + // The hub listens on loopback; advertised canonical.host may not resolve locally. + const probe = await probeHealth(`http://127.0.0.1:${canonical.port}`, undefined, fetchImpl); + if ("version" in probe && typeof probe.version === "string" && probe.version.trim() !== "") { + return probe.version; + } + return "dev"; +} + +async function enrollAsServer(argv: string[], deps: FleetEnrollDeps): Promise { + const runner = deps.commandRunner ?? defaultCommandRunner; + const machineName = (deps.machineName ?? (() => detectDeviceNameVia(runner, hostname(), process.platform)))(); + // The friendly display name is DECOUPLED from canonical.host (ADR 0028 §inv 6): + // canonical derives from --host or the RAW hostname, never the friendly name, + // so a friendly name can never leak into `canonical`/the minted join token. + const host = flagValue(argv, "--host") ?? hostname(); + const portRaw = flagValue(argv, "--port") ?? "4096"; + if (!/^\d+$/.test(portRaw)) return fail([`--port "${portRaw}" must be a positive integer`]); + const port = Number(portRaw); + const sshAlias = flagValue(argv, "--ssh-alias") ?? host; + const transportHint = flagValue(argv, "--transport-hint") ?? "ssh"; + const canonical: JoinTokenCanonical = { host, port, sshAlias }; + + const writeConfig = deps.writeFleetConfig ?? schemaWriteFleetConfig; + const configPath = deps.fleetConfigPath ?? fleetTopologyPath(); + writeConfig({ role: "server", canonical }, configPath); + + // Provision the durable hub service (the installer, role server; idempotent). + const inst = (deps.runInstaller ?? defaultRunInstaller)("server"); + if (!inst.ok) { + return fail( + [`hub-service provisioning failed: ${inst.detail ?? "(no detail)"}`], + { role: "server", fix: "run `bash tools/fleet/install.sh` and re-enroll once it reports ok" }, + 70, + ); + } + + const fleet_token = (deps.mintFleetToken ?? defaultMintFleetToken)(); + const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as typeof fetch); + const pin_version = await resolveServerPinVersion(deps, canonical, fetchImpl); + const token: JoinToken = { canonical, fleet_token, transport_hint: transportHint, pin_version }; + const outPath = deps.joinTokenOutPath ?? path.join(homedir(), ".amico", "ops", "fleet", "join-token.json"); + (deps.writeJoinToken ?? defaultWriteJoinToken)(outPath, token); + + // ── self-register the server's OWN roster row (#1372 AC2), keyed by + // machine_id = canonical.host — the same identity a client references it + // by, so the client's synthesized canonical-server node collapses against + // this real row (zero client-side change), and the server's own self-row + // (reconciled to canonical.host in the extension) renders exactly once. + // ONLY after VERIFYING the hub is reachable on loopback (#1372 AC1): the + // hub listens on loopback; canonical.host may not resolve locally. This is + // a SEPARATE check from resolveServerPinVersion's dev-fallback probe (which + // tolerates an unreachable hub) — a dev pin is never reachability proof. + // No silent fallback (ADR 0024/0025): unreachable ⇒ no POST, no false row. + const readSetting = deps.readDeviceSetting ?? defaultReadDeviceSetting; + const nameOverride = readSetting("amicode.device.name"); + const resolvedName = nameOverride && nameOverride.trim() !== "" ? nameOverride.trim() : machineName; + const typeOverride = readSetting("amicode.device.type"); + const detectedType = (deps.deviceType ?? (() => detectDeviceTypeVia(runner, process.platform)))(); + // The server's device_type: override → OS detection → the honest "server" + // default (a server that can't detect its form factor is, at least, a server). + const serverDeviceType = typeOverride && typeOverride.trim() !== "" ? typeOverride.trim() : (detectedType ?? "server"); + + const loopbackOrigin = `http://127.0.0.1:${port}`; + const loopbackCanonical: JoinTokenCanonical = { host: "127.0.0.1", port, sshAlias }; + const reachable = await verifyHubReachable(loopbackOrigin, fetchImpl, deps.retryDelayMs); + let self_registered = false; + if (reachable.ok) { + const serverRow: RosterRow = { + machine_id: canonical.host, + name: resolvedName, + server_mode: "server", + capabilities: (deps.capabilities ?? (() => []))(), + sshAlias: canonical.sshAlias, + transport: transportHint, + last_report: (deps.now ?? (() => new Date().toISOString()))(), + health: "reachable", + device_type: serverDeviceType, + }; + // Reuse the single-writer POST (no new writer). The URL targets loopback + // (the local hub); the ROW carries machine_id = canonical.host. + const post = await postRosterRow(loopbackCanonical, undefined, serverRow, fetchImpl); + self_registered = "ok" in post && post.ok; + } + + const result: EnrollResult = { + machine_id: (deps.machineId ?? (() => hostname()))(), + name: resolvedName, + server_mode: "server", + capabilities: (deps.capabilities ?? (() => []))(), + transport: transportHint, + verify_attach: { ok: true }, + }; + return { + json: { + verb: "fleet", + subcommand: "enroll", + ok: true, + result, + // the join token rides the result so #1320 can hand it to clients; it is + // ALSO persisted at 0600. It is a secret — surfaced on the structured + // result only, never in a log line. + join_token: token, + join_token_path: outPath, + self_registered, + // AC5: canonical.host is IMMUTABLE after first server enroll — changing + // --host on a re-enroll orphans clients' synthesized node and requires + // re-enrolling the CLIENTS, not just the server (never auto-reconciled). + caveat: + "canonical.host is immutable after first enroll — changing --host requires re-enrolling clients (their canonical pointer is not auto-reconciled)", + note: self_registered + ? "server enrolled — durable hub service provisioned, Fleet token minted, join token emitted (0600), and the server's own roster row self-registered (machine_id = canonical.host)" + : "server enrolled — hub provisioned + join token emitted (0600); the hub was NOT reachable on loopback at self-register time, so the server roster row was NOT posted (no false success). Re-enroll once the hub answers.", + }, + code: 0, + }; +} + +// ── the client redeem path ─────────────────────────────────────────────────── +async function enrollAsClient(argv: string[], token: JoinToken, deps: FleetEnrollDeps): Promise { + const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as typeof fetch); + const canonical = token.canonical; + + // ── AC4: the PIN CHECK, BEFORE any file is written ── + // Probe the canonical host's version and reject a pin_version disagreement + // via the pure versionSkewVerdict. An unreachable host at this stage is also + // a pre-write refusal (the token cannot be validated) — never a blind write. + // #1354: the hub uses open auth (AMICODE_SERVICE_AUTH=open) — the SSH tunnel + // is the trust boundary, so no HTTP credentials are sent. + const pinProbe = await probeHealth(`http://${canonical.host}:${canonical.port}`, undefined, fetchImpl); + if ("error" in pinProbe) { + return fail( + [`cannot reach the hub host to validate the join token's pin (host ${canonical.host}:${canonical.port} unreachable)`], + { + stage: "pin-check", + cause: "transport-down", + fix: "confirm the hub is up and reachable from this machine, then re-run enroll", + wrote_nothing: true, + }, + 69, + ); + } + if (pinProbe.version !== null) { + const verdict = versionSkewVerdict(token.pin_version, pinProbe.version); + if (!verdict.agree) { + return fail([verdict.reason], { + stage: "pin-check", + cause: "pin-mismatch", + pin_version: token.pin_version, + host_version: pinProbe.version, + fix: "align the client and hub versions (upgrade one) and re-mint the join token, then re-enroll", + wrote_nothing: true, + }); + } + } + + // ── transport selection (AC2): roaming → tailscale, else the token hint ── + const caps = (deps.capabilities ?? (() => []))(); + const transport = caps.includes("roaming") ? "tailscale" : token.transport_hint; + + // ── write fleet.json (role + canonical ONLY — capabilities go to the row) ── + const writeConfig = deps.writeFleetConfig ?? schemaWriteFleetConfig; + const configPath = deps.fleetConfigPath ?? fleetTopologyPath(); + writeConfig({ role: "client", canonical }, configPath); + + const machineId = (deps.machineId ?? (() => hostname()))(); + const rawHostname = hostname(); + const runner = deps.commandRunner ?? defaultCommandRunner; + const readSetting = deps.readDeviceSetting ?? defaultReadDeviceSetting; + + // ── device identity, resolved per field (ADR 0028 precedence) ── + // name: setting override → OS detection (machineName seam) → prettified hostname + // device_type: setting override → OS detection (deviceType seam) → undefined (honest omit) + const nameOverride = readSetting("amicode.device.name"); + const detectedName = (deps.machineName ?? (() => detectDeviceNameVia(runner, rawHostname, process.platform)))(); + const name = nameOverride && nameOverride.trim() !== "" ? nameOverride.trim() : detectedName; + + const typeOverride = readSetting("amicode.device.type"); + const detectedType = (deps.deviceType ?? (() => detectDeviceTypeVia(runner, process.platform)))(); + const deviceType = typeOverride && typeOverride.trim() !== "" ? typeOverride.trim() : detectedType; + + const now = (deps.now ?? (() => new Date().toISOString()))(); + + // ── seed lifecycle-admin authority (#1541, ADR 0034 D3) ── + // Record the enroller (the canonical server) as THIS machine's lifecycle-admin + // authority — the net-new persistence that makes a headless target approvable + // from a UI-bearing authority machine (the target only ever ENFORCES). It runs + // AFTER the pin check + fleet.json commit (a refused enroll returns before this + // and seeds nothing) and is a pure side-addition — the enroll result, roster + // row, and join token are byte-identical to before. The authority's identity + // anchor is the canonical server's stable id (keypair fingerprints are not + // minted at enroll yet — #1477's rollout will thread a real fingerprint here). + (deps.recordLifecycleAuthority ?? ((rec: LifecycleAuthorityRecord) => schemaRecordLifecycleAuthority(rec)))({ + targetMachineId: machineId, + authorityMachineId: canonical.host, + authorityIdentityKey: canonical.host, + recordedAt: now, + }); + + // ── register the roster row (#1318 POST /amicode/roster), provisional ── + const provisionalRow: RosterRow = { + machine_id: machineId, + name, + server_mode: "client", + capabilities: caps, + sshAlias: canonical.sshAlias, + transport, + last_report: now, + health: "reachable", + ...(deviceType ? { device_type: deviceType } : {}), + }; + await postRosterRow(canonical, undefined, provisionalRow, fetchImpl); + + // ── set the transport, then run the installer (guard + client tunnel) ── + (deps.setTransport ?? defaultSetTransport)(transport); + const inst = (deps.runInstaller ?? defaultRunInstaller)("client"); + + // ── VERIFY ATTACH (AC3): probe the just-set transport ── + const resolveOrigin = deps.resolveProbeOrigin ?? defaultResolveProbeOrigin; + const verify = await runVerifyAttach(resolveOrigin, transport, canonical, token, fetchImpl, deps.retryDelayMs); + + const result: EnrollResult = { + machine_id: machineId, + name, + server_mode: "client", + capabilities: caps, + transport, + verify_attach: verify.ok ? { ok: true } : { ok: false, cause: verify.cause }, + }; + + if (verify.ok) { + return { + json: { + verb: "fleet", + subcommand: "enroll", + ok: true, + result, + installer_ok: inst.ok, + note: "client enrolled — fleet.json written, roster row registered, transport set, guard installed, verify-attach passed", + }, + code: 0, + }; + } + + // ── honest failure: reflect it on the roster row, do NOT report success ── + const failedHealth: RosterHealth = verify.cause === "transport-down" ? "down" : "degraded"; + await postRosterRow( + canonical, + undefined, + { ...provisionalRow, health: failedHealth, last_report: (deps.now ?? (() => new Date().toISOString()))() }, + fetchImpl, + ); + return { + json: { + verb: "fleet", + subcommand: "enroll", + ok: false, + result, + cause: verify.cause, + fix: verify.fix, + roster_health: failedHealth, + installer_ok: inst.ok, + note: "verify-attach FAILED — the roster row's health reflects it and success is NOT reported (no silent fallback)", + }, + code: 65, + }; +} + +type VerifyOutcome = { ok: true } | { ok: false; cause: string; fix: string }; + +async function runVerifyAttach( + resolveOrigin: (transport: string, canonical: JoinTokenCanonical) => ProbeOriginResult, + transport: string, + canonical: JoinTokenCanonical, + token: JoinToken, + fetchImpl: typeof fetch, + retryDelays: number[] = [1000, 2000, 4000], +): Promise { + const originR = resolveOrigin(transport, canonical); + if (!originR.ok) return { ok: false, cause: originR.cause, fix: originR.fix }; + + const maxAttempts = 1 + retryDelays.length; // first try + retries + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const probe = await probeHealth(originR.origin, undefined, fetchImpl); + + // Only "unreachable" (transport-down) retries — auth rejections, pin + // mismatches, and other non-transport failures fail immediately. + if ("error" in probe) { + if (attempt < retryDelays.length) { + await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt])); + continue; + } + // Exhausted retries + return { + ok: false, + cause: "transport-down", + fix: `the ${transport} transport to ${canonical.host}:${canonical.port} is not reachable — confirm the tunnel/tailscale is up, then re-run enroll`, + }; + } + if (probe.status === 401 || probe.status === 403) { + return { + ok: false, + cause: "auth-rejected", + fix: "the hub rejected the Fleet token — re-mint it on the server (`amico fleet enroll --as-server`) and re-enroll with the fresh join token", + }; + } + if (probe.status !== 200) { + return { + ok: false, + cause: "transport-down", + fix: `the transport reached the host but it answered HTTP ${probe.status} on /global/health — confirm the hub is healthy, then re-run enroll`, + }; + } + // 200 but a pin that does not match the host = reached the WRONG/updated host. + if (probe.version !== null && !versionSkewVerdict(token.pin_version, probe.version).agree) { + return { + ok: false, + cause: "pin-mismatch", + fix: `verify-attach reached a host reporting ${probe.version} but the token pins ${token.pin_version} — the transport points at the wrong host or it was upgraded; re-mint the join token`, + }; + } + return { ok: true }; + } + + // Unreachable — the loop always returns — but TypeScript needs it. + return { + ok: false, + cause: "transport-down", + fix: `the ${transport} transport to ${canonical.host}:${canonical.port} is not reachable — confirm the tunnel/tailscale is up, then re-run enroll`, + }; +} + +// ── the enroll verb body ─────────────────────────────────────────────────── +export async function fleetEnroll(argv: string[], deps: FleetEnrollDeps = {}): Promise { + if (argv.includes("--as-server")) { + return enrollAsServer(argv, deps); + } + + const tokenPath = flagValue(argv, "--join-token"); + const tokenJson = flagValue(argv, "--join-token-json"); + let raw: string | null = null; + if (tokenJson !== undefined) { + raw = tokenJson; + } else if (tokenPath !== undefined) { + raw = (deps.readJoinTokenFile ?? defaultReadJoinTokenFile)(tokenPath); + if (raw === null) return fail([`join token file not found or unreadable: ${tokenPath}`]); + } else { + return fail([ + "enroll needs a role: `amico fleet enroll --as-server` (mint a join token) or `amico fleet enroll --join-token ` (redeem one)", + ]); + } + + const token = parseJoinToken(raw); + if (token === null) { + return fail([ + "the join token is malformed — expected { canonical{host,port,sshAlias}, fleet_token, transport_hint, pin_version }", + ]); + } + return enrollAsClient(argv, token, deps); +} diff --git a/packages/amico-run/src/fleet_projection_verb.ts b/packages/amico-run/src/fleet_projection_verb.ts index 60e67e2d2..4f1239b76 100644 --- a/packages/amico-run/src/fleet_projection_verb.ts +++ b/packages/amico-run/src/fleet_projection_verb.ts @@ -32,6 +32,7 @@ import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import { mkdtempSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; import * as path from "node:path"; import { FleetContractVersionError, @@ -41,6 +42,8 @@ import { renderFleetStatus, fleetProjectionCachePath, fleetTopologyPath, + buildBaseProjection, + parseFleetTopology, type FleetProjection, } from "@amicode/schema"; import { PREMIUM_CODE, readCodes } from "./premium.js"; @@ -88,6 +91,14 @@ export interface FleetProjectionDeps { /** #1106: the cache write (injectable). Default: mkdir -p + atomic * tmp+rename, mirroring the extension's writeFleetConfig discipline. */ writeCache?: (p: string, content: string) => void; + /** ADR 0023 (base tier): the STABLE per-machine epoch source. Default: + * read-or-mint `~/.amico/ops/fleet/base_epoch`. Injected in tests. */ + baseEpoch?: () => string; + /** ADR 0023: the monotonic publish counter. Default: a persisted counter + * seeded from the publish wall-second. */ + baseCounter?: () => number; + /** ADR 0023: the ISO publish stamp (provenance only). Default: now. */ + nowIso?: () => string; } function flagValue(argv: string[], name: string): string | undefined { @@ -150,6 +161,171 @@ function bootstrap(reason: "entitlement" | "checkout", rendered: string, extra: }; } +/** The default atomic cache writer — mkdir -p + tmp+rename. Shared by the + * amicissimo success path and the base-tier producer. */ +function defaultWriteCache(p: string, content: string): void { + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmpFile = `${p}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(tmpFile, content); + fs.renameSync(tmpFile, p); + } finally { + fs.rmSync(tmpFile, { force: true }); + } +} + +/** The base-tier epoch file — a stable per-machine UUID, minted once and reused + * so freshness comparisons stay within one epoch (ADR 0023). */ +function baseEpochPath(): string { + return path.join(homedir(), ".amico", "ops", "fleet", "base_epoch"); +} + +/** Read-or-mint the stable base-tier epoch (production default; tests inject + * deps.baseEpoch). Exclusive creation makes concurrent first publishes agree + * on the one epoch that was actually persisted. */ +function defaultBaseEpoch(): string { + const p = baseEpochPath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const epoch = randomUUID(); + try { + fs.writeFileSync(p, epoch, { encoding: "utf8", flag: "wx" }); + return epoch; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; + const existing = fs.readFileSync(p, "utf8").trim(); + if (existing.length === 0) throw new Error(`base-tier epoch file is empty: ${p}`); + return existing; + } +} + +function baseCounterPath(): string { + return path.join(homedir(), ".amico", "ops", "fleet", "base_counter.json"); +} + +interface BaseCounterLease { + counter: number; + release: () => void; +} + +/** Hold the counter lock through projection publication, so concurrent callers + * cannot publish a lower reserved counter after a higher one. */ +function acquireBaseCounterLock(lockPath: string): () => void { + const token = `${process.pid}:${randomUUID()}`; + const deadline = process.hrtime.bigint() + 5_000_000_000n; + for (;;) { + try { + fs.writeFileSync(lockPath, token, { encoding: "utf8", flag: "wx" }); + return () => { + if (fs.readFileSync(lockPath, "utf8") === token) fs.rmSync(lockPath, { force: true }); + }; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; + if (process.hrtime.bigint() >= deadline) { + throw new Error(`timed out acquiring base-tier counter lock: ${lockPath}`); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + } +} + +/** Persist and reserve the next counter for this epoch. The returned lease is + * released only after the projection cache has been published. */ +function defaultBaseCounter(epoch: string): BaseCounterLease { + const p = baseCounterPath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const release = acquireBaseCounterLock(`${p}.lock`); + try { + let last: number | undefined; + try { + const state = JSON.parse(fs.readFileSync(p, "utf8")) as { epoch?: unknown; counter?: unknown }; + if (state.epoch !== epoch) { + last = undefined; + } else if (Number.isSafeInteger(state.counter) && (state.counter as number) >= 0) { + last = state.counter as number; + } else { + throw new Error(`base-tier counter file is invalid: ${p}`); + } + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + const wallSecond = Math.floor(Date.now() / 1000); + const counter = last === undefined ? wallSecond : Math.max(wallSecond, last + 1); + if (!Number.isSafeInteger(counter)) throw new Error(`base-tier counter exhausted: ${p}`); + defaultWriteCache(p, JSON.stringify({ epoch, counter }) + "\n"); + return { counter, release }; + } catch (e) { + release(); + throw e; + } +} + +/** ADR 0023 — the base-tier producer. When the amicissimo authority is + * unavailable (no entitlement / no checkout) but the machine carries a + * fleet.json declaring a real role (client/server), render + cache a minimal + * contract-v1 projection from that membership file and return success, so the + * base product still gets an enforced client. Returns null when there is no + * usable enrolled role — the caller then keeps the honest bootstrap (exit 75). + * This is a PRODUCER: consumers still read the cached projection through the + * ONE reader, unchanged. */ +function tryBaseTierProjection( + deps: FleetProjectionDeps, + reason: "entitlement" | "checkout", +): VerbResult | null { + const topologyPath = deps.topologyPath ?? fleetTopologyPath(); + const read = deps.readFile ?? ((p: string) => (fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null)); + const raw = read(topologyPath); + if (raw === null) return null; + const topo = parseFleetTopology(raw); + if (topo === null) return null; + // Only an ENROLLED role produces a base projection; standalone/unknown keeps + // the existing bootstrap-75 behavior verbatim (minimal blast radius). + if (topo.role !== "client" && topo.role !== "server") return null; + + const epoch = (deps.baseEpoch ?? defaultBaseEpoch)(); + const lease = deps.baseCounter === undefined + ? defaultBaseCounter(epoch) + : { counter: deps.baseCounter(), release: () => {} }; + try { + const publishedAt = (deps.nowIso ?? (() => new Date().toISOString()))(); + const proj = buildBaseProjection(topo, { epoch, counter: lease.counter, publishedAt }); + const published = JSON.stringify(proj, null, 2); + + const cachePath = deps.cachePath ?? fleetProjectionCachePath(); + const writeCache = deps.writeCache ?? defaultWriteCache; + writeCache(cachePath, published); + + const topology = objectValue(proj, "topology"); + const canonical = objectField(topology, "canonical"); + return { + json: { + verb: "fleet", + subcommand: "status", + projection: true, + ok: true, + base_tier: true, + reason, + mode: scalarOrBase(proj, "mode", "standalone"), + posture: scalarOrBase(proj, "posture", "ok"), + ...(topology === undefined ? {} : { role: topology.role }), + ...(canonical === undefined ? {} : { canonical }), + cache_path: cachePath, + publisher: proj.publisher ?? {}, + sections: proj.sections ?? {}, + freshness: { counter: proj.freshness?.counter, hub_epoch: proj.freshness?.hub_epoch }, + summary: renderFleetStatus(proj), + note: + "base-tier projection (ADR 0023) — the amicissimo authority is unavailable (" + + reason + + "); this projection was rendered from the machine's fleet.json by the amicode base-tier producer (a public floor, not the authority). amicissimo stays canonical when present; every consumer reads this through the ONE reader, unchanged. Cached at " + + cachePath, + }, + code: 0, + }; + } finally { + lease.release(); + } +} + /** A section's carried value, with the base default applied when the section * is absent (mode absent = standalone, posture absent = ok — the projection * contract's additive-optional discipline; the base default is applied, not @@ -201,6 +377,11 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps "", "(bootstrap exception, exit 75 — distinct from success and from usage; see `amico premium`)", ].join("\n"); + // ADR 0023: an enrolled machine without amicissimo still gets an honest + // base-tier projection from its fleet.json (so the guard enforces client). + // Only standalone/unenrolled falls through to the bootstrap exception. + const baseTier = tryBaseTierProjection(deps, "entitlement"); + if (baseTier !== null) return baseTier; return bootstrap("entitlement", rendered, { config: configFile }); } @@ -216,6 +397,10 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps "", "(bootstrap exception, exit 75 — distinct from success and from usage)", ].join("\n"); + // ADR 0023: entitled but no checkout — the authority still can't run, so an + // enrolled machine gets the base-tier floor from fleet.json all the same. + const baseTier = tryBaseTierProjection(deps, "checkout"); + if (baseTier !== null) return baseTier; return bootstrap("checkout", rendered, { checkout }); } @@ -277,14 +462,7 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps // contract version never clobbers the consumers' artifact. The cached // bytes are the publisher's own output, verbatim. const cachePath = deps.cachePath ?? fleetProjectionCachePath(); - const writeCache = - deps.writeCache ?? - ((p: string, content: string) => { - fs.mkdirSync(path.dirname(p), { recursive: true }); - const tmpFile = `${p}.tmp`; - fs.writeFileSync(tmpFile, content); - fs.renameSync(tmpFile, p); - }); + const writeCache = deps.writeCache ?? defaultWriteCache; writeCache(cachePath, published); // ── the additive machine fields for script consumers (#1106) ── diff --git a/packages/amico-run/src/fleet_revoke_verb.ts b/packages/amico-run/src/fleet_revoke_verb.ts new file mode 100644 index 000000000..add7a613f --- /dev/null +++ b/packages/amico-run/src/fleet_revoke_verb.ts @@ -0,0 +1,162 @@ +// `amico fleet revoke ` (#1438, ADR 0032 §D5) — the revocation + +// rotation surface. Distrusting one machine no longer re-keys the fleet: +// +// 1. LOCAL expulsion — drop this machine's issued grant for X AND add X to +// the mint-list bar (§D4), on ~/.amico/fleet-peer-tokens.json. This is +// authoritative for THIS machine (single-writer of the registry granting +// access to itself) and always applies. +// 2. FAN-OUT — because a machine is the single writer of its own registry, +// expelling X fleet-wide means EVERY serving peer drops its own issued +// entry for X. This verb fans the revocation out to each serving peer's +// receiving endpoint (POST /amicode/fleet/revoke?machine_id=X); each peer +// applies it to its OWN registry (no machine writes another's). +// +// amico-run cannot import the extension's fleet_issued_tokens.ts, so the LOCAL +// registry mutation is a small self-contained writer over the SAME file shape +// (the githubAppConfigFile cross-package-duplication precedent). The roster read +// is shared via @amicode/schema. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + fleetRosterCachePath, + parseRosterDocument, + placementDescriptor, + type RosterRow, +} from "@amicode/schema"; +import type { VerbResult } from "./verbs.js"; + +/** A serving+reachable peer the fan-out targets. */ +export interface ServingPeer { + machine_id: string; + origin: string; +} + +export interface FanOutResult { + peer: string; + ok: boolean; + error?: string; +} + +export interface FleetRevokeDeps { + /** The local issued-token registry. Default: ~/.amico/fleet-peer-tokens.json + * (env $AMICO_FLEET_ISSUED_TOKEN_FILE). */ + registryFile?: string; + /** ISO clock for the revoked_at stamp. Default: now. */ + now?: () => string; + /** Resolve the serving+reachable peers to fan out to. Default: read the + * host-owned roster and keep serving∧reachable rows that carry a peer_origin. */ + listServingPeers?: () => Promise; + /** Fan a revocation out to ONE peer. Default: POST + * /amicode/fleet/revoke?machine_id=X. */ + fanOutRevoke?: (peer: ServingPeer, machineId: string) => Promise; + /** The HTTP impl for the default fan-out. Default: globalThis.fetch. */ + fetchImpl?: typeof fetch; +} + +const STORE_VERSION = 1; + +function fail(errors: string[]): VerbResult { + return { json: { verb: "fleet", subcommand: "revoke", ok: false, errors }, code: 64 }; +} + +function issuedRegistryPath(deps: FleetRevokeDeps): string { + if (deps.registryFile) return deps.registryFile; + const env = process.env.AMICO_FLEET_ISSUED_TOKEN_FILE; + if (env && env.trim() !== "") return env; + return path.join(require("node:os").homedir(), ".amico", "fleet-peer-tokens.json"); +} + +/** LOCAL expulsion: drop issued[X], set revoked[X] — atomic 0600, preserving + * unknown top-level keys (the same file contract the extension's registry + * writer honors). */ +function revokeLocal(file: string, machineId: string, now: () => string): void { + let doc: Record = {}; + try { + if (fs.existsSync(file)) { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) doc = parsed as Record; + } + } catch { + doc = {}; + } + const issued = { ...((typeof doc.issued === "object" && doc.issued !== null ? doc.issued : {}) as Record) }; + delete issued[machineId]; + const revoked = { ...((typeof doc.revoked === "object" && doc.revoked !== null ? doc.revoked : {}) as Record) }; + revoked[machineId] = { revoked_at: now() }; + const out = { ...doc, store_version: STORE_VERSION, issued, revoked }; + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + try { + fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 }); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); + } finally { + fs.rmSync(tmp, { force: true }); + } +} + +/** Default serving-peer resolution: the host-owned roster's serving∧reachable + * rows that self-report a peer_origin (the HTTP target the fan-out reaches). */ +function defaultListServingPeers(): ServingPeer[] { + const rosterPath = fleetRosterCachePath(); + if (!fs.existsSync(rosterPath)) return []; + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(rosterPath, "utf8")); + } catch { + return []; + } + const doc = parseRosterDocument(parsed); + if (!doc.ok) return []; + const peers: ServingPeer[] = []; + for (const row of doc.doc.rows as RosterRow[]) { + const d = placementDescriptor(row); + if (!d.serving || !d.reachable) continue; + const origin = typeof row.peer_origin === "string" ? row.peer_origin.trim() : ""; + if (origin === "") continue; // SSH-only peers have no direct HTTP origin (out of scope here) + peers.push({ machine_id: row.machine_id, origin }); + } + return peers; +} + +async function defaultFanOutRevoke(peer: ServingPeer, machineId: string, fetchImpl: typeof fetch): Promise { + const url = `${peer.origin.replace(/\/+$/, "")}/amicode/fleet/revoke?machine_id=${encodeURIComponent(machineId)}`; + try { + const res = await fetchImpl(url, { method: "POST" }); + return res.ok ? { peer: peer.origin, ok: true } : { peer: peer.origin, ok: false, error: `HTTP ${res.status}` }; + } catch (e) { + return { peer: peer.origin, ok: false, error: (e as Error).message }; + } +} + +export async function fleetRevoke(argv: string[], deps: FleetRevokeDeps = {}): Promise { + const machineId = (argv[0] ?? "").trim(); + if (machineId === "" || machineId.startsWith("-")) { + return fail(["`amico fleet revoke ` requires a machine_id to revoke"]); + } + + // 1. LOCAL expulsion (authoritative for this machine; always applies). + revokeLocal(issuedRegistryPath(deps), machineId, deps.now ?? (() => new Date().toISOString())); + + // 2. FAN-OUT to every serving peer (each applies it to its own registry). + const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as typeof fetch); + const peers = await (deps.listServingPeers ?? (async () => defaultListServingPeers()))(); + const fan = deps.fanOutRevoke ?? ((peer, id) => defaultFanOutRevoke(peer, id, fetchImpl)); + const fanned_out = await Promise.all(peers.map((p) => fan(p, machineId))); + + const allOk = fanned_out.every((f) => f.ok); + return { + json: { + verb: "fleet", + subcommand: "revoke", + ok: true, // local expulsion succeeded; per-peer fan-out status is itemized + machine_id: machineId, + revoked_local: true, + fanned_out, + note: allOk + ? "revoked locally (dropped + mint-barred) and fanned out to every serving peer" + : "revoked locally (dropped + mint-barred); one or more peers could not be reached — re-run once they are up (their tokens for this machine still authenticate there until then)", + }, + code: 0, + }; +} diff --git a/packages/amico-run/src/fleet_verb.ts b/packages/amico-run/src/fleet_verb.ts index 65e02b0fd..7d0d59c8f 100644 --- a/packages/amico-run/src/fleet_verb.ts +++ b/packages/amico-run/src/fleet_verb.ts @@ -591,6 +591,8 @@ const USAGE = "amico fleet re-tier --session --model [--variant ] | amico fleet sweep [--dry-run] | " + "amico fleet launch --session --pid | " + 'amico fleet finish --session --outcome settled|crashed --pid [--step ""] | ' + + "amico fleet enroll --as-server [--host H --port N --ssh-alias A --transport-hint T] | " + + "amico fleet enroll --join-token | " + "amico fleet digest [--post ] [--machines a,b] [--jobs-line \"\"] [--dry-run] [--root D] | " + "amico fleet shard-watch [--clients a,b] [--port

] [--alert-min ] [--db ] [--post ] [--dry-run]"; @@ -620,6 +622,9 @@ export function fleetVerb(argv: string[], deps: FleetVerbDeps = {}): VerbResult if (sub === "finish") return fleetFinish(rest); if (sub === "digest") return fleetDigest(rest); if (sub === "shard-watch") return shardWatch(rest); + // NOTE: `enroll` is async (real HTTP) and is dispatched at the registry seam + // (verbs.ts `run`), NOT here — so this synchronous router stays synchronous + // and its pinned VerbResult contract (and callers) are untouched. return { json: { verb: "fleet", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, usage: USAGE }, code: 64, diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 5d392b92a..eb7316e05 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -21,6 +21,8 @@ import { noteVerb } from "./note_verb.js"; import { ledgerVerb } from "./ledger_verb.js"; import { profileVerb } from "./profile_verb.js"; import { fleetVerb } from "./fleet_verb.js"; +import { fleetEnroll } from "./fleet_enroll_verb.js"; +import { fleetRevoke } from "./fleet_revoke_verb.js"; import { specVerb } from "./spec_verb.js"; import { planVerb } from "./plan_verb.js"; import { handoffVerb } from "./handoff_verb.js"; @@ -159,10 +161,20 @@ const profile: Verb = { const fleet: Verb = { name: "fleet", summary: - "fleet registry: list/status read verbs, steer/stop/re-tier as signal enqueuers (never a record write), sweep with a pid-liveness guard, digest as the Slack projection; status --projection reads the fleet-authority projection (entitlement-gated, #1068)", + "fleet registry: list/status read verbs, steer/stop/re-tier as signal enqueuers (never a record write), sweep with a pid-liveness guard, digest as the Slack projection; status --projection reads the fleet-authority projection (entitlement-gated, #1068); enroll joins a machine to a fleet (server mint / client redeem, #1319); revoke expels one peer — local drop + mint-list bar + fan-out to serving peers (ADR 0032 §D5, #1438)", generalizes: "the fleet view + in-chat /fleet + Amico's conversational fleet questions + the Slack digest, over ~/.amico/ops/fleet", slice: "fleet substrate (§9 step 2)", - run: fleetVerb, + // `enroll` (#1319) is the ONE async sub-verb (real HTTP: roster POST + + // verify-attach) and lives in its OWN module (fleet_enroll_verb.ts), DISTINCT + // from the session-registry fleetVerb. It is dispatched HERE at the registry + // seam so the synchronous fleetVerb router keeps its pinned VerbResult + // contract; Verb.run already permits a Promise and both callers await it. + run: (args) => + args[0] === "enroll" + ? fleetEnroll(args.slice(1)) + : args[0] === "revoke" + ? fleetRevoke(args.slice(1)) + : fleetVerb(args), }; // spec — the deliberation front half: adversarially review a Spec before it compiles to a diff --git a/packages/amico-run/test/fleet_discover.test.ts b/packages/amico-run/test/fleet_discover.test.ts new file mode 100644 index 000000000..20767c31a --- /dev/null +++ b/packages/amico-run/test/fleet_discover.test.ts @@ -0,0 +1,147 @@ +// fleet_discover.test.ts (#1320) — the read-only discovery helper that backs +// the `/create-a-fleet` orchestrator. `discoverFleetCandidates` is PURE: it +// takes already-gathered inputs (tailnet peers, parsed ~/.ssh/config hosts, the +// #1318 roster rows) and enumerates the candidate machines, deduped, mutating +// nothing. No filesystem, no network — the skill gathers the inputs; this helper +// only reasons over them. A NEW suite, sibling of fleet_enroll_verb.test.ts. +// +// Run: pnpm --filter @amicode/amico-run test fleet_discover +import { describe, it, expect } from "vitest"; +import type { RosterRow } from "@amicode/schema"; +import { discoverFleetCandidates } from "../src/fleet_discover.js"; + +/** Build a full, lawful RosterRow (#1318 shape) from a partial override. */ +function rosterRow(over: Partial & { machine_id: string }): RosterRow { + return { + machine_id: over.machine_id, + name: over.name ?? over.machine_id, + server_mode: over.server_mode ?? "client", + capabilities: over.capabilities ?? [], + sshAlias: over.sshAlias ?? "", + transport: over.transport ?? "ssh", + last_report: over.last_report ?? "2026-09-20T00:00:00.000Z", + health: over.health ?? "reachable", + }; +} + +describe("discoverFleetCandidates — enumeration", () => { + it("enumerates a machine from each source, tagged with the source it came from", () => { + const result = discoverFleetCandidates({ + tailnet: [{ hostName: "aurora", tailscaleIP: "100.64.0.1", online: true }], + sshConfig: [{ alias: "borealis", hostName: "borealis.local" }], + roster: [rosterRow({ machine_id: "cid", name: "cygnus", sshAlias: "cygnus", health: "reachable" })], + }); + + expect(result).toHaveLength(3); + const byName = Object.fromEntries(result.map((c) => [c.name, c])); + expect(byName.aurora.sources).toEqual(["tailnet"]); + expect(byName.borealis.sources).toEqual(["ssh-config"]); + expect(byName.cygnus.sources).toEqual(["roster"]); + }); +}); + +describe("discoverFleetCandidates — dedupe across sources", () => { + it("folds one machine seen in all three sources into a single candidate, merging sources + reach hints", () => { + const result = discoverFleetCandidates({ + // The same machine, named consistently across sources (tailnet HostName == + // ssh alias == roster name), with a MagicDNS suffix on the tailnet name to + // exercise the first-DNS-label normalization. + tailnet: [{ hostName: "mini.tail-abcd.ts.net.", tailscaleIP: "100.64.0.9", online: true }], + sshConfig: [{ alias: "mini", hostName: "mini.local" }], + roster: [rosterRow({ machine_id: "mini-id", name: "mini", sshAlias: "mini", health: "reachable" })], + }); + + expect(result).toHaveLength(1); + const [c] = result; + // sources deduped + in canonical order (roster, ssh-config, tailnet). + expect(c.sources).toEqual(["roster", "ssh-config", "tailnet"]); + expect(c.id).toBe("mini"); + // reach hints merged from whichever sources carried them. + expect(c.sshAlias).toBe("mini"); + expect(c.address).toBe("100.64.0.9"); + }); + + it("keeps distinct machines distinct even when some share a source", () => { + const result = discoverFleetCandidates({ + tailnet: [ + { hostName: "mini", tailscaleIP: "100.64.0.9" }, + { hostName: "studio", tailscaleIP: "100.64.0.10" }, + ], + sshConfig: [{ alias: "mini" }], + roster: [], + }); + expect(result.map((c) => c.id)).toEqual(["mini", "studio"]); + expect(result.find((c) => c.id === "mini")!.sources).toEqual(["ssh-config", "tailnet"]); + expect(result.find((c) => c.id === "studio")!.sources).toEqual(["tailnet"]); + }); +}); + +describe("discoverFleetCandidates — read-only (mutates nothing)", () => { + /** Recursively freeze so ANY write to an input throws in strict mode. */ + function deepFreeze(o: T): T { + if (o && typeof o === "object") { + for (const v of Object.values(o)) deepFreeze(v); + Object.freeze(o); + } + return o; + } + + it("does not read-through-and-write: frozen inputs survive, deep-equal to a pre-call snapshot", () => { + const input = { + tailnet: [{ hostName: "mini.tail-abcd.ts.net.", tailscaleIP: "100.64.0.9", online: true }], + sshConfig: [{ alias: "mini", hostName: "mini.local" }], + roster: [rosterRow({ machine_id: "mini-id", name: "mini", sshAlias: "mini", health: "reachable" })], + }; + const snapshot = structuredClone(input); + deepFreeze(input); + + // The call must not throw (no write to a frozen input) and must produce a result. + const result = discoverFleetCandidates(input); + expect(result.length).toBeGreaterThan(0); + + // The inputs are byte-for-byte what they were. + expect(input).toEqual(snapshot); + }); +}); + +describe("discoverFleetCandidates — enrolled state + the simulated gap", () => { + it("marks a machine enrolled ONLY when its roster row is healthy; a gap machine stays enumerated but unenrolled", () => { + const result = discoverFleetCandidates({ + // hub: enrolled server (verify-attach passed → health reachable). + // gap: discovered on the wire but NO roster row (no amicode / no link yet). + // stuck: has a roster row but verify-attach FAILED (health down). + tailnet: [ + { hostName: "hub", tailscaleIP: "100.64.0.1" }, + { hostName: "gap", tailscaleIP: "100.64.0.2" }, + { hostName: "stuck", tailscaleIP: "100.64.0.3" }, + ], + sshConfig: [{ alias: "hub" }, { alias: "gap" }, { alias: "stuck" }], + roster: [ + rosterRow({ machine_id: "hub-id", name: "hub", sshAlias: "hub", server_mode: "server", health: "reachable" }), + rosterRow({ machine_id: "stuck-id", name: "stuck", sshAlias: "stuck", server_mode: "client", health: "down" }), + ], + }); + + // guide-and-resume: the gap machine is NOT dropped — all three continue. + expect(result.map((c) => c.id)).toEqual(["gap", "hub", "stuck"]); + const byId = Object.fromEntries(result.map((c) => [c.id, c])); + + // hub: a healthy roster row → enrolled, with its role + health surfaced. + expect(byId.hub).toMatchObject({ inRoster: true, enrolled: true, serverMode: "server", health: "reachable" }); + + // gap: no roster row at all → never reported enrolled (nothing to verify yet). + expect(byId.gap).toMatchObject({ inRoster: false, enrolled: false }); + expect(byId.gap.health).toBeUndefined(); + expect(byId.gap.serverMode).toBeUndefined(); + + // stuck: a roster row exists but verify-attach failed → unenrolled until resolved. + expect(byId.stuck).toMatchObject({ inRoster: true, enrolled: false, health: "down" }); + }); + + it("a degraded roster row is also not enrolled (only `reachable` clears the honesty bar)", () => { + const result = discoverFleetCandidates({ + roster: [rosterRow({ machine_id: "d-id", name: "deg", health: "degraded" })], + }); + expect(result[0]).toMatchObject({ inRoster: true, enrolled: false, health: "degraded" }); + }); +}); diff --git a/packages/amico-run/test/fleet_enroll_verb.test.ts b/packages/amico-run/test/fleet_enroll_verb.test.ts new file mode 100644 index 000000000..9595b147b --- /dev/null +++ b/packages/amico-run/test/fleet_enroll_verb.test.ts @@ -0,0 +1,831 @@ +// fleet_enroll_verb.test.ts (#1319) — the enroll primitive: `amico fleet +// enroll`. A NEW suite, DISTINCT from the session-registry fleet_verb.test.ts +// (different domain: enroll writes fleet.json + a roster row; the registry +// writes neither). Each AC gets its own tracer-bullet cycle. +// +// The stub host below mirrors the #1318 host contract (GET/POST /amicode/roster +// + GET /global/health) as a loopback server, so verify-attach and roster +// registration run the REAL fetch path — amico-run cannot import the extension's +// test/support/stub_hub.ts, so this is its sibling. +import { describe, it, expect, afterEach } from "vitest"; +import * as http from "node:http"; +import { hostname as osHostname } from "node:os"; +import type { AddressInfo } from "node:net"; +import { + parseRosterRow, + normalizeDeviceName, + type RosterRow, + type LifecycleAuthorityRecord, +} from "@amicode/schema"; +import { + fleetEnroll, + parseJoinToken, + detectDeviceTypeVia, + detectDeviceNameVia, + type CommandRunner, + type FleetEnrollDeps, + type JoinToken, + type EnrollResult, +} from "../src/fleet_enroll_verb.js"; + +// VerbResult.json is typed `unknown`; this is the enroll verb's structured shape. +interface EnrollJson { + ok: boolean; + result?: EnrollResult; + join_token?: JoinToken; + join_token_path?: string; + cause?: string; + fix?: string; + errors?: string[]; + stage?: string; + wrote_nothing?: boolean; + roster_health?: string; + installer_ok?: boolean; + self_registered?: boolean; + caveat?: string; +} +const j = (r: { json: unknown }): EnrollJson => r.json as EnrollJson; + +// ── a loopback stub host (the #1318 roster contract + /global/health) ────────── +interface EnrollStubOptions { + version?: string; // GET /global/health → version (default v1.18.29) + requireAuth?: string; // 401 unless Authorization matches + healthStatus?: number; // force a non-200 on /global/health (auth-rejected etc.) + dead?: boolean; // never listen (transport-down); url points at a closed port +} +interface EnrollStub { + url: string; + host: string; + port: number; + rosterRows: () => RosterRow[]; + rosterPosts: () => RosterRow[]; + healthHits: () => number; + stop: () => Promise; +} + +async function startEnrollStub(opts: EnrollStubOptions = {}): Promise { + const version = opts.version ?? "v1.18.29"; + const rows: RosterRow[] = []; + const posts: RosterRow[] = []; + let health = 0; + const readBody = (req: http.IncomingMessage): Promise => + new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c as Buffer)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + const server = http.createServer((req, res) => { + if (opts.requireAuth !== undefined && req.headers.authorization !== opts.requireAuth) { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "unauthorized" })); + return; + } + if (req.method === "GET" && req.url?.startsWith("/global/health")) { + health += 1; + if (opts.healthStatus && opts.healthStatus !== 200) { + res.writeHead(opts.healthStatus, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ healthy: true, version })); + return; + } + if (req.method === "GET" && (req.url === "/amicode/roster" || req.url?.startsWith("/amicode/roster?"))) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ schema_version: 1, rows })); + return; + } + if (req.method === "POST" && (req.url === "/amicode/roster" || req.url?.startsWith("/amicode/roster?"))) { + void readBody(req).then((raw) => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "bad_request: body must be JSON" })); + return; + } + const row = parseRosterRow(parsed); + if (!row.ok) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "bad_request: not a well-formed roster row" })); + return; + } + posts.push(row.row); + // single-writer upsert by machine_id (mirrors #1318 upsertRosterRow) + const i = rows.findIndex((r) => r.machine_id === row.row.machine_id); + if (i >= 0) rows[i] = row.row; + else rows.push(row.row); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, machine_id: row.row.machine_id, error: null })); + }); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "not found" })); + }); + if (opts.dead) { + // Bind then immediately close: the port is known but nothing listens → + // fetch fails with ECONNREFUSED (the honest transport-down probe). + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + }); + await new Promise((r) => server.close(() => r())); + return { + url: `http://127.0.0.1:${port}`, + host: "127.0.0.1", + port, + rosterRows: () => rows, + rosterPosts: () => posts, + healthHits: () => health, + stop: async () => {}, + }; + } + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + }); + return { + url: `http://127.0.0.1:${port}`, + host: "127.0.0.1", + port, + rosterRows: () => rows, + rosterPosts: () => posts, + healthHits: () => health, + stop: () => new Promise((r) => server.close(() => r())), + }; +} + +// A dep bundle that records every side effect and injects a fake identity, so a +// cycle can assert order + gating without touching the real machine. +interface Recorder { + deps: FleetEnrollDeps; + fleetWrites: { config: unknown; path: string }[]; + joinTokenWrites: { path: string; token: JoinToken }[]; + installerCalls: string[]; + transportSets: string[]; + engine: { spawns: number }; + authorityRecords: LifecycleAuthorityRecord[]; +} +function recorder(over: Partial = {}): Recorder { + const fleetWrites: { config: unknown; path: string }[] = []; + const joinTokenWrites: { path: string; token: JoinToken }[] = []; + const installerCalls: string[] = []; + const transportSets: string[] = []; + const engine = { spawns: 0 }; + const authorityRecords: LifecycleAuthorityRecord[] = []; + const deps: FleetEnrollDeps = { + machineId: () => "machine-abc", + machineName: () => "workbench", + capabilities: () => [], + clientVersion: () => "v1.18.29", + now: () => "2026-01-01T00:00:00.000Z", + fleetConfigPath: "/tmp/nonexistent-enroll/fleet.json", + writeFleetConfig: (config, path) => fleetWrites.push({ config, path }), + writeJoinToken: (path, token) => joinTokenWrites.push({ path, token }), + joinTokenOutPath: "/tmp/nonexistent-enroll/join-token.json", + runInstaller: (role) => { + installerCalls.push(role); + return { ok: true }; + }, + setTransport: (kind) => transportSets.push(kind), + mintFleetToken: () => "FLEET-SECRET", + spawnEngine: () => { + engine.spawns += 1; + }, + retryDelayMs: [0, 0, 0], + // Keep device-identity detection hermetic by default: no shelling out, no + // real settings.json read. Individual cases override these seams. + commandRunner: () => "", + readDeviceSetting: () => undefined, + // Capture the lifecycle-admin authority record (#1541) instead of writing + // the real ~/.amico store — keeps every enroll test hermetic. + recordLifecycleAuthority: (rec) => authorityRecords.push(rec), + ...over, + }; + return { deps, fleetWrites, joinTokenWrites, installerCalls, transportSets, engine, authorityRecords }; +} + +const stubs: EnrollStub[] = []; +afterEach(async () => { + while (stubs.length) await stubs.pop()!.stop(); +}); +async function stub(opts?: EnrollStubOptions): Promise { + const s = await startEnrollStub(opts); + stubs.push(s); + return s; +} + +describe("amico fleet enroll --as-server (#1319 AC1)", () => { + it("provisions the hub service, mints the Fleet token, and emits a join token bundling {canonical, fleet_token, transport_hint, pin_version}", async () => { + const rec = recorder(); + const r = await fleetEnroll( + ["--as-server", "--host", "hub.example", "--port", "4096", "--ssh-alias", "hub", "--transport-hint", "ssh"], + rec.deps, + ); + expect(r.code).toBe(0); + expect(j(r).ok).toBe(true); + + // fleet.json written role=server + canonical + expect(rec.fleetWrites).toHaveLength(1); + expect((rec.fleetWrites[0].config as { role: string }).role).toBe("server"); + expect((rec.fleetWrites[0].config as { canonical: unknown }).canonical).toEqual({ + host: "hub.example", + port: 4096, + sshAlias: "hub", + }); + + // the durable hub service is provisioned via the installer (server role) + expect(rec.installerCalls).toContain("server"); + + // the Fleet token is minted and the join token emitted with the 4 fields + expect(rec.joinTokenWrites).toHaveLength(1); + const token = rec.joinTokenWrites[0].token; + expect(token.canonical).toEqual({ host: "hub.example", port: 4096, sshAlias: "hub" }); + expect(token.fleet_token).toBe("FLEET-SECRET"); + expect(token.transport_hint).toBe("ssh"); + expect(token.pin_version).toBe("v1.18.29"); + + // the emitted join token is also surfaced on the result for #1320 to hand out + expect(j(r).join_token).toEqual(token); + }); + + // #1354 follow-up: minting must pin the host's REAL engine version, not the + // placeholder "dev" — a dev-minted token otherwise fails the pin-check against + // the server's own 1.18.x engine before verify-attach ever runs. + it("pins the host's probed /global/health version when no version is explicitly configured", async () => { + const s = await stub({ version: "1.18.29" }); + const savedEnv = process.env.AMICO_CLIENT_VERSION; + delete process.env.AMICO_CLIENT_VERSION; + try { + // No clientVersion injected → the mint must probe the local hub (127.0.0.1:port), + // NOT fall back to "dev". The stub answers /global/health with 1.18.29. + const rec = recorder({ clientVersion: undefined }); + const r = await fleetEnroll( + ["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub", "--transport-hint", "ssh"], + rec.deps, + ); + expect(r.code).toBe(0); + expect(rec.joinTokenWrites).toHaveLength(1); + expect(rec.joinTokenWrites[0].token.pin_version).toBe("1.18.29"); + } finally { + if (savedEnv === undefined) delete process.env.AMICO_CLIENT_VERSION; + else process.env.AMICO_CLIENT_VERSION = savedEnv; + } + }); + + it("falls back to \"dev\" when the local hub is unreachable at mint time", async () => { + const savedEnv = process.env.AMICO_CLIENT_VERSION; + delete process.env.AMICO_CLIENT_VERSION; + try { + const rec = recorder({ clientVersion: undefined }); + // --port points at a closed port → the mint-time probe is unreachable → "dev". + const r = await fleetEnroll( + ["--as-server", "--host", "hub.example", "--port", "1", "--ssh-alias", "hub"], + rec.deps, + ); + expect(r.code).toBe(0); + expect(rec.joinTokenWrites[0].token.pin_version).toBe("dev"); + } finally { + if (savedEnv === undefined) delete process.env.AMICO_CLIENT_VERSION; + else process.env.AMICO_CLIENT_VERSION = savedEnv; + } + }); + + it("lets an explicit AMICO_CLIENT_VERSION override win over the probe", async () => { + const s = await stub({ version: "1.18.29" }); + const savedEnv = process.env.AMICO_CLIENT_VERSION; + process.env.AMICO_CLIENT_VERSION = "operator-pin"; + try { + const rec = recorder({ clientVersion: undefined }); + const r = await fleetEnroll( + ["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub"], + rec.deps, + ); + expect(r.code).toBe(0); + expect(rec.joinTokenWrites[0].token.pin_version).toBe("operator-pin"); + } finally { + if (savedEnv === undefined) delete process.env.AMICO_CLIENT_VERSION; + else process.env.AMICO_CLIENT_VERSION = savedEnv; + } + }); +}); + +// A ready-to-redeem join token pointing at a stub host. +function tokenFor(s: EnrollStub, over: Partial = {}): JoinToken { + return { + canonical: { host: s.host, port: s.port, sshAlias: "hub" }, + fleet_token: "FLEET-SECRET", + transport_hint: "ssh", + pin_version: "v1.18.29", + ...over, + }; +} + +describe("amico fleet enroll — client redeem happy path (#1319 AC2)", () => { + it("writes fleet.json (role+canonical ONLY), registers the roster row, sets the transport, runs the installer, and reports success after verify-attach", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder(); + const token = tokenFor(s); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps); + + expect(r.code).toBe(0); + expect(j(r).ok).toBe(true); + + // fleet.json = role + canonical ONLY (no capabilities smuggled in) + expect(rec.fleetWrites).toHaveLength(1); + expect(rec.fleetWrites[0].config).toEqual({ role: "client", canonical: token.canonical }); + + // the roster row registered on the host, contract-valid, server_mode=client + expect(s.rosterRows()).toHaveLength(1); + const row = s.rosterRows()[0]; + expect(row.machine_id).toBe("machine-abc"); + expect(row.name).toBe("workbench"); + expect(row.server_mode).toBe("client"); + expect(row.transport).toBe("ssh"); + expect(row.sshAlias).toBe("hub"); + expect(row.health).toBe("reachable"); + // capabilities live on the ROW, not fleet.json + expect(row.capabilities).toEqual([]); + + // transport set + installer run (guard) as a client + expect(rec.transportSets).toEqual(["ssh"]); + expect(rec.installerCalls).toEqual(["client"]); + + // success reported only after verify-attach passed + expect(j(r).result?.verify_attach).toEqual({ ok: true }); + }); + + it("defaults the transport to tailscale when this machine's capabilities include `roaming` (else the token's hint)", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder({ capabilities: () => ["roaming"] }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + + expect(r.code).toBe(0); + expect(rec.transportSets).toEqual(["tailscale"]); + expect(s.rosterRows()[0].transport).toBe("tailscale"); + expect(s.rosterRows()[0].capabilities).toEqual(["roaming"]); + }); +}); + +describe("amico fleet enroll — client redeem seeds lifecycle-admin authority (#1541 AC1)", () => { + it("records the enroller (canonical.host) as this machine's lifecycle-admin authority — persisted + resolvable", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder(); + const token = tokenFor(s); // canonical.host = s.host (the enroller / canonical server) + const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps); + + expect(r.code).toBe(0); + expect(rec.authorityRecords).toHaveLength(1); + const a = rec.authorityRecords[0]; + // the target is THIS machine (the one running Enroll); the authority is the enroller. + expect(a.targetMachineId).toBe("machine-abc"); + expect(a.authorityMachineId).toBe(token.canonical.host); + expect(a.authorityIdentityKey).toBe(token.canonical.host); + expect(typeof a.recordedAt).toBe("string"); + }); + + it("a pin-mismatch refusal seeds NO authority (nothing recorded on a refused enroll)", async () => { + const s = await stub({ version: "v2.0.0" }); // major skew → refused before any write + const rec = recorder(); + const r = await fleetEnroll( + ["--join-token-json", JSON.stringify(tokenFor(s, { pin_version: "v1.18.29" }))], + rec.deps, + ); + expect(r.code).not.toBe(0); + expect(rec.authorityRecords).toEqual([]); + }); + + it("the server path (--as-server) records no authority-over-a-target (it establishes the keeper, not a grantee)", async () => { + const rec = recorder(); + const r = await fleetEnroll(["--as-server", "--host", "hub", "--port", "4096", "--ssh-alias", "hub"], rec.deps); + expect(r.code).toBe(0); + expect(rec.authorityRecords).toEqual([]); + }); +}); + +describe("amico fleet enroll — pin check refuses a skewed token BEFORE any write (#1319 AC4)", () => { + it("rejects a join token whose pin_version disagrees with the host-version probe, writing NOTHING", async () => { + const s = await stub({ version: "v2.0.0" }); // host is v2.x; token pins v1.18.29 → major skew + const rec = recorder(); + const token = tokenFor(s, { pin_version: "v1.18.29" }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps); + + // refused, honestly + expect(r.code).not.toBe(0); + expect(j(r).ok).toBe(false); + expect(j(r).cause).toBe("pin-mismatch"); + // the reason names BOTH versions (the pure versionSkewVerdict speaking) + expect(j(r).errors?.join(" ")).toContain("1.18.29"); + expect(j(r).errors?.join(" ")).toContain("2.0.0"); + + // BEFORE any file is written: no fleet.json, no roster row, no transport, no installer + expect(j(r).wrote_nothing).toBe(true); + expect(rec.fleetWrites).toEqual([]); + expect(s.rosterRows()).toEqual([]); + expect(rec.transportSets).toEqual([]); + expect(rec.installerCalls).toEqual([]); + }); +}); + +describe("amico fleet enroll — verify-attach honest failure (#1319 AC3)", () => { + it("transport down: the just-set transport is unreachable → cause=transport-down, row health=down, NO success", async () => { + const s = await stub({ version: "v1.18.29" }); // canonical healthy → pin check passes + // verify-attach probes the transport, which resolves to a dead port + const rec = recorder({ resolveProbeOrigin: () => ({ ok: true, origin: "http://127.0.0.1:1" }) }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + + expect(r.code).not.toBe(0); + expect(j(r).ok).toBe(false); + expect(j(r).cause).toBe("transport-down"); + expect(typeof j(r).fix).toBe("string"); // the specific fix accompanies the cause + expect(j(r).result?.verify_attach).toEqual({ ok: false, cause: "transport-down" }); + // the roster row's health reflects the failure (re-posted), NOT a false green + expect(s.rosterRows()).toHaveLength(1); + expect(s.rosterRows()[0].health).toBe("down"); + }); + + it("auth rejected: the transport reaches a host that 401s → cause=auth-rejected, row health=degraded, NO success", async () => { + const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy) + const rejecting = await stub({ healthStatus: 401 }); // what the transport actually reaches + const rec = recorder({ resolveProbeOrigin: () => ({ ok: true, origin: rejecting.url }) }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps); + + expect(r.code).not.toBe(0); + expect(j(r).cause).toBe("auth-rejected"); + expect(canonical.rosterRows()[0].health).toBe("degraded"); + }); + + it("sshAlias unresolved: an ssh transport with no alias → cause=sshAlias-unresolved, NO success", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder(); + // transport_hint ssh + an EMPTY sshAlias → the default origin resolver refuses + const token = tokenFor(s, { transport_hint: "ssh", canonical: { host: s.host, port: s.port, sshAlias: "" } }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps); + + expect(r.code).not.toBe(0); + expect(j(r).cause).toBe("sshAlias-unresolved"); + expect(j(r).ok).toBe(false); + expect(s.rosterRows()[0].health).toBe("degraded"); + }); +}); + +describe("amico fleet enroll — idempotent re-run repairs in place (#1319 AC5)", () => { + it("a second enroll on an already-enrolled machine produces NO duplicate roster row and no duplicate unit path", async () => { + const s = await stub({ version: "v1.18.29" }); + const token = tokenFor(s); + + const first = await fleetEnroll(["--join-token-json", JSON.stringify(token)], recorder().deps); + const rec2 = recorder(); + const second = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec2.deps); + + expect(first.code).toBe(0); + expect(second.code).toBe(0); + // single-writer upsert by machine_id → exactly ONE row after two runs + expect(s.rosterRows()).toHaveLength(1); + expect(s.rosterRows()[0].machine_id).toBe("machine-abc"); + // the re-run still repairs via the installer (idempotent), never a second mechanism + expect(rec2.installerCalls).toEqual(["client"]); + // two posts total (one per run), collapsed to one row — no duplication + expect(s.rosterPosts().length).toBe(2); + }); +}); + +describe("amico fleet enroll — the enroll-result JSON shape #1320 consumes (#1319 AC6)", () => { + it("client result has EXACTLY {machine_id,name,server_mode,capabilities,transport,verify_attach:{ok}}", async () => { + const s = await stub({ version: "v1.18.29" }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], recorder().deps); + const result = j(r).result!; + expect(Object.keys(result).sort()).toEqual([ + "capabilities", + "machine_id", + "name", + "server_mode", + "transport", + "verify_attach", + ]); + expect(Object.keys(result.verify_attach)).toEqual(["ok"]); // no cause on success + expect(result.server_mode).toBe("client"); + }); + + it("server result carries the same fixed shape (server_mode=server, verify_attach ok)", async () => { + const r = await fleetEnroll(["--as-server", "--host", "hub", "--port", "4096", "--ssh-alias", "hub"], recorder().deps); + const result = j(r).result!; + expect(Object.keys(result).sort()).toEqual([ + "capabilities", + "machine_id", + "name", + "server_mode", + "transport", + "verify_attach", + ]); + expect(result.server_mode).toBe("server"); + expect(result.verify_attach.ok).toBe(true); + }); + + it("on failure the verify_attach carries {ok:false, cause} — the exact shape, with the cause", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder({ resolveProbeOrigin: () => ({ ok: true, origin: "http://127.0.0.1:1" }) }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + expect(Object.keys(j(r).result!.verify_attach).sort()).toEqual(["cause", "ok"]); + expect(j(r).result!.verify_attach.ok).toBe(false); + }); +}); + +describe("amico fleet enroll — never-fork: a client installs the guard and spawns NO engine (#1319, ADR 0005)", () => { + it("enrolling as a client runs the installer (guard) and NEVER touches the engine-spawn seam", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder(); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + + expect(r.code).toBe(0); + // the guard is installed via the client installer (the never-fork enforcement) + expect(rec.installerCalls).toEqual(["client"]); + // and the enroll verb NEVER spawns a local engine (the tripwire stays untouched) + expect(rec.engine.spawns).toBe(0); + }); +}); + +describe("amico fleet enroll — open-auth hub: no HTTP credentials sent (#1354 Bug 2)", () => { + it("does NOT send an Authorization header to the hub (the SSH tunnel is the auth boundary)", async () => { + const s = await stub({ version: "v1.18.29" }); + const authHeaders: (string | undefined)[] = []; + const spyFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const hdrs = init?.headers as Record | undefined; + authHeaders.push(hdrs?.Authorization); + return globalThis.fetch(input, init); + }) as typeof fetch; + const rec = recorder({ fetchImpl: spyFetch }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + + expect(r.code).toBe(0); + expect(j(r).ok).toBe(true); + // Every fetch call should have Authorization = undefined (open auth) + expect(authHeaders.length).toBeGreaterThan(0); // at least the pin check + verify-attach + expect(authHeaders.every((h) => h === undefined)).toBe(true); + }); +}); + +describe("amico fleet enroll — verify-attach retries on transient transport-down (#1354 Bug 3)", () => { + it("retries up to 3 times on transport-down, then succeeds when the host comes up", async () => { + const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy) + const verifyTarget = await stub({ version: "v1.18.29" }); // verify-attach target + let verifyProbeCount = 0; + const rec = recorder({ + resolveProbeOrigin: () => ({ ok: true as const, origin: verifyTarget.url }), + fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url.includes(`:${verifyTarget.port}/global/health`)) { + verifyProbeCount++; + if (verifyProbeCount <= 2) throw new TypeError("fetch failed"); + } + return globalThis.fetch(input, init); + }) as typeof fetch, + retryDelayMs: [0, 0, 0], + }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps); + + expect(r.code).toBe(0); + expect(j(r).ok).toBe(true); + expect(j(r).result?.verify_attach).toEqual({ ok: true }); + expect(verifyProbeCount).toBe(3); // 2 failures + 1 success + expect(verifyTarget.healthHits()).toBe(1); // only the successful probe reached the stub + }); + + it("gives up after exhausting retries and reports transport-down", async () => { + const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy) + const rec = recorder({ + resolveProbeOrigin: () => ({ ok: true as const, origin: "http://127.0.0.1:1" }), + retryDelayMs: [0, 0, 0], + }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps); + + expect(r.code).not.toBe(0); + expect(j(r).ok).toBe(false); + expect(j(r).cause).toBe("transport-down"); + expect(j(r).result?.verify_attach).toEqual({ ok: false, cause: "transport-down" }); + }); + + it("does NOT retry on auth-rejected (401)", async () => { + const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy) + const rejecting = await stub({ healthStatus: 401 }); // what the transport actually reaches + const rec = recorder({ + resolveProbeOrigin: () => ({ ok: true as const, origin: rejecting.url }), + retryDelayMs: [0, 0, 0], + }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps); + + expect(r.code).not.toBe(0); + expect(j(r).cause).toBe("auth-rejected"); + expect(rejecting.healthHits()).toBe(1); // no retries — fails immediately + }); +}); + +// ── device identity on the client roster row (#1371 / #1368, ADR 0028) ───────── +// The producer half of the shared self-report: the client enroll POSTs a row +// whose `name` is the resolved friendly name and whose `device_type` is the +// resolved form factor, per the precedence setting-override → OS-detection → +// prettified-hostname (name) / undefined (type). The impure OS detection hides +// behind ONE injectable command-runner seam; classification is the shared +// @amicode/schema pure functions. +describe("amico fleet enroll — client row carries friendly name + device_type (#1371 AC8)", () => { + it("POSTs a row whose name is the resolved friendly name and device_type the resolved type", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder({ + machineName: () => "Test Laptop", // OS-detected friendly name (scutil ComputerName) + deviceType: () => "laptop", // OS-detected form factor + }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + + expect(r.code).toBe(0); + const row = s.rosterRows()[0]; + expect(row.name).toBe("Test Laptop"); + expect(row.device_type).toBe("laptop"); + }); +}); + +describe("amico fleet enroll — device-identity resolution precedence (#1371 AC9)", () => { + it("override-wins: an explicit amicode.device.name / amicode.device.type setting beats OS detection", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder({ + machineName: () => "detected-name", + deviceType: () => "laptop", + readDeviceSetting: (k) => + k === "amicode.device.name" ? "Lab Rig" : k === "amicode.device.type" ? "server" : undefined, + }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + + expect(r.code).toBe(0); + const row = s.rosterRows()[0]; + expect(row.name).toBe("Lab Rig"); + expect(row.device_type).toBe("server"); + }); + + it("honest fallback: no override + no detected type → name is the prettified hostname, device_type omitted", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder({ + machineName: undefined, // exercise the default name detector + deviceType: undefined, // exercise the default type detector + commandRunner: () => "", // detection yields nothing + readDeviceSetting: () => undefined, // no override + }); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps); + + expect(r.code).toBe(0); + const row = s.rosterRows()[0]; + expect(row.name).toBe(normalizeDeviceName(osHostname())); + expect(row.device_type).toBeUndefined(); + }); +}); + +describe("amico fleet enroll — the friendly name never leaks into canonical/join token (#1371 AC11)", () => { + it("wiring a friendly name does NOT change canonical.host or the minted join token", async () => { + // A friendly, space-bearing display name; NO --host → canonical.host must be + // the RAW hostname, never the friendly name (the seam is decoupled). + const rec = recorder({ machineName: () => "Test Desktop" }); + const r = await fleetEnroll(["--as-server", "--port", "4096", "--ssh-alias", "hub"], rec.deps); + + expect(r.code).toBe(0); + const canonicalHost = (rec.fleetWrites[0].config as { canonical: { host: string } }).canonical.host; + expect(canonicalHost).toBe(osHostname()); + expect(canonicalHost).not.toBe("Test Desktop"); + expect(j(r).join_token?.canonical.host).toBe(osHostname()); + }); +}); + +// ── the impure detector: canned command output through the REAL caller (#1371 AC6/AC7) ── +// AC6: a per-package test feeds canned command output through the real +// command-runner caller into the shared pure classifier — so "on macOS/Linux/WSL +// the row reflects X" is failing-test-able without shelling out. +describe("detectDeviceTypeVia / detectDeviceNameVia — the amico-run impure detector (#1371 AC6/AC7)", () => { + it("darwin: system_profiler Model Name → type; scutil ComputerName → name", () => { + const run: CommandRunner = (cmd) => + cmd === "system_profiler" + ? " Model Name: Mac Studio\n Model Identifier: Mac14,13\n" + : cmd === "scutil" + ? "Test Desktop\n" + : ""; + expect(detectDeviceTypeVia(run, "darwin")).toBe("desktop"); + expect(detectDeviceNameVia(run, "Desktop.local", "darwin")).toBe("Test Desktop"); + }); + + it("darwin: an unrecognized Model Name abstains; a missing ComputerName falls to the prettified hostname", () => { + const run: CommandRunner = () => ""; // both commands yield nothing + expect(detectDeviceTypeVia(run, "darwin")).toBeUndefined(); + expect(detectDeviceNameVia(run, "Desktop.local", "darwin")).toBe("Desktop"); + }); + + it("linux source order: hostnamectl chassis is consulted first (#1371 AC7)", () => { + const run: CommandRunner = (cmd, args) => { + if (cmd === "cat" && args[0] === "/proc/version") return "Linux version 6.8.0-generic"; + if (cmd === "hostnamectl" && args[0] === "chassis") return "server\n"; + return ""; + }; + expect(detectDeviceTypeVia(run, "linux")).toBe("server"); + }); + + it("linux: /sys/class/dmi/id/chassis_type is the second source when hostnamectl gives nothing", () => { + const run: CommandRunner = (cmd, args) => { + if (cmd === "cat" && args[0] === "/proc/version") return "Linux version 6.8.0-generic"; + if (cmd === "hostnamectl") return ""; // no systemd / no chassis + if (cmd === "cat" && args[0] === "/sys/class/dmi/id/chassis_type") return "9\n"; // 9 = Laptop + return ""; + }; + expect(detectDeviceTypeVia(run, "linux")).toBe("laptop"); + }); + + it("linux WSL abstains on device_type (#1371 AC7 / ADR 0028)", () => { + const run: CommandRunner = (cmd, args) => + cmd === "cat" && args[0] === "/proc/version" + ? "Linux version 5.15.90.1-microsoft-standard-WSL2" + : "hostnamectl-should-not-be-consulted"; + expect(detectDeviceTypeVia(run, "linux")).toBeUndefined(); + }); + + it("linux: hostnamectl absent ⇒ device_type omitted; name is the prettified hostname (#1371 AC7)", () => { + const run: CommandRunner = () => ""; // nothing detected at all + expect(detectDeviceTypeVia(run, "linux")).toBeUndefined(); + expect(detectDeviceNameVia(run, "box.local", "linux")).toBe("box"); + }); + + it("linux: hostnamectl --pretty (PRETTY_HOSTNAME) drives the name when present", () => { + const run: CommandRunner = (cmd, args) => + cmd === "hostnamectl" && args[0] === "--pretty" ? "Lab Server\n" : ""; + expect(detectDeviceNameVia(run, "box.local", "linux")).toBe("Lab Server"); + }); +}); + +// ── server self-registration (#1372 / #1368, ADR 0028) ──────────────────────── +// Slice 2, the SERVER half: after provisioning the hub, `enroll --as-server` +// self-registers its OWN roster row keyed machine_id = canonical.host (the same +// identity a client references it by, so the client's synthesized node collapses +// against it). The POST happens ONLY after the hub is verified reachable on +// loopback (retry-or-honest-fail); the dev-fallback pin probe is NOT reachability +// proof. `canonical.host` is documented immutable post-enroll. +describe("amico fleet enroll --as-server — self-registration (#1372 AC2)", () => { + it("POSTs its own roster row keyed machine_id = canonical.host with resolved name + type after a verified-reachable hub", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder({ machineName: () => "Test Desktop" }); + const r = await fleetEnroll( + ["--as-server", "--host", "Desktop.local", "--port", String(s.port), "--ssh-alias", "studio"], + rec.deps, + ); + expect(r.code).toBe(0); + expect(j(r).self_registered).toBe(true); + const serverRow = s.rosterRows().find((x) => x.machine_id === "Desktop.local"); + expect(serverRow).toBeDefined(); + expect(serverRow!.server_mode).toBe("server"); + expect(serverRow!.name).toBe("Test Desktop"); + // detection abstains (commandRunner "" in recorder) ⇒ device_type defaults to "server" + expect(serverRow!.device_type).toBe("server"); + expect(serverRow!.sshAlias).toBe("studio"); + expect(serverRow!.health).toBe("reachable"); + }); + + it("honors the amicode.device.type override on the server row (#1372 AC2)", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder({ + machineName: () => "Test Desktop", + readDeviceSetting: (k: string) => (k === "amicode.device.type" ? "desktop" : undefined), + }); + const r = await fleetEnroll(["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub"], rec.deps); + expect(r.code).toBe(0); + const serverRow = s.rosterRows().find((x) => x.machine_id === "hub.example"); + expect(serverRow!.device_type).toBe("desktop"); + }); +}); + +describe("amico fleet enroll --as-server — reachability-verified, no false-success row (#1372 AC1)", () => { + it("does NOT self-register when the hub is unreachable on loopback — no roster POST, no false-success row", async () => { + const postAttempts: string[] = []; + const spy: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url.includes("/amicode/roster") && init?.method === "POST") postAttempts.push(url); + // The hub is unreachable EVERYWHERE (health probe + any POST both fail). + throw new TypeError("fetch failed"); + }) as typeof fetch; + // clientVersion undefined ⇒ the dev-fallback pin probe runs and TOLERATES the + // unreachable hub (returns "dev") — which must NOT be taken as reachability. + const rec = recorder({ fetchImpl: spy, clientVersion: undefined }); + const r = await fleetEnroll( + ["--as-server", "--host", "Desktop.local", "--port", "4096", "--ssh-alias", "hub"], + rec.deps, + ); + // provisioning still succeeded (token minted, fleet.json written), but the + // self-registration honestly did not happen — and NO row was POSTed. + expect(j(r).self_registered).toBe(false); + expect(postAttempts).toEqual([]); + // the dev-fallback pin still resolved (tolerant), proving it is a SEPARATE + // concern from reachability — a dev pin did not green-light the self-POST. + expect(j(r).join_token?.pin_version).toBe("dev"); + }); +}); + +describe("amico fleet enroll --as-server — canonical.host immutability (#1372 AC5)", () => { + it("documents that canonical.host is immutable post-enroll (changing --host requires re-enrolling clients)", async () => { + const s = await stub({ version: "v1.18.29" }); + const r = await fleetEnroll(["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub"], recorder().deps); + expect(r.code).toBe(0); + expect(String(j(r).caveat ?? "")).toMatch(/canonical.*immutable|re-?enroll/i); + }); +}); diff --git a/packages/amico-run/test/fleet_projection_verb.test.ts b/packages/amico-run/test/fleet_projection_verb.test.ts index ab24e52db..e3175c142 100644 --- a/packages/amico-run/test/fleet_projection_verb.test.ts +++ b/packages/amico-run/test/fleet_projection_verb.test.ts @@ -19,13 +19,13 @@ // surfaces the reader's LOUD rejection verbatim (both versions named). // // Run: pnpm --filter @amicode/amico-run test fleet_projection_verb -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fleetVerb } from "../src/fleet_verb.js"; import { fleetProjectionStatus, FLEET_BOOTSTRAP_EXIT, type FleetProjectionDeps } from "../src/fleet_projection_verb.js"; -import { fleetProjectionCachePath, fleetTopologyPath } from "@amicode/schema"; +import { fleetProjectionCachePath, fleetTopologyPath, freshnessBetween, readProjection, BASE_TIER_PUBLISHER_IDENTITY } from "@amicode/schema"; const E1 = "44444444-4444-4444-8444-444444444444"; @@ -37,7 +37,10 @@ let tmp: string; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "fleet-proj-verb-")); }); -afterEach(() => rmSync(tmp, { recursive: true, force: true })); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(tmp, { recursive: true, force: true }); +}); /** A hermetic world: an entitlements file carrying the `amicissimo` code, a * checkout dir, and a runPublisher that copies the fixture projection to the @@ -350,3 +353,136 @@ describe("the fleet verb router", () => { expect((plain.json.errors as string[]).join(" ")).toMatch(/--session is required/); }); }); + +// ── ADR 0023: the base-tier producer (a public floor under the authority) ────── +describe("the base-tier projection (ADR 0023 — enrolled machine, no amicissimo)", () => { + const DET = { baseEpoch: () => "epoch-x", baseCounter: () => 7, nowIso: () => "2026-09-18T00:00:00.000Z" }; + + it("no entitlement + an enrolled client fleet.json → ok (exit 0), base_tier, role=client, canonical carried, cache written", () => { + const topologyPath = join(tmp, "fleet.json"); + const cachePath = join(tmp, "cache.json"); + const canonical = { host: "user@100.64.0.1", port: 4096, sshAlias: "test-desktop" }; + const fleetJson = JSON.stringify({ role: "client", canonical }); + const deps: FleetProjectionDeps = { + readFile: (p) => (p === topologyPath ? fleetJson : null), // NO entitlements file + topologyPath, + cachePath, + ...DET, + }; + const r = run(["--config", join(tmp, "entitlements.toml")], deps); + expect(r.code).toBe(0); + expect(r.json).toMatchObject({ ok: true, base_tier: true, reason: "entitlement", role: "client", mode: "fleet" }); + expect(r.json.canonical).toEqual(canonical); + // the cached projection is readable by the ONE reader, unchanged + const cached = readProjection(cachePath); + expect(cached.publisher?.identity).toBe(BASE_TIER_PUBLISHER_IDENTITY); + expect((cached.sections?.topology?.value as Record).role).toBe("client"); + }); + + it("no entitlement + role=standalone → the honest bootstrap-75 (base tier does NOT engage)", () => { + const topologyPath = join(tmp, "fleet.json"); + const deps: FleetProjectionDeps = { + readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "standalone" }) : null), + topologyPath, + cachePath: join(tmp, "cache.json"), + ...DET, + }; + const r = run(["--config", join(tmp, "entitlements.toml")], deps); + expect(r.code).toBe(FLEET_BOOTSTRAP_EXIT); + expect(r.json).toMatchObject({ bootstrap: true, reason: "entitlement", mode: "standalone" }); + }); + + it("no entitlement + no fleet.json → the honest bootstrap-75, unchanged", () => { + const topologyPath = join(tmp, "fleet.json"); + const deps: FleetProjectionDeps = { + readFile: () => null, // nothing on disk + topologyPath, + cachePath: join(tmp, "cache.json"), + ...DET, + }; + const r = run(["--config", join(tmp, "entitlements.toml")], deps); + expect(r.code).toBe(FLEET_BOOTSTRAP_EXIT); + expect(r.json).toMatchObject({ bootstrap: true, reason: "entitlement" }); + }); + + it("entitled but NO checkout + an enrolled server fleet.json → base tier engages with reason=checkout", () => { + const entitlements = join(tmp, "entitlements.toml"); + const topologyPath = join(tmp, "fleet.json"); + const cachePath = join(tmp, "cache.json"); + const deps: FleetProjectionDeps = { + readFile: (p) => + p === entitlements ? 'codes = ["amicissimo"]' : p === topologyPath ? JSON.stringify({ role: "server", canonical: { host: "h", port: 4096 } }) : null, + checkDir: () => false, // no amicissimo checkout present + topologyPath, + cachePath, + ...DET, + }; + const r = run(["--checkout", join(tmp, "absent-amicissimo"), "--config", entitlements], deps); + expect(r.code).toBe(0); + expect(r.json).toMatchObject({ ok: true, base_tier: true, reason: "checkout", role: "server", mode: "fleet" }); + }); + + it("the base-tier freshness is epoch-bound and knowable (no false-fresh, no refetch loop)", () => { + const topologyPath = join(tmp, "fleet.json"); + const deps: FleetProjectionDeps = { + readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "client", canonical: { port: 4096 } }) : null), + topologyPath, + cachePath: join(tmp, "cache.json"), + ...DET, + }; + const r = run(["--config", join(tmp, "entitlements.toml")], deps); + expect(r.json.freshness).toEqual({ counter: 7, hub_epoch: "epoch-x" }); + }); + + it("the default counter turns an equal wall-second into fresh, not equal-counter stale", () => { + const previousHome = process.env.HOME; + process.env.HOME = join(tmp, "home"); + vi.spyOn(Date, "now").mockReturnValue(2_000_000_000_000); + try { + const topologyPath = join(tmp, "fleet.json"); + const cachePath = join(tmp, "cache.json"); + const deps: FleetProjectionDeps = { + readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "client" }) : null), + topologyPath, + cachePath, + }; + run(["--config", join(tmp, "entitlements.toml")], deps); + const previous = readProjection(cachePath); + run(["--config", join(tmp, "entitlements.toml")], deps); + const fetched = readProjection(cachePath); + + expect(previous.freshness?.counter).toBe(2_000_000_000); + expect(fetched.freshness?.counter).toBe(2_000_000_001); + expect(freshnessBetween(previous, fetched)).toBe("fresh"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + } + }); + + it("the default counter turns a lower wall-second into fresh, not a backwards-counter unknown", () => { + const previousHome = process.env.HOME; + process.env.HOME = join(tmp, "home"); + const now = vi.spyOn(Date, "now").mockReturnValue(2_000_000_000_000); + try { + const topologyPath = join(tmp, "fleet.json"); + const cachePath = join(tmp, "cache.json"); + const deps: FleetProjectionDeps = { + readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "client" }) : null), + topologyPath, + cachePath, + }; + run(["--config", join(tmp, "entitlements.toml")], deps); + const previous = readProjection(cachePath); + now.mockReturnValue(1_000_000_000_000); + run(["--config", join(tmp, "entitlements.toml")], deps); + const fetched = readProjection(cachePath); + + expect(fetched.freshness?.counter).toBe(2_000_000_001); + expect(freshnessBetween(previous, fetched)).toBe("fresh"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + } + }); +}); diff --git a/packages/amico-run/test/fleet_revoke_verb.test.ts b/packages/amico-run/test/fleet_revoke_verb.test.ts new file mode 100644 index 000000000..9ec1a67be --- /dev/null +++ b/packages/amico-run/test/fleet_revoke_verb.test.ts @@ -0,0 +1,91 @@ +// fleet_revoke_verb.test.ts (#1438, ADR 0032 §D5) — `amico fleet revoke +// `: LOCAL expulsion (drop the issued grant + add the mint-list +// bar, on this machine's own registry) PLUS the fan-out to every serving peer +// (each peer applies it to its own registry; no machine writes another's). +// +// A NEW suite, sibling of fleet_enroll_verb.test.ts. The registry file I/O is +// exercised for real on a temp file; the fan-out is injected so the +// orchestration is asserted without a live mesh. +import { describe, it, expect } from "vitest"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fleetRevoke, type FleetRevokeDeps, type ServingPeer } from "../src/fleet_revoke_verb.js"; + +interface RevokeJson { + verb: string; + subcommand: string; + ok: boolean; + machine_id?: string; + revoked_local?: boolean; + fanned_out?: Array<{ peer: string; ok: boolean; error?: string }>; + errors?: string[]; +} +const j = (r: { json: unknown }): RevokeJson => r.json as RevokeJson; + +function tmpRegistry(seed?: unknown): string { + const file = join(mkdtempSync(join(tmpdir(), "amico-revoke-")), "fleet-peer-tokens.json"); + if (seed !== undefined) { + require("node:fs").writeFileSync(file, JSON.stringify(seed, null, 2)); + } + return file; +} + +describe("amico fleet revoke (#1438, ADR 0032 §D5)", () => { + it("requires a machine_id argument", async () => { + const r = await fleetRevoke([], { registryFile: tmpRegistry() }); + expect(r.code).not.toBe(0); + expect(j(r).ok).toBe(false); + }); + + it("LOCAL expulsion: drops the issued grant AND writes the mint-list bar on this machine's registry", async () => { + const file = tmpRegistry({ + store_version: 1, + issued: { evil: { token: "T", scope: "full-session", issued_at: "x" } }, + revoked: {}, + }); + const r = await fleetRevoke(["evil"], { registryFile: file, listServingPeers: async () => [] }); + expect(r.code).toBe(0); + expect(j(r).revoked_local).toBe(true); + const doc = JSON.parse(readFileSync(file, "utf8")) as { issued: Record; revoked: Record }; + expect(doc.issued.evil).toBeUndefined(); // dropped + expect(doc.revoked.evil).toBeDefined(); // barred (the mint-list bar) + }); + + it("FANS OUT to every serving peer (each peer applies it to its own registry)", async () => { + const calls: Array<{ peer: string; machineId: string }> = []; + const peers: ServingPeer[] = [ + { machine_id: "p1", origin: "http://p1:43117" }, + { machine_id: "p2", origin: "http://p2:43117" }, + ]; + const deps: FleetRevokeDeps = { + registryFile: tmpRegistry({ store_version: 1, issued: {}, revoked: {} }), + listServingPeers: async () => peers, + fanOutRevoke: async (peer, machineId) => { + calls.push({ peer: peer.origin, machineId }); + return { peer: peer.origin, ok: true }; + }, + }; + const r = await fleetRevoke(["evil"], deps); + expect(r.code).toBe(0); + expect(calls.map((c) => c.peer).sort()).toEqual(["http://p1:43117", "http://p2:43117"]); + expect(calls.every((c) => c.machineId === "evil")).toBe(true); + expect(j(r).fanned_out?.length).toBe(2); + expect(j(r).fanned_out?.every((f) => f.ok)).toBe(true); + }); + + it("a fan-out failure to one peer is reported (not swallowed), local revoke still applied", async () => { + const file = tmpRegistry({ store_version: 1, issued: { evil: { token: "T" } }, revoked: {} }); + const r = await fleetRevoke(["evil"], { + registryFile: file, + listServingPeers: async () => [{ machine_id: "p1", origin: "http://p1" }], + fanOutRevoke: async () => ({ peer: "http://p1", ok: false, error: "unreachable" }), + }); + // local expulsion is authoritative for THIS machine and always applies + expect(j(r).revoked_local).toBe(true); + const doc = JSON.parse(readFileSync(file, "utf8")) as { revoked: Record }; + expect(doc.revoked.evil).toBeDefined(); + // the failed peer is surfaced honestly + expect(j(r).fanned_out?.some((f) => !f.ok && f.error === "unreachable")).toBe(true); + }); +}); diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 8e65c7f34..db47e49e5 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -8,7 +8,7 @@ "upstream_base_sha": "3104c1428ec91f809e5ab86631300de41eb6952e", "promoted_at": "2026-09-13T22:15:25.839Z", "files": { - "packages/app/CONTEXT.md": "7dc4bf176a32ee578a54593a58a6b97907ffc5a9baf7ae7c16a8fa920d8e6e86", + "packages/app/CONTEXT.md": "69e73ecec62f730398e8f25071e736a58b2f7a1d9059b60788e9936051051f14", "packages/app/index.html": "fa38026be3b7e7ec38958a558d7b4d059140b2aff998c3ac862601543702584d", "packages/app/package.json": "3c9bf3d487052d4834b7fd3ce93fcf6b7fc358de92b8be4b0e84fc424a971f74", "packages/app/playwright.config.ts": "b7dae0b39248b4ddf7cba829af96ac7fe3cc919add9711ecb5292d9bd52e1805", @@ -25,7 +25,7 @@ "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", "packages/app/src/app-desktop-commands.test.ts": "1d2057f238dc14e4407ff5c4e53afe091784790bc0fd0dbfde0eacd91e2bd968", "packages/app/src/app.tsx": "07b292f88de59fad23b0e7564e0c536214917b65b50326552894925a9c0e17d1", - "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910", + "packages/app/src/design-polish.css": "6dceafb232f7132be588debc33c913fa6773966d54d6bbf6a1496b7622adeacc", "packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", @@ -60,6 +60,7 @@ "packages/schema/src/provider-permission.ts": "382d3b8c8e7bac64816d6a8cb0a84dff6e9a4bd1a921a653ebb20b23d4580e14", "packages/schema/src/question.ts": "538b73b25f8993700d7836b915ff27a1aff63cb6a6ef42e77b90bfbef83a5558", "packages/schema/src/session-receipt.ts": "8bdc6d169b3055bfbfd03d10169d71bc10cad8c085409d1f8481210ca6736a4d", + "packages/schema/src/session-status-event.ts": "975b5d271dc46576d40a60f7507f035d7b2dbb9a41d6dc667c1e25e8ba77958b", "packages/schema/src/worktree-event.ts": "833b689b3636335d00a60cbed521f05dc02b467f7ee757de48291a602fac55b3", "packages/schema/test/provenance-matrix.test.ts": "80c7b10e7c249d20e14aea3f6e1341450f63ff8ee99f3c69ac9e2db52d7340e1", "packages/schema/test/question.test.ts": "511f4fabf2841ed89e1fed9594f2e3a1be8eea5bcb50b680d762c49d6ef572d9", @@ -110,14 +111,24 @@ "packages/app/src/components/dialog-release-notes.tsx": "0ea5b049786787643a7f6b54d4ccf84c4f8ffa4e8272426c0d32f60d445b6890", "packages/app/src/components/dialog-select-directory-v2.css": "2e2f18abdca77f9fe85da157c40f288f3b1641ca92845208749e16455fd58637", "packages/app/src/components/dialog-select-file.tsx": "e6278ee35dd27d203e00ad73f70c709c44a6c06f81ffbd931edfaa0c986b9e03", + "packages/app/src/components/engine-state-signal.ts": "2726628b85d4bc58bf6b3bd3045fc4239ad384deaba14fc8f46b818741549a5e", + "packages/app/src/components/engine-toggle-utils.test.ts": "bf53ca7a6fac3696f8d9aac032ceee5e35fb3b7e3dfaef3f9afb1fa0ff3135bb", + "packages/app/src/components/engine-toggle-utils.ts": "bab8a7ddef9f49d4fc243477b7206feb741afdf5ec154a2d70a9f31c814f8c5d", "packages/app/src/components/external-link.tsx": "7130044026ad3ec417de4ba7389cc6059de46f21b370047046269c772aa7a4bb", "packages/app/src/components/help-button.tsx": "780d533f776a784660cfed072a131198bc6fcc8e8fe332e8e1a220d516c9684e", + "packages/app/src/components/new-session-machine-picker.ts": "8c7689f79c7610e32fef478320870d1468fa17a3a02463e2cbe058a487fb4769", "packages/app/src/components/pane-bridge.tsx": "756b3306b51c963fac6778716f6c6c4626f0f3bf75c93b7cb4cab58ea0bf3d4e", "packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8", "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "946b718c2164da394602f26e4d9919d752da0d0b9bee714f345ba16c274542da", - "packages/app/src/components/prompt-input-v2.tsx": "5ab46e41bda4fa11e99f06c37df76c1fd3cce061c6ba7a9f85bee369a965b98a", - "packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4", + "packages/app/src/components/prompt-input-v2.tsx": "3eaa57edd5e6071235f2a75928cc1ead492b7da640b7400ce59310e6a8722baf", + "packages/app/src/components/prompt-input.tsx": "c127f81e2c0de4bdbc21a349473df39c623d8d7e2df8df0c03ea60bbef101a25", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", + "packages/app/src/components/remote-create-arm.test.ts": "388132209e16b6f00d79ac7c700f8c2bfb5f80343eee34cee73451b32842d3f2", + "packages/app/src/components/remote-create-arm.ts": "49cb5f89f041ee5d555a348ec5bbbd9709d448752987c491f63376b02522db36", + "packages/app/src/components/remote-create-header.test.ts": "bfe81618f1ecf13a5e55e19242ce286ef40ab9eb95dae9a64fed03e46d7b1954", + "packages/app/src/components/remote-create-header.ts": "7491c1068231d675dda18cad01e2ad2aefaddea5b23726a8c46cc0a73b3df0c4", + "packages/app/src/components/remote-create-preflight.test.ts": "fe9981c45385235e2ba89a215c5cc23c765c8840befb6d795b2d835833c779cb", + "packages/app/src/components/remote-create-preflight.ts": "9f875be60c8dd0f93d2401008df7841549106611631282a5c478659d88301bd6", "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd", "packages/app/src/components/report-bug-button.stories.tsx": "82aed22bd3fcca448841205dee759ca0f0de92562a99d07c696eee5bd29e807b", "packages/app/src/components/report-bug-button.tsx": "a1beb8c94b4faaa4c7db3ab68de349f8a8a03cb3a058ce447f552aed35848d43", @@ -126,7 +137,7 @@ "packages/app/src/components/settings-providers.tsx": "c86c9bf4a6f6e198dc7b0995a69d5b76e851144987bbe601802b1b0120ee4ac4", "packages/app/src/components/solver-switch-banner.tsx": "ec7edeec6dc05a4a06b39241b4f3a8e7534da97722dd8ec6f26ff782025802e9", "packages/app/src/components/split-frame.tsx": "44c53737f72027b461097c03df48a4f411d04a82d02b1fce4901813c73dfcc72", - "packages/app/src/components/status-popover-body.tsx": "06b457108c48969d3b7419d1d2f3dc7e38a9b18cb45735dd4963ca0f266f6129", + "packages/app/src/components/status-popover-body.tsx": "c7f5cce9066a6388de8531156a300503ead6f596cad2ea4269d64c421a97d0c5", "packages/app/src/components/status-popover-indicator.test.ts": "9afc7b49d9021a94d74f3f777178230ae13bd26e5749da0cee4d3378eacacacd", "packages/app/src/components/status-popover-indicator.ts": "887b3e659ff49fd0d2a8715fad6562dc7e9402b80d06442058502306bdb108f5", "packages/app/src/components/status-popover-model.test.ts": "10634035bab0f31de24564e0d6c8413a0f7434fb2c08ac3ef722981a69dd08e7", @@ -135,12 +146,14 @@ "packages/app/src/components/terminal.tsx": "2ce242034ae129c1f475c7a24fdec3facdd9bb1f49ba8c6b503643b07ae241e5", "packages/app/src/components/titlebar-channel.test.ts": "184238871c46f586dcc21081f710e37a77914c983fccf250665c7317212e41d8", "packages/app/src/components/titlebar-channel.ts": "385ace91d3c69c92f5b442bf15d2040e35df855f1d4ece47312c1ff8e1b01780", - "packages/app/src/components/titlebar-layout.test.ts": "52912f37fab4e7d0d5b7403371060651597f4c4904fefb802ff9e3333facdeed", + "packages/app/src/components/titlebar-layout.test.ts": "00708b4dd63a357f76bec035ce0fad017e5b24c18129f38df039f587e9e71f71", "packages/app/src/components/titlebar-layout.ts": "a375c238ab632421a6505dce3a41fd3894865729e921e68eb32249478c18a286", + "packages/app/src/components/titlebar-portal-dedup.test.ts": "99034b76bf7896247a7eb8d492e7801ac2d67290cce226e70487c008eaff2a1f", + "packages/app/src/components/titlebar-portal-registry.ts": "e6a159c44490125046ddbb249fbda5cef6cef383a604a168cb4018cf49025521", "packages/app/src/components/titlebar-tab-nav.css": "90930c2f9476fb0eace8692424a029b3510781c88d1ee61f7f2edba559526832", - "packages/app/src/components/titlebar-tab-nav.tsx": "2304e936af56c24f27f1549f4f8751a600ed274123d7a3973ecfa6a5bb28102c", + "packages/app/src/components/titlebar-tab-nav.tsx": "0d5e11f63006e87fc42a4682c481203f50622677b07a8ff87a3348613cdf3674", "packages/app/src/components/titlebar-tab-popover.css": "d731db2d7d49838004089a3d82ff68c22214b0ca636bf3b8e02498c14dadbf8d", - "packages/app/src/components/titlebar-tab-strip.tsx": "932a2d01e6673d283a48d704b580f7a7b88ba000061d11f6df5efe994dbd4ee3", + "packages/app/src/components/titlebar-tab-strip.tsx": "a3c8a8df186a6164494230bec7f302cfe3a1cf658e3ab10f9ef0af4f5d63c038", "packages/app/src/components/titlebar.css": "50c262603ba1140db9e4fefe8f03e9c6ae3bf64dc7a917e55586bfd67a5e3011", "packages/app/src/components/titlebar.tsx": "c53f438701e97cbe3fbf2eaa67fb584a0b8b3a91f2d4f8016542156e17cd598c", "packages/app/src/components/vault-browser-model.test.ts": "7ca11eae3574033d58081bc4126b1ec772a5b496573c70159f05497a1365f4cd", @@ -151,11 +164,17 @@ "packages/app/src/components/workbench-panel.tsx": "d16f477044098842a214a2e16f9be719e7688a04bcb575889ca544988f23c690", "packages/app/src/context/command.tsx": "388d6cb12b6dc43ecb0827e3fb918370cb9d16d96161a5990dec10fff05af172", "packages/app/src/context/directory-sync.test.ts": "c03c7bcd95ac47b3de5e1e9719b6eccdb3569063026fe993b448453c818c7b48", - "packages/app/src/context/directory-sync.ts": "8515e0f8a5bdf0bf6243a0c2dfe64f234f5292074464e38c68da8287235acc3e", + "packages/app/src/context/directory-sync.ts": "e53a71fa2b87ad5d73ea0fd8ad8a58fe2d1e06c80e8369d3d89ce21310f0ed7d", + "packages/app/src/context/fleet-reseed-scheduler.test.ts": "a7f3fd7f861d2fe9d89e2eb20d125e61f6aa6180bb7d2ddb5422113a1beeba17", + "packages/app/src/context/fleet-reseed-scheduler.ts": "1ed15079a7920cf5a62f51073587af2d7810f8b27486d3e11ec7b59ba9b8d8ea", + "packages/app/src/context/fleet-session-status.e2e-fixture.ts": "74bcf006f7105ef635c5b0ea251fb0ca06aad5dbaa9c817c77e5093df1e08032", + "packages/app/src/context/fleet-session-status.e2e.test.ts": "e76af016dacfafaf0124c86470bdeda28a49e5d97d9a1cfa9d855cdfbbc83687", + "packages/app/src/context/fleet-session-status.test.ts": "4d37bac382d59df115a72b26a09a1c01dbd1417e04eb443563f070dcab55597d", + "packages/app/src/context/fleet-session-status.ts": "0088413ee2059d2828da97e8f4af98883c09b8d76c1e6078cd5e05b4bdbb7821", "packages/app/src/context/language.tsx": "0f7b1cbd33071f293c15b66e3a900dfd07347f9eb93d8cf61df0fac3bc4effc5", - "packages/app/src/context/layout-side-panel-tabs.test.ts": "47425bbed0ada18d3fcb67a76b63de3b9e1f827cd63a61ffe4eb45430fc5814e", - "packages/app/src/context/layout-side-panel-tabs.ts": "e8426fadbdf9e7c49bf457e00ec92793475b44bcc11548e8b5ef9a9fa461be76", - "packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c", + "packages/app/src/context/layout-side-panel-tabs.test.ts": "297780d590110895baf053bdde81863439dac94a18592287314167db579a89d5", + "packages/app/src/context/layout-side-panel-tabs.ts": "e8dec07b2b5a5ebe68a76c98d688afa1360a4e31741a10bb49228afb8a7f3777", + "packages/app/src/context/layout-tabs.test.ts": "524327496e735379bb3425e78cbeb6cefa92e30296ddc44776eb8e99d644925d", "packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5", "packages/app/src/context/layout.tsx": "7767c8b5f64efd63390cbe99128686bec2f1da7e8f2b21530df4c3fd67d171bb", "packages/app/src/context/local-agent.test.ts": "a5a9d60bb4401d409218cc247c1cc06ede8b7878ddf01dc6080328f54eb9cade", @@ -163,20 +182,32 @@ "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", "packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da", - "packages/app/src/context/server-sdk.test.ts": "5cd22962c8ea1e4374915508a765281ceb9f808ad8bbf241c06b00b48621c816", - "packages/app/src/context/server-sdk.tsx": "7e1b71538136237481f1f5ec05680a81b01c82f24ecf5819c430d317c4a72f17", - "packages/app/src/context/server-session.test.ts": "a76d24b0d36d7c6c9ce187d2eb5bba78c90774184adc0398559ee1a417674516", - "packages/app/src/context/server-session.ts": "4d85011c9ff81cf0470633f6f27cd9f11c234294c31e2c77a990aad135d317ab", - "packages/app/src/context/server-sync.tsx": "14396d7402d697ab9a10744c9e428b96e483f3a9620a0415246fa840596fd8ed", + "packages/app/src/context/server-sdk.test.ts": "833920190c25089a5c3ed9849a62b021d2e0e2e4fb76196af8ddc9e6aa9817ba", + "packages/app/src/context/server-sdk.tsx": "36cfd55e634dbb974d4d6785281fedc80f4ddd539090f859fa7156f959ab500d", + "packages/app/src/context/server-session-child-active.test.ts": "de8c467f67f88b6cce351c1805ec90728cf238eb1ef32f39ffdbbf3de4dcec22", + "packages/app/src/context/server-session-orphan-part.test.ts": "1d635374c0bfff25a01825061aabac7334e5cdaedca2dab9f3d9467ff543caea", + "packages/app/src/context/server-session.test.ts": "30cd24bb1345376fd1a165d65677dee5c36d586efa7969d3b4844967040fda59", + "packages/app/src/context/server-session.ts": "d309c8caad0b444d911a2e63be012d9e269b1791ffbfbb55218b0d631fd684fe", + "packages/app/src/context/server-sync.tsx": "cdc7057c2188d79c5e586f1be05431cb97880ed3fd6b8f3980268db0fa011e34", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/session-mirror.test.ts": "65e6d6474e4e67f2da194047140c8c8aac04fe0cc9926441468e53fabc48db96", "packages/app/src/context/session-mirror.ts": "b98de060f2b06e6f9a48b1f69ed408f12086c626d90c7dd22029a4ee732260e2", + "packages/app/src/context/session-status-reconcile.test.ts": "72fa5682150aa4f2d7c03a61dcda76a682e81f095a2a58e5a686315c28c257d3", + "packages/app/src/context/session-status-reconcile.ts": "4318c0a4a8057cced4ea3895034484d9712c1d33fe8ffd3b4e99acddb4076802", "packages/app/src/context/session-warm.test.ts": "b862625f1e418913a982b368573f5abf07aaf6e1a63ded8296a1d43ef164ec34", "packages/app/src/context/session-warm.ts": "d4184b8f0a090b0a011cdf7b6e80d5f765b6f7acd834eaad064e39ca8e0eb7bc", + "packages/app/src/context/session-working.test.ts": "2c6326946bfe04f5f6bdb879861ba3e1e2f6e749d77940bb6fea55356fcde000", + "packages/app/src/context/session-working.ts": "aa3f70d326c481524920502aae5fc0cdc4dc2c5067dd94969242629154358e9c", "packages/app/src/context/settings.tsx": "19e078e81620ffa21a92797335009b84d4311485a37c5ede2f76906b734292ad", "packages/app/src/context/split.tsx": "891aeb290b369b327a63ce548dcaab1daca08e41fab430648787be7f84e53ec0", + "packages/app/src/context/sse-gap-frame.test.ts": "cf93c169015d66cebbf25752bc5a99296af80a7253bde8b6e565d2c0e50410f3", + "packages/app/src/context/sse-gap-frame.ts": "2f64fde540a305579762c5276721d4bbfbf7c862131aaf4462e8a4e8f32c5960", + "packages/app/src/context/sse-origin.test.ts": "71cd3aa996a51d7e99236a49cede4769e520e20e53f57c6920fe441f2abb0c7d", + "packages/app/src/context/sse-origin.ts": "da9a231907dc50d87cf01758177bee0d4a0b0165d8adb5e152db8eca29dfd9f5", "packages/app/src/context/stream-gap.test.ts": "b7203445564f44fd1d966ef684a11cb9b5ec567d9ab54f1b7617726fdf44d3f5", "packages/app/src/context/stream-gap.ts": "2fd8eba30a6112cebf38e33c9418ab2b7d9c9d466f76729d828d5ab52d56c5ec", + "packages/app/src/context/stream-liveness.test.ts": "63a06ca7fef3e38c0c9abfa23df3d9043b1a804f3c527400d08ed9eb32c466b0", + "packages/app/src/context/stream-liveness.ts": "94fe6fe8a776eb0de6903546f653c89f4577cf7628e7f051afa83280e391d16f", "packages/app/src/context/tab-migration.ts": "569e69cd3425917c7e195f91c3a838abea68b616267ea7511f3ccf01d94ac01e", "packages/app/src/context/tabs.tsx": "06d6ddb6d50727a4e0a01ac1ca4a70b06bf63a4e5f881526aead54ccde62472b", "packages/app/src/context/vault-panel.ts": "f91cb5fb49a9f0d46bd2a1b131f9a218b30147e2ad3617d2a2dc4f1e14599a53", @@ -188,7 +219,7 @@ "packages/app/src/i18n/compaction-failure.test.ts": "a4a3765beb238a2c157c07c9c8ede8418b9febb814d8d1e3a5b2d8868cbd80d9", "packages/app/src/i18n/da.ts": "db685cb94f22ace194850efe9bdc02c530777a4583dc9eb9d00b97002b0a0968", "packages/app/src/i18n/de.ts": "c440ad7543321cae1425e0c2fad92ff73963f21672fbf31176831933573bf103", - "packages/app/src/i18n/en.ts": "01edcfcc7bea550ef0fc98b49ea00c59ce3c320767fb5ab633ca9a4ba59b0e2b", + "packages/app/src/i18n/en.ts": "d073d90198297bcc33ad5da1975a3829250b20a1e18fec35eb65809c35fca973", "packages/app/src/i18n/es.ts": "8cdec3592cddf0b91d7fd59002c81c4e8973c5bf6573e065df64d48c42e1802f", "packages/app/src/i18n/fr.ts": "b9f646352189054b6918bfbac670579e76418c3148eb7e5afca9d71d97e7f2b7", "packages/app/src/i18n/ja.ts": "27ffc41a0fd8dc4b2e814a45c55fad4726ff1c7a85c884cf6af95f8397f17cbf", @@ -205,23 +236,26 @@ "packages/app/src/pages/empty-workspace-landing.tsx": "ac9410f5b223b30e2d82a6d222992fed2226b6afc1213ff0c8305a230cda7cb2", "packages/app/src/pages/error.tsx": "db9ba0847cfb205100ad6133cce30c6ea2ce983802af44947e6d2b746c2a6ec5", "packages/app/src/pages/hold-debug-badge-gate.test.ts": "661f59e172a5b074a6807896f46c18338faa59b6d9c0912fd824df2dae4a5730", + "packages/app/src/pages/home-archive.test.ts": "ee52897297aaa4aabfd122f341b585afce945e62348dad542884f1eb608bcda9", "packages/app/src/pages/home-projects.test.ts": "a90155c37210a0e5b7a095bbfc3ba2d40681f02927d9726148a706bed5ba7891", "packages/app/src/pages/home-projects.ts": "d55124578dc839b98e2084b488d818e30e4a1abf1ae264499b83e403c2aabca7", "packages/app/src/pages/home-session-archive.test.ts": "e8f1126287a3e9daa455efcc378e5d72aebdd092158bf8526f7779669b72e850", "packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5", - "packages/app/src/pages/home.tsx": "06fb5c7718aacfd29b35e730e05d45076b39d6cd5d936d1270fb1bd9424c32fa", - "packages/app/src/pages/layout-new.tsx": "3ef477634cb6d7de0c32d3572c5ff699e07efff47e6e75458583456eb1ff8b27", - "packages/app/src/pages/layout.tsx": "eaf8bed869b5ec53582db732119fb903b223d749ebda354b1e388de3be02dc52", + "packages/app/src/pages/home.tsx": "3e33dca2d17a371daa2f02509eb2730ad94068cf60584c428e22d69056582c05", + "packages/app/src/pages/layout-archive.test.ts": "eed98374a2bea6c692d7d13116db9287ee61ff526b90952895bfb0f61afbfb2d", + "packages/app/src/pages/layout-new.tsx": "7ba4195f7b893a371641fcdf9f6ca228afa09b8f078bbf1f7aa912ce98e79ca0", + "packages/app/src/pages/layout.tsx": "90306f1691cf22b35627e063ce6401ea0ed2f98e12db022861f403de2d0f2d08", "packages/app/src/pages/new-session-landing.test.ts": "95e2780456a653a71e6f66b4302ae546e308a29e6b8ad9e84442938a3a226905", "packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268", "packages/app/src/pages/new-session.tsx": "c060bbb15569b6da2f67146e6af0879ccb5444345df03dbc2f9b39cb98b9db78", "packages/app/src/pages/overlay-content-hash.test.ts": "aa208098213522dcde42434408afe9c8641fad663f44a58c316922dbd0da2f5c", - "packages/app/src/pages/session.tsx": "e502d2274c351612aacbed4dbee49e8692da1fb6b3411fbd1491499737772a39", - "packages/app/src/utils/amicode-bridge.ts": "5104a04c7822f80dc0c564619e7dd2d850ba50035dbc86ea875604ecd97df05f", + "packages/app/src/pages/session.tsx": "b054b966b3ec70c706699f7e473032688efa66d966312fc67523509bc92a8d95", + "packages/app/src/utils/amicode-bridge.ts": "3ec87c24c0384d312f7eb644807d9217a53b1b4b97f7fffadab1cc1595603f3b", "packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444", "packages/app/src/utils/amicode-bug-report.ts": "d55d9cecfe17719c0d4a0ca498345c7eff6fca1de6b645e4bc3fecb40f2f4645", "packages/app/src/utils/amicode-developer.ts": "b5cecd598b7e50631d2a441d7c72d1a012a2a77bdee3629fcfe9400a36312ad6", - "packages/app/src/utils/amicode-fetch.ts": "ec62b502e6b8688cef0a2aa249715e847032265fd0e150d73ab3021a63db0e33", + "packages/app/src/utils/amicode-fetch.test.ts": "a99d0d366d444b5b54a4b42f2417f9e30617ecccea38b85dfeb419585e9e2b8d", + "packages/app/src/utils/amicode-fetch.ts": "fbd40a63233e4e89aeefbb34c67e07aa21f595c6244b131abfca3e8bc35f0788", "packages/app/src/utils/amicode-hidden-project.test.ts": "333ce7e13a2940268448db06fcfc8bf1733110b5311932820ade1dc9ab573180", "packages/app/src/utils/amicode-hidden-project.ts": "d845a80ecad2d497528a5071f55cffab72e7b451f16d106cdb594f5bbd62c0ee", "packages/app/src/utils/amicode-pane.ts": "46c18a2dc47c5db154f70272d25031e6834539ad03f6ab25613f082426f7c642", @@ -282,17 +316,19 @@ "packages/llm/src/protocols/bedrock-converse.ts": "834a415fa8ddf9ad1224133f8352d0a926694f19f0954201d182111c171ec03b", "packages/llm/test/provider/bedrock-converse.test.ts": "e8e14d60e8502e928f170f8ac5b406c44bf16d2dd34996ddc2fb7b9261decf85", "packages/opencode/src/agent/agent.ts": "512372a4c62997e9565266638ada19b9dc0a3685b0c0483fd28fede2abdb5c9b", + "packages/opencode/src/effect/runner.ts": "b660b5b22df810b16293c23c6d832e5e8dcfb12b9a8a858c875d2c051c855b81", "packages/opencode/src/mcp/browser.ts": "ecdda42cbd55a97b8d14050aa15c6e6450a1d63b0a641c8c8e5b81e50f957c87", "packages/opencode/src/mcp/index.ts": "da45165e6791a5b30c8962c7fd0ea391bb8e1931cc2357c6b90e9fd090cdeb0d", "packages/opencode/src/project/instance-context.ts": "65a35792e50e96a50ea14cd9f928045c0e2fc15eb49506f5bbceee38c4af1457", "packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab", "packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8", "packages/opencode/src/provider/error.ts": "7f3c8210c775e2ce98140bf015dc97e61192912af062c75e18ecb09ee2736a0f", - "packages/opencode/src/provider/provider.ts": "abe4bcfd92c7b1d24760d6a8ad9dd97e12fe3278e0168edb19c3b8d3da497ef2", + "packages/opencode/src/provider/provider.ts": "435a2838291d2aa32de4d1ecab1778b443f9ec9b3b5ed409b94d3b2de0142853", "packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8", - "packages/opencode/src/server/server.ts": "56b3c2047917aa80d1dfb29bfa9839bcc2ad7f5a6f4db12103cd962a98fba866", + "packages/opencode/src/server/auth.ts": "a054aff2ea06a303a99f326275332a68089c58916356a26d66af44f048f597cc", + "packages/opencode/src/server/server.ts": "9cbd3503b77b61b07d754f1064207c04a5f28175c4138fbd08bba2abf0016803", "packages/opencode/src/session/business-record.ts": "d82db061055d0dd26ad95b34bff688ce23fd06a8fb9248dd335c6726c6ff0f5e", - "packages/opencode/src/session/compaction.ts": "fd6159a8b04050706b6968c7a1b091f79d06e6e4aa988a12629a7616b8128e94", + "packages/opencode/src/session/compaction.ts": "101ce3aba83206a31e80fd7e00ed7ec7bbea8180c7b5bda5a848a319addc2013", "packages/opencode/src/session/evidence.ts": "f1b11aa0dcfb345f074e408ab95986ea9d932249e82d6f2cc6594d8d4ab65a1e", "packages/opencode/src/session/external-diff.ts": "2e1955ac23f5a50f572d61152a14cc994aee9573c59d6bd261a060513b4140b2", "packages/opencode/src/session/instruction.ts": "19640d02c96b8ec764edd49b0ce74879432fd2d2cbb4d2ad0076caa200dd4797", @@ -305,25 +341,31 @@ "packages/opencode/src/session/receipt.ts": "06fd30326424112b8dacd756e4bba5424b3a7a2d10318464a5670aeabb0c4808", "packages/opencode/src/session/reminders.ts": "abcd602de7f8ab8ab1a0364d30d28f1eb04fe81bf7cd837db9e926ffecf2dc6c", "packages/opencode/src/session/rollout.ts": "55518da37d6d4d1298eeab1c7ee208f59b80b7246f3370438de98ad774e4b02e", - "packages/opencode/src/session/session.ts": "1d94c364a55c3124ba6ab762f599cc4194905d7e23ec91ba79552671cd4d86c7", + "packages/opencode/src/session/run-state.ts": "e2479d977b3a0bff5bcad8a0f6660d91732e866872787799422d82e5a692c429", + "packages/opencode/src/session/session.ts": "d3d8b9e7603aa9e40113a361f5bdbd7a48924250274071f5c07e0c13e1e62d4d", + "packages/opencode/src/session/status.ts": "3e4aae80765f57fa5b3e50e7eefbf70a887c6054ebe4a1850640d96d4548d468", "packages/opencode/src/session/summary.ts": "09f546db362a992156bdb4901cce58e495bd38bab6ba41b4386cca77bebab7f3", - "packages/opencode/src/session/system.ts": "f607576faf505085c2e31f208f06f45eaec32f0509570fa9cf6e4c130a0a50a5", + "packages/opencode/src/session/system.ts": "b8708f3e63386e08041368e6dfa887f250bc6a7543ca396bb8b838041e8aeb19", "packages/opencode/src/session/turn-output.ts": "889624ca54a8149d35f200c7efa732771dfca36984e8a322a9781b1a25434762", "packages/opencode/src/session/user-mutation.ts": "8f8cebe1370a8a54361c546fb6777ee4fbdd47d0dec1ef870305ce5dbe232208", "packages/opencode/src/snapshot/index.ts": "4d76a78c48597bda2379e2c3396f4a53828b21c6ce03e1975d2911150822c6de", "packages/opencode/src/tool/apply_patch.ts": "37535a783d115ac2032def7a28cce56e304f4e7c75d3ecac05b9a37054ae5b4f", "packages/opencode/src/tool/edit.ts": "38aa4ce0751ff99bbb0807bcda52f8b9a1d32b6e342df4cc2aedab1f03d342b1", "packages/opencode/src/tool/question.txt": "49bbecb33a825620c2ed14ec9b61faa294359c3673c33277e32f57c1070d2806", - "packages/opencode/src/tool/registry.ts": "2b0e8546f01643e78b9dee504357bfc33f2d7075ec45f8a1462d8c3ee8e7aff5", + "packages/opencode/src/tool/registry.ts": "0097ab8a7904559d0354d165245caff6bf0e887c99ce267be77fa4ee4b66ec3c", "packages/opencode/src/tool/task.ts": "ddeb07a1cf009dcafc1d1d1c86b9d507676a8991db33a4632c8f29ae0e70e654", "packages/opencode/src/tool/truncate.ts": "92c59d83c4a660116d6ecc48e900aeb16bde93962bfae9f520193fda18273379", "packages/opencode/src/tool/widget-author.ts": "912be8636b162adca8796a501e927019c2449393c14bb5f3ff478b02083b73a9", "packages/opencode/src/tool/widget-author.txt": "a2572913debbbbd2d4a644dcdf4576accbc7efdcc8217c0fc9f5a011e7a15ec4", "packages/opencode/src/tool/write.ts": "094661a3bdd3fab2cd439c0b6cb942b6a2653d3e9451420523b33acbc762d1a7", "packages/opencode/src/worktree/index.ts": "a86f542b579ef40f2940d36e2c8011ce5592d4811cd5ed437bafe614789fb87e", + "packages/opencode/test/acp/service-session.test.ts": "5810c4a0c935f06389c5f01df791e4ae82a4c32268407baf32dc4502d487f282", + "packages/opencode/test/effect/runner-queue.test.ts": "9ce3074dc4a86f620381e2c7ada08b9034b500a62b5da1a7d63896f2519085ca", + "packages/opencode/test/effect/runner.test.ts": "6be02ad6a7d173642100a9e17b323410bad7ece842a00018d2170b91d4894c5a", "packages/opencode/test/fixture/fixture.ts": "357bdaa06c6233a91069db20cc5f1cbb2d6eafa3bc7ba3e89b019859d195d6ef", "packages/opencode/test/lib/cli-process.ts": "110c43d247930e834ffffb9068682c5ab2e1b95460ec1ac3d0a75cafbdee60ff", "packages/opencode/test/project/worktree-rename.test.ts": "3ec168c9f8595249b75832895a251483ec1f3641ce44ad0bd5a664b5bbd6dad1", + "packages/opencode/test/provider/combine-abort-signals.test.ts": "0a997303e717184e3e237e4b978b64a34ab8022724c47245b25f32e90738d205", "packages/opencode/test/provider/harmoniqs-tools.test.ts": "0c626daa51c312eb81dd854509a2e1944712ee9620a36eb4db66078ab80d6b82", "packages/opencode/test/provider/provider.test.ts": "5b2cbed8994d3bbc968a72d01459460c1de72b6117a0795c2bc6a10abbf02926", "packages/opencode/test/provider/transform.test.ts": "ecf9dd1bdec36c2126f3140d43125728c06211b9771d47619ff65f7453ff37fd", @@ -344,7 +386,9 @@ "packages/opencode/test/server/amicode-widget-manifest.test.ts": "ac404d1737bdde8ea3464cf0b5fa12f0a6f9e630a0698ca5fdec38449cb66d0f", "packages/opencode/test/server/amicode-widgets-src.test.ts": "fa95521084f95e0c421e7f7f13bf61de7e6b5cc9f42aa9fade486f5c1b53a65d", "packages/opencode/test/server/amicode-widgets.test.ts": "14fc6ccff4e83a5b005a724936e9f0d7ce411fab74109f319e8bc748665f7ab8", + "packages/opencode/test/server/auth-accept-set.test.ts": "8fe698812ed9a809d21ace1a9751bd3f1976ab1e58ad5bda65dbfff58df8e3bc", "packages/opencode/test/server/httpapi-compression.test.ts": "5f7603d7018e0b95f1c2f36c01dd297c01770bfcdd91ce29778e03b7d6143f75", + "packages/opencode/test/server/httpapi-file-write-gate.test.ts": "0c91b7417e615850edfde3f2878207dab6b6624d4cfbb6e6783b2e6d9aa4dff0", "packages/opencode/test/server/httpapi-file.test.ts": "d3386129bc81467ecbcc57a3861f53767ee56ac41b4ff6ad58d8b168fb3b3373", "packages/opencode/test/server/httpapi-instance-route-auth.test.ts": "4b73a89d2431c02bc3b665d93f9002914e51eb1add979dc1867efbcbc0c36e25", "packages/opencode/test/server/httpapi-provider.test.ts": "9e8b3e085b478e3f99b689ffff600e10c0b4b50b3cabdbd749f88dae19bf5904", @@ -357,10 +401,11 @@ "packages/opencode/test/server/workspace-routing.test.ts": "2574731ac379c540f450edc15f8750ac5360e8c33b4a0dad7c61e8eb686137bb", "packages/opencode/test/session/adversarial-e2e.test.ts": "1b471a69223d040c5d5f4f2ee99c2daa6df56bdf11679b65d902c15bedac3e80", "packages/opencode/test/session/business-record.test.ts": "7857094479408bcfd6ea8d9f5b425c6384bcf8490f930674bc1c203e82782d75", - "packages/opencode/test/session/instruction.test.ts": "8b4d954eb10afa852a671e6c0a94d19604deacc9a62f9c3651c7456cc32736b7", + "packages/opencode/test/session/instruction.test.ts": "06e1b371a08c7d07ed7688b997eb43ca9957ccd07936f601797d7f04a2bf0ab7", "packages/opencode/test/session/llm-native-recorded.test.ts": "7139b58569af0172862af521e8f8d7a78132f40f06dcceb4baf8ca3a2f07d3bd", "packages/opencode/test/session/message-v2.test.ts": "3a45b7b884a33ee48daf533ac84bf5c0b34d01845b8c1c4cc7e68de88b063ed1", "packages/opencode/test/session/mutation.test.ts": "ccb82b6858ac097d2af09a94497d05f6a0ff843b928a69e763fdb510905a10cd", + "packages/opencode/test/session/processor-effect.test.ts": "51ebe1cb9b7b0e1d249472367491a1306b5add24ca8b04d5dc15b2b2fa1f1b56", "packages/opencode/test/session/prompt.test.ts": "e3d0748edb833640ef284857ae725e4b974843480d4128f56239e3fd1e6d481f", "packages/opencode/test/session/prose-guard.test.ts": "7239a758cbf27df4c3879fa9ee0374964559c1461773c2f5a85196f64632e6c4", "packages/opencode/test/session/receipt-privacy.test.ts": "6c8777edc280a061fec1a4d16e2504aec18bdf9cd6dd2cedc7a09ef8fc6c2ca0", @@ -454,7 +499,7 @@ "packages/ui/src/amicode/devtools-header-alignment.test.ts": "8506411132e142d54a27e551f2dd3806166824a777dc002be6fb5477c77af5a3", "packages/ui/src/amicode/edit-row.test.ts": "7c68fcf083d3854bfb70768b648110e001843a290dcfdc25869c1a4681445b94", "packages/ui/src/amicode/edit-row.ts": "28d5f39470376968e7eabc3f50807a779177e9c9f2081302e4b5eb15306aef1b", - "packages/ui/src/amicode/entity-rail.tsx": "fee936650d0f05ab00e46ebb70333b2c92a57ab0da1e7fcee942d7e6b64004b7", + "packages/ui/src/amicode/entity-rail.tsx": "21f1a44653eee4bed80ac32189b8322a89be20a0146b58dc2e2d92bc207117b3", "packages/ui/src/amicode/entity-view.tsx": "e181575c8bd66a7cbdf89411d0cc1d12173a74a3bac9122163f2e27331e3becc", "packages/ui/src/amicode/facets.test.ts": "e89bd4e49276f52ac0af4990b202919331a48302b4b34c1ec1d8406fa4fccabf", "packages/ui/src/amicode/facets.ts": "074f7843a105961da3b4b89c67ea20de106968b4524f9094a5f57d1ecb0acee0", @@ -471,8 +516,8 @@ "packages/ui/src/amicode/institution-lookup.ts": "3edf90b0778ea50b048975cc317a18a1aa82290cd4f5241cf9df74fd17866fe9", "packages/ui/src/amicode/onboarding-wizard.test.ts": "268df538ea99280c5297f9b1dc06a236da2fb82830c2d49a107feca3f610279f", "packages/ui/src/amicode/onboarding-wizard.tsx": "75db623f8f50fd875f1bde8942841fbb5167c77a34fe3c6dbfa80732841b53e8", - "packages/ui/src/amicode/problem.test.ts": "e7a15928df883fbed424744953a87ad0b5ba8a0c1039dc190ed00bfefe85eeee", - "packages/ui/src/amicode/problem.ts": "d40ca9f4f411b112d97c7d1067b5834ff22f763456df17df7fa0ad31768a3a51", + "packages/ui/src/amicode/problem.test.ts": "096b447e1e16d1ae382c3916018e9df60ea6418404701731b46a311e443b543f", + "packages/ui/src/amicode/problem.ts": "76e47d343b1e4e3faf6cb31caf51aa15817a63f99bf3b1385b77126bc2c8181c", "packages/ui/src/amicode/project-type-icons.test.ts": "1db755e2818a5e97d018e7133ae329b9ce1ff63fc347a06bcac7b9f4d0784969", "packages/ui/src/amicode/rail-gate.test.ts": "7cf0ff5add76f27eb4022224a0f18532a7bc419dd5368788e7a0667f9913dcae", "packages/ui/src/amicode/rail-gate.ts": "c24f99d8285a5484863078acedc5ed36d8de67084c2baaf2bb2f7b6d87e657cb", @@ -536,7 +581,7 @@ "packages/ui/src/components/amicode-connections-tab.tsx": "d184dfcd5c645332c2cc1c79356e551cd53b67a6b61ed16e78d041de5d13fafe", "packages/ui/src/components/amicode-edit-row.tsx": "99c8c4e90ea02be21f74603c5a5ac1e2c030f487d62ce9da972c99dff505d892", "packages/ui/src/components/amicode-entity-rail.tsx": "5377561b952d9e6bd9bd6a16cec91a37770f14195bb7bd1cd294eb20a1e78a3d", - "packages/ui/src/components/amicode-entity-view.tsx": "1ff46b6abdca65992606755e0d5e516483bbd8f3f8cef54b81cb6faa08d895dc", + "packages/ui/src/components/amicode-entity-view.tsx": "1514d29716dc6488fa06dee4672b718444700f4563c9ac6de00e5941005d005b", "packages/ui/src/components/amicode-footer.tsx": "d7d7344613c7acbf2d4b0dd3f33c89f67fae6ac065bcea047e7dc26d13bca5a0", "packages/ui/src/components/amicode-getting-started.tsx": "219524d12d3b7e35a3e6ac4a112c64b8a54ce1b695bb61dc4a0bcc64a7f37e2e", "packages/ui/src/components/amicode-harmonic-dot.tsx": "3f459b3e579e5d8fc3de5562593ec4236d22878f1da59ba46b74e524559542dc", @@ -586,6 +631,7 @@ "packages/ui/src/context/dialog.tsx": "876f8a5dc26ebb98f1f5e9e528e7d0bd7aa79c0a107b62027dd9fe78ed2cc620", "packages/ui/src/context/marked-math.test.ts": "6edcbe85175366da339d8d921b0f98834e95ea9cf500e91dfae7c2c738bdf902", "packages/ui/src/context/marked-parser.tsx": "90b79a684f9c0b7afff63bb5776512779eb3912e76f85a7bb2e2d97bb575d4b9", + "packages/ui/src/hooks/create-auto-scroll.tsx": "4a8e9aa41f075b5e1815e647ab3a8a5333340d00e20ff7414f5e6c1f9a738337", "packages/ui/src/i18n/ar.ts": "8113d320fe4a5300f1bd70150ae1695d68cb783e1c6e2be43b8a429b7dbdb711", "packages/ui/src/i18n/br.ts": "cbde2da43ef820fe2664cb96cb973fd722156a6c8a3c5e16d18638bf014e5196", "packages/ui/src/i18n/bs.ts": "23ea0ae34a4edaa4599b13f63f481be861e28da3b0d898987e291c7e91a9f11e", @@ -621,20 +667,26 @@ "packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3", "packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d", "packages/app/src/components/prompt-input/slash-popover.tsx": "a1800173edebcd419802796470f7270acaf0ef689a72f1b81a1cedfd8968337d", - "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "68a94b03e75625531b2f2ad161ef9d0b93365f9edae154a797f82031b5bfc062", - "packages/app/src/components/prompt-input/submit.ts": "6f14368b0d481bdf10a2711e08da41010edd9f67ca8af90b930c096a56778b89", + "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "351f0f5a8cf7aff55c859cb82fbca388ef848831256b80f74ee52ab9cf5d07c6", + "packages/app/src/components/prompt-input/submit.ts": "cdb01cf2f2485fbbdb7ddc77dcdea99197d8c03bd302b8fe032bee8c15f525c3", "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", - "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", + "packages/app/src/components/session/index.ts": "f0d58fa3a336639566ec51a10b692649707f91a7b780c0b5c4dcf18642d030e8", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", "packages/app/src/components/session/pdf-canvas-view.tsx": "591bac93a2e1e10a1b42404a29bcc5222ebbc5faf734c80556fbca35c90551dd", "packages/app/src/components/session/pdf-restore.test.ts": "4cd4cabbdc8063deb5fe95b5e637cf154ccdf864c5ff9f2f6833826bd69e4f90", "packages/app/src/components/session/pdf-restore.ts": "e5c92096b9302a452439babfc2732610a7e0d18e23793ce3bf6352fa32f91a36", "packages/app/src/components/session/preview-file-helpers.test.ts": "e0c1ccb40b48990c0c5b87ea62a35de60b860cd760067c7f1327cbb1d36584b1", "packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e", - "packages/app/src/components/session/preview-file-view.tsx": "7b221de755b851170e4b3dfc4d78d8446f6dfa40116b7529466db24e714d90f5", - "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7", - "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", - "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6", + "packages/app/src/components/session/preview-file-view.tsx": "8292f2430d41c3b6a587fcbc6547ab6828445542a8ddc6c4554d115c1340f3f3", + "packages/app/src/components/session/preview-human-write-gate.ts": "4c4b1cf258a06b0012d722add0125ecb77a0f4e463dd95d193f21e5e73541098", + "packages/app/src/components/session/session-archived-fleet.test.ts": "f5d0e16dea2ab6e6cb44a6768b5ef1e772f8ccc39aeeb02571525ec44336ab57", + "packages/app/src/components/session/session-chats-dropdown.test.ts": "72e2e797fe96a59ee46ae59c4a5cb34eae8d338a468977bd4e8f61cefd6abd49", + "packages/app/src/components/session/session-context-tab.tsx": "4062ef39e161f041bf1d7a300173c510cecd581ad8e1fbb4503961b7cd1a29f5", + "packages/app/src/components/session/session-fleet-control-projection.ts": "c1a91ce975dd0399251e491091af81abf2d24c0bbba074a334b58c56868bcad1", + "packages/app/src/components/session/session-fleet-peers.test.ts": "0b9e128d62f09907691d676b560edd610b6d92fa919d38705addd888d6fce54a", + "packages/app/src/components/session/session-fleet-peers.ts": "474fd37998061f1c2df1be81c88e8362654f5aee6bcf79fd4b6669caf2ce50bd", + "packages/app/src/components/session/session-header.test.tsx": "733f48a613d3d1fb57ae87a38a0f857d1f37261ee4b1db2bcf0dd8457ec6c196", + "packages/app/src/components/session/session-header.tsx": "8d5e943e3b2b338e519530842e5c06cdb30ca5d393d9652cc362e0105f0f7e64", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", "packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe", "packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653", @@ -659,17 +711,17 @@ "packages/app/src/components/settings-v2/permission-scope-relocation.test.ts": "39539c4fc4b7433f08b913100f48a7fa75d6ab47615ba697aa9ae8eee09d316b", "packages/app/src/components/settings-v2/permissions.tsx": "5746c8ef02da833ff72fee1375dab2a7ee241bb111e8ef15b21ae70c49349543", "packages/app/src/components/settings-v2/providers.tsx": "e15f23a40058abb4299ad7444df2845e3c24b5da3ca58a731e0cbafe58de2a76", - "packages/app/src/components/settings-v2/settings-v2.css": "38818ab5349cf637023379d2b1ee62081d456676020bdb445576865327858162", + "packages/app/src/components/settings-v2/settings-v2.css": "129ebe678b18d5ea897e077ef16e7b71d003468f29c49b0f47e2b54d310c8f5b", "packages/app/src/components/settings-v2/skills-controller.ts": "2c7344d144da0eec1c25083ead45f5afb2a8c6fd759541ad719cae9a09fcd19b", "packages/app/src/components/settings-v2/skills.tsx": "42deb0c11fcba158abad857b9644ffb551aed5ba9e00dbfd1584a7ffaaba5aa0", "packages/app/src/components/ui/drawer.tsx": "76574ca4c6997209f37a766a61000d707332a9a857f6ea8100a3cee1be58d65c", "packages/app/src/context/global-sync/bootstrap.test.ts": "ed66b4c0427d51c015fd286dd63c2cc24a25391320366d75e28634334d70e366", - "packages/app/src/context/global-sync/child-store.ts": "3fde298a6cda8e96efc22bccee4ef124e139abd6ec28510363c78f702bf70db9", - "packages/app/src/context/global-sync/event-reducer.test.ts": "0528ed45952107b3ca174e72fd0b920191dbbd49eb795c4dff63cf89c2c91c0e", - "packages/app/src/context/global-sync/event-reducer.ts": "9c26e9e83709e56c57b228a03186df6bf1a52a073c4b7e721853956ee0da6142", + "packages/app/src/context/global-sync/child-store.ts": "ec6464eaba08a8cf41f3169777faeead75b55a80ce117e830becc9535f6e1b2c", + "packages/app/src/context/global-sync/event-reducer.test.ts": "9db2c436adb78f54662126defbf06a2c0a911c5a3b1210c0dba95adb35297533", + "packages/app/src/context/global-sync/event-reducer.ts": "4bb0dac9c8962aed0ceaf08ba8aa70fa2ca64e928f3db8eb50872ecdbcf328b8", "packages/app/src/context/global-sync/session-cache.test.ts": "e3c66ef76ff20a416f3b2d72041acd66c2a65da57ded92cbcb381ab71474285f", - "packages/app/src/context/global-sync/session-cache.ts": "70f60c37ab9ffcb5ac0778f4962d88f0e7c6fb61fc39a18bda28942322605316", - "packages/app/src/context/global-sync/types.ts": "76af58cc126d49bbcb6ffc7ea4d6bce72dc9a714f9ee05de576d823f92b27963", + "packages/app/src/context/global-sync/session-cache.ts": "78861c053e9ba7574cc0766681773087b2e5b93eabf28dd43dbdc88535389fe0", + "packages/app/src/context/global-sync/types.ts": "da1408b5665534588a0f437972730b810b0d1271a98bb8d0c442f3d4625bbf7f", "packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8", "packages/app/src/pages/home/home-sessions-view.tsx": "5b73b18e4349a628a15e09a3810ecf2319bde222e18fe8de5d08373ac91fb37c", "packages/app/src/pages/layout/helpers.test.ts": "3a77cfe018ce2867cccded2c726f556e53a9895a77baa944a4878714b9b2b59e", @@ -685,16 +737,26 @@ "packages/app/src/pages/layout/worktree-auto-rename.test.ts": "fb6fac363b8bee1225bbcff8098019c02f19b095b5cd96c29f7e673acb1e4e89", "packages/app/src/pages/layout/worktree-auto-rename.ts": "2ef3e506b96b972cb55799a89aa54ca6453fc31c80bc022aa16db6ecfe9cdced", "packages/app/src/pages/new-session/new-session-draft-controller.ts": "3607771a22b6855afd0b0d666de662c9070a2dcd56ae96ba51d89c9e11018e81", - "packages/app/src/pages/new-session/new-session-view.test.ts": "615110d1a956bb2321ca77c998d6f5da434756d166ee61cf71ca232cc9efb0c4", - "packages/app/src/pages/new-session/new-session-view.tsx": "30b0b048f88ce741215e642b0760758638ad36696d2eb8770bc8e6b8b8f3fc29", + "packages/app/src/pages/new-session/new-session-machine-mount.test.ts": "debb3b8b058a88a64e4c2cc56751502b241be53ce5a74b9c81ee51a617675c6f", + "packages/app/src/pages/new-session/new-session-machine-mount.ts": "2eb56ed87f78c15a34dae63a95fa1efddbb7395a3d6893d2b148c2f594910b24", + "packages/app/src/pages/new-session/new-session-machine-picker-mount.tsx": "e480e65c849af1434c713452eb55c9b39aeb660292773468e08108761e87f45d", + "packages/app/src/pages/new-session/new-session-machine-picker.test.ts": "2f2adf9d738aee98f2989c5453c97eeba0e48522b7863c99d9c0be9a6c11ff3b", + "packages/app/src/pages/new-session/new-session-machine-selection.ts": "618df365b74547d997920fa3d14e1f73f3b5d618e54f2b1297b8374535900f7a", + "packages/app/src/pages/new-session/new-session-view.test.ts": "eb337485bf03facbc59d9eadac37cfeb04f75bf97814df80d3be4d0ec9455047", + "packages/app/src/pages/new-session/new-session-view.tsx": "8fa065e3d792f99de6fdb6f0de8c6205fab519159342655fe59fc09ea67691b1", "packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "4991e775d3a6c63a514072ccfa5aad190c586383b77ef41689c853fb271d3bf4", - "packages/app/src/pages/session/helpers.test.ts": "09fd272c286c7746db5ce635e1c453c8943e46111d030b4892af9145a89401db", - "packages/app/src/pages/session/helpers.ts": "0311f52b7cc34c2c38950512c01397e9672c89a9e61fdfa0e4696192da114f34", + "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "f3bb956e343ccd82acd8bf38c9ad2413d48b24ec9f484c5dfd3939d9e07139b7", + "packages/app/src/pages/session/fleet-manager-tab.tsx": "1e7db225a1f2fa3234e0eb47f314de49185db1f3ebddb5585fd0247184adebaa", + "packages/app/src/pages/session/fleet-manager.test.ts": "b10aa624fba721c66e295a08f5bb862363701e09fe870295028c8d4b39d3b850", + "packages/app/src/pages/session/fleet-manager.ts": "2177198394e525abd793da064dde0a1ba119e87084d2d48cd6f4766451e27f03", + "packages/app/src/pages/session/helpers.test.ts": "b6a8bcbb78fe237d3dc347520b596342c9561c45056d1cfa4dd2e15ec34425ed", + "packages/app/src/pages/session/helpers.ts": "1b777919c8f3c864b52469e90564f873445f9e7877c87f7290db3578823091cd", + "packages/app/src/pages/session/preview-human-write-gate.test.ts": "b657eadc3f2fed1ff7332b17afc6dbe5fb23709a27fd1d36249e5a25788f6bc4", "packages/app/src/pages/session/review-tab.tsx": "3c90e6b11fad40ea19a258966c374ee12c05bd0e7891fcea561ba0b965ddc9f5", "packages/app/src/pages/session/session-panel-width.test.ts": "9482afba7fbce254cbbd21ef885e9e9620b981616681635376f466478f611155", "packages/app/src/pages/session/session-panel-width.ts": "02ee3a02ed78db2eac47c2528055cf78aef95f389641e929ddc56c17471dcbe2", "packages/app/src/pages/session/session-side-panel-structure.test.ts": "b2d1d007d9200d0ac4a0889e45c2dbe57a324a10cb26ae9ab8f420d3c890402b", - "packages/app/src/pages/session/session-side-panel.tsx": "b295340d9a39ba37c9e025d6360669d2a879284c4e60df795b7902c5a4cef3fc", + "packages/app/src/pages/session/session-side-panel.tsx": "a27806be45c93c2cd8ae24639829cca936c2e444fdab1ff224270847effdc47c", "packages/app/src/pages/session/terminal-panel-v2.tsx": "68dad9307f1d2abf3ff9248e451bd08005acf01ddc46b30a6d01b58f4a90dce0", "packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111", "packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67", @@ -723,6 +785,7 @@ "packages/opencode/src/server/amicode/profile.ts": "521338a7990deb8d0f352ed96d0f0a96fd56daeeec407c3ae133b02dfb2ed5c1", "packages/opencode/src/server/amicode/project.ts": "7f0a8a15aea0ef367a00ab31ba4329ecf2344282965afce553005b5355be63c3", "packages/opencode/src/server/amicode/run-terminal.ts": "c6857036c1dca90e8b462e916f49df707757f227d0ed74f072b8e94d08d74cd0", + "packages/opencode/src/server/amicode/self-shutdown.ts": "ed0961edf483adeb94096704159eaf02b677fae8f851a05518bc78c85f442085", "packages/opencode/src/server/amicode/toml-lite.ts": "0001fb071c53c1a3ab330e40550ccfba497b78e2a0cc45084e1c016d8f215831", "packages/opencode/src/server/amicode/vault-browser.ts": "8a8252fb0b46583c75e9475d7c89e68c092f5255b21476f3c3d0b9d379ea2d48", "packages/opencode/src/server/amicode/vaults.ts": "e48c90c64de27abe24a510f188def6b56297d9bb1fa1ae08cbcc94888d42044a", @@ -734,13 +797,14 @@ "packages/opencode/src/server/shared/public-ui.ts": "a843b4e84673d9a5e34f36eb7cffbb499850f7588154c23061fc8a81cd1369ef", "packages/opencode/src/server/shared/ui.ts": "eba93fdd47c786d723d2593e788f6b7278b072548acb916d19eeb2e9616a4197", "packages/opencode/src/server/shared/workspace-routing.ts": "3704b6cf218fc165ce9c1cdad0ee42fc9cf604976e4a552478200f85db80876a", - "packages/opencode/src/session/llm/request.ts": "bcee69e2f73778020e68917e099207ab636bfd5b045d85632ab9ecf6c164cfc4", + "packages/opencode/src/session/llm/request.ts": "d6fdcac11a92ef9fa18efce69791900f30c66e54e94a1647ea70136e8825e7e6", "packages/opencode/src/session/prompt/anthropic.txt": "e3e1a55159f0104ceec69098620eb281cd8927cfa808ab5feaa753719af27f05", "packages/opencode/src/session/prompt/communicating.txt": "e14e1daf86eb4fa2fc1222cc941145913d540827bde3d27ebfe0fe30181f3e97", "packages/opencode/src/session/prompt/default.txt": "bfb0d6ab93fb82a8edad591101bd84839565a0fb401770ceb0045b1fd3b081bc", "packages/opencode/src/session/prompt/gemini.txt": "d24815ea833b058b304d95d3212ad2e091bc07064a22a6af9401872cdc3871a9", "packages/opencode/test/cli/run/question.shared.test.ts": "fbb3bdb20fae7d77f0e54444847c658c721b132feca9ad939a963721d2ddd772", "packages/opencode/test/cli/run/run-process.test.ts": "13e6386bf1919688cff2f6f9f68bcddcfbbec822ef8fc271ee68347a542827b8", + "packages/opencode/test/cli/run/stream.transport.test.ts": "3c11172baa0dd4860e1b754c22b8edc3a7ea0b1c5860cc35a88f94ca3d463961", "packages/opencode/test/server/httpapi-exercise/index.ts": "59048fa30e60348240de62cca862a5c6abbe2e9e0f6340b77b0a9bbd997bec27", "packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap": "e1b0c536581dd36599aebcb5562ffe49923b09629cc8fe5d64ba0a2d7fe43630", "packages/session-ui/src/v2/components/attachment-card-v2.css": "3b4b9c23d69e76b35488bc3a65fb51211038c3e8aac015df3f531b4e97e96359", @@ -788,7 +852,8 @@ "packages/ui/src/v2/components/menu-v2.stories.tsx": "4ed1498d7e4cd51ce9c7459061f3540efdfae71751ff618901c78ee413951559", "packages/ui/src/v2/components/radio-v2.css": "bf6eb3457a7fc13e48356f5a7dcf3cbf462382580250a3144a745402ff88e2e6", "packages/ui/src/v2/components/select-v2.css": "4dd4104bee8f661c30ad08ec806e5bddec21d75be6a5d09b693dd297ea10a24c", - "packages/ui/src/v2/components/switch-v2.css": "96406f51a412f931c99c005ecd6a889898ad80160143cca0a81cacfa13714f8f", + "packages/ui/src/v2/components/switch-v2.css": "863b1f4d29e4c25440f695f6cca95438b671e0445a717ae826115a33dd6d3635", + "packages/ui/src/v2/components/switch-v2.tsx": "52167c94e7b931ca37a29a5ace612f7d8bf5ae1aa82ebb6cb07c931de05eb62f", "packages/ui/src/v2/components/toast-v2.css": "9b652d908f39e35cdf1764f1675e4db3dea1d7abf43cb792cfa2d27b699f2723", "packages/ui/src/v2/components/tooltip-v2.css": "7cbd2523d9f183b7e24bfbc1f523c370abf8e32836f8be90cfdf75e1c70f05a9", "packages/ui/src/v2/components/wordmark-v2.tsx": "6037a68db862ddd67235cbae8dae5a95c9f7403e7c5f268b78fdaaf013950166", @@ -801,7 +866,7 @@ "packages/app/src/pages/session/composer/session-bug-dock.stories.tsx": "a5730bb3699751d63629134924ce8dea021b264722c40f5f6bb98236dc15eda7", "packages/app/src/pages/session/composer/session-bug-dock.tsx": "e7fed3c5ab80527d69a106440833f795bbbafc5deeba3883902ede9fba612605", "packages/app/src/pages/session/composer/session-composer-controls.ts": "7ba9d56c81b1547acbdd47c4f9371ddc37c9a92059f5785f7286fc68e0cb4483", - "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "979d2e28d9734581f88e7cb3e0a51c8a8c204f81e644f7a3b5766f60ddf88938", + "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "d0403567856eb07ee93957e6d8a23c0f5c78620d94fa09407c01fc6ad3d9eadc", "packages/app/src/pages/session/composer/session-composer-region.tsx": "415cd5c3a77b625a3142b828b6458687e1a35b382d3a208d90ff1555cc7098b1", "packages/app/src/pages/session/composer/session-composer-state.ts": "fcffa11d801309cf58f42a174143bf6eb3ea2b14f860ad91a8ffa4e097df5c16", "packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts": "a1fe31fe4dfdf2914bc184030a0427bcbdfae0f3e7b146e1d6ee29ce31d85b64", @@ -813,16 +878,19 @@ "packages/app/src/pages/session/composer/session-tour.tsx": "da5d807c7efe7aed1140d65d7eddd7982a8c17348d928f5d0a887d1b2a8d3a50", "packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx": "70a8267c725af320d78d266b85fd0ff005d067c4d09a21bef824126602e831e7", "packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "83753b3ec7227570523c2e0b9e82401fae7a20f3a23980bb40deaeb13304327b", - "packages/app/src/pages/session/timeline/message-timeline.tsx": "96aa7f175b2b89bad522ac57c0d6e2eb6bc7bedca453f721ddc27fe2c0e7f65d", + "packages/app/src/pages/session/timeline/message-timeline-archive.test.ts": "5699d233b5b1a8ba165354113ff40cc9d55b37a80787d163c15165655d591555", + "packages/app/src/pages/session/timeline/message-timeline.tsx": "a32ac3a844bd5fb2d7816880df292635399c15e7eea45827ba14e8189eba72a3", "packages/app/src/pages/session/timeline/model-pure.ts": "50be4ba12d747dd8df9932044ceee29c5f3b6fa6d6ac36791207f91a91067cd4", "packages/app/src/pages/session/timeline/model.test.ts": "459e0694409ffcf2530252a65491e1006f982b8f5c78a15573ed41646b2c85cf", - "packages/app/src/pages/session/timeline/model.ts": "1692edf34c1e829900fb3fadcd3f092598164f353e29fe28a9dfb40debcc36bc", + "packages/app/src/pages/session/timeline/model.ts": "cec6ba88bbc628e97b7a14bdb5fe8e5007bcc8ef4d38e0ee1df8b109b48afabe", "packages/app/src/pages/session/timeline/projection.test.ts": "68655f29c382640fc281780166aa528ea26d28d953f3376e1e062be7a0e13749", - "packages/app/src/pages/session/timeline/projection.ts": "36ab006523de7b695b33d26a0a03b59db9e148b7e979c36f63970cbcb8bcaca2", + "packages/app/src/pages/session/timeline/projection.ts": "e76549e34baedee02c8d2fd9357245f80aa4e79dcf2916869f48f0ea1efed980", "packages/app/src/pages/session/timeline/rows-current.test.ts": "b6a2d9671cfba970990b36d9ef0d1f107efcee59f0a9863f1280526116299095", - "packages/app/src/pages/session/timeline/rows.ts": "68581190ff5e51d2dc398298c705fbb5dfdefb7eb4cecf98f1da6ae0bd171f18", + "packages/app/src/pages/session/timeline/rows.ts": "5063098af62102fb85d5e3ce05626242e82ce12b3efc266b01f2d5199630f873", + "packages/app/src/pages/session/timeline/session-header-provenance.test.ts": "4bdf201c16d00eb60957d0ad9b40dafd69410a4b46deb0634e16da26103be1da", + "packages/app/src/pages/session/timeline/session-header-provenance.ts": "7b73833aa5a3530c704edef2ca38cb6730aaaa729c23e06b6227788fb5c7e324", "packages/app/src/pages/session/timeline/smooth-scroll.test.ts": "79d3bba42ad83c3ab80df907e0acf08665a38c4809468e6c12ac9fcf5e94682f", - "packages/app/src/pages/session/timeline/smooth-scroll.ts": "a7c16860aa68917b74f5c5c46d239e9a5d7a9a952233407e2ed856b534059828", + "packages/app/src/pages/session/timeline/smooth-scroll.ts": "3f2782f52e36acf86398fb444bc53fcfec2fec9a0684613466ff7d24b8bceb26", "packages/app/src/pages/session/timeline/thought-rail-pure.ts": "886c10b0de61b0ab64baf8a284758dab8154f279fd9cd509b7b1d8ecc155ad02", "packages/app/src/pages/session/timeline/thought-rail.test.ts": "21960365e57d61de047440c386a18a06607bca74554e89881ef53cb7c00c4519", "packages/app/src/pages/session/timeline/thought-rail.tsx": "d42be90ccb75c2efef3a3a2718763165a10336d08f8d48f8b33ba7dbb696128f", @@ -838,19 +906,19 @@ "packages/app/src/pages/session/v2/lineage-ledger-view.ts": "6a465a513bf901413c743c83839d0bc8a12bdee780dbdecef86b68460451bc90", "packages/app/src/pages/session/v2/review-panel-v2-state.test.ts": "ac0cf1939c164948d9a0c54cc15076b30413612d872237e0fbd0912804c1ced0", "packages/app/src/pages/session/v2/review-panel-v2-state.ts": "e27c3aa236d2862e3cbd87128c4e03e6d810974faaaadb12a7e04cdd3b531e1c", - "packages/app/src/pages/session/v2/review-panel-v2.tsx": "a3cd899538a738d0236ecbe9e292928ff14e48d3646f2b045dc25880bc7f3ee3", + "packages/app/src/pages/session/v2/review-panel-v2.tsx": "d3dedc2d9020d955223142ef47d40da3c508eb79b59aecf2ba27052e51bc0461", "packages/opencode/src/cli/cmd/run/footer.question.tsx": "c9a1b335c9c7d8446d15c58c8808ea857f8f238c8a4047121f49599296922f39", "packages/opencode/src/cli/cmd/run/question.shared.ts": "f4b8ab69c18e66d3efea505f3d2c8c5476bba78d46b0f30bc52da4386b19e478", "packages/opencode/src/server/amicode/widgets-src/jump-back-in.ts": "a82bf11004b71c7ac6e0357c45a0d7532aad4ee6af16e048ae86ef9bf3799fef", "packages/opencode/test/server/fixtures/credentials/cloud.json": "431bdf114031da38975538384e21ab0ec7206a0859859f0bf87345b8c360829e", "packages/opencode/test/server/fixtures/credentials/pasqal.json": "d3c775cc498fe3029d9ac234d9eaa9eaffa1ad8656e12a5aca1355c733276736", "packages/sdk/js/src/v2/gen/sdk.gen.ts": "6610638b4fb71bac498da98d232ef9adcd4b011b63e5e81a63fc6dc20d311b17", - "packages/sdk/js/src/v2/gen/types.gen.ts": "0c23b262085d1e14b89bd03292a380c9ec8b52e66c4308304a05173362b79339", + "packages/sdk/js/src/v2/gen/types.gen.ts": "20dfc17423861bd51c197fe6a2fcb6efbd7ad11853b28e0bb79844115256e729", "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea", "packages/session-ui/src/v2/components/prompt-input/index.tsx": "194b1c6d0c99000579df78b47c7ff72fe34f62a92ecd21b2a9377133d6d27bb9", - "packages/opencode/src/server/routes/instance/httpapi/server.ts": "85c200797fed41c0cd08e7aa699ce13aeb8a3d23aa2b9eddc4d72e72b8b5082c", + "packages/opencode/src/server/routes/instance/httpapi/server.ts": "a94becd6a5224fb5fc2778c1091733eca334b308253234948bcdcafdc709b021", "packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "944a0aee215a917e867fd9482f3c2bd40f49082bd3174c8a7d967ca40e34ec15", - "packages/opencode/src/server/routes/instance/httpapi/groups/file.ts": "ed165131c62eeb29375db39edf89db8f87488e24ef99fc6f2cf19705ba35751a", + "packages/opencode/src/server/routes/instance/httpapi/groups/file.ts": "a96fdc55ddb95ca7c481c86f67e37ed88d4cf2a190a482765ab55ce4409f3a59", "packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts": "048efa7b74d43c4ad4a75d0f8421c808760c8c0dcd593f0051a87044e40487d6", "packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts": "34c6f1ab1a085d1b047c220bbff32a36032dc5e924784e9272cda166cf0ec84b", "packages/opencode/src/server/routes/instance/httpapi/groups/session.ts": "1421f8c07948b121c7e2005bd9b0fcd6b2c456565ddd1da32d8e6c35d03fac9c", @@ -858,7 +926,8 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "10bcb79b117beebee04184fb48c1ad789e782e2f7ae4b0c68603edc00f03aee9", "packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "7da08d58765cdf2b6ce4648fc70de371402949f5bb7b75696f674d43f2d5365b", "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "8baf16cad0992c5351b61b0c0fb27903bb25b17e1b25fc7c5d77f807c24413a6", - "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f" + "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f", + "packages/opencode/src/server/routes/instance/httpapi/middleware/file-write-workspace.ts": "665c33633173ee89c6da1d4a0eaacaf4eebe3ccdd04eb36b063515cb4c093e4a" }, "symlinks": [ "packages/app/public/amico.svg" @@ -873,11 +942,11 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T04:46:38.771Z", + "extracted_at": "2026-10-12T14:32:31.142Z", "per_package": { "packages/app": { - "A": 186, - "M": 157, + "A": 237, + "M": 158, "D": 0 }, "packages/core": { @@ -891,13 +960,13 @@ "D": 0 }, "packages/opencode": { - "A": 69, - "M": 81, + "A": 75, + "M": 89, "D": 0 }, "packages/schema": { "A": 6, - "M": 7, + "M": 8, "D": 0 }, "packages/sdk": { @@ -917,12 +986,12 @@ }, "packages/ui": { "A": 152, - "M": 62, + "M": 64, "D": 0 } }, "counts": { - "overlay_total": 851, + "overlay_total": 920, "deletions": 7, "server_coupled": 0 }, @@ -979,6 +1048,7 @@ "packages/schema/src/provider-permission.ts": "A", "packages/schema/src/question.ts": "M", "packages/schema/src/session-receipt.ts": "A", + "packages/schema/src/session-status-event.ts": "M", "packages/schema/src/worktree-event.ts": "M", "packages/schema/test/provenance-matrix.test.ts": "A", "packages/schema/test/question.test.ts": "A", @@ -1029,14 +1099,24 @@ "packages/app/src/components/dialog-release-notes.tsx": "M", "packages/app/src/components/dialog-select-directory-v2.css": "M", "packages/app/src/components/dialog-select-file.tsx": "M", + "packages/app/src/components/engine-state-signal.ts": "A", + "packages/app/src/components/engine-toggle-utils.test.ts": "A", + "packages/app/src/components/engine-toggle-utils.ts": "A", "packages/app/src/components/external-link.tsx": "M", "packages/app/src/components/help-button.tsx": "M", + "packages/app/src/components/new-session-machine-picker.ts": "A", "packages/app/src/components/pane-bridge.tsx": "A", "packages/app/src/components/profile-popover.tsx": "A", "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "A", "packages/app/src/components/prompt-input-v2.tsx": "M", "packages/app/src/components/prompt-input.tsx": "M", "packages/app/src/components/prompt-project-selector.tsx": "M", + "packages/app/src/components/remote-create-arm.test.ts": "A", + "packages/app/src/components/remote-create-arm.ts": "A", + "packages/app/src/components/remote-create-header.test.ts": "A", + "packages/app/src/components/remote-create-header.ts": "A", + "packages/app/src/components/remote-create-preflight.test.ts": "A", + "packages/app/src/components/remote-create-preflight.ts": "A", "packages/app/src/components/report-bug-button.css": "A", "packages/app/src/components/report-bug-button.stories.tsx": "A", "packages/app/src/components/report-bug-button.tsx": "A", @@ -1056,6 +1136,8 @@ "packages/app/src/components/titlebar-channel.ts": "A", "packages/app/src/components/titlebar-layout.test.ts": "A", "packages/app/src/components/titlebar-layout.ts": "A", + "packages/app/src/components/titlebar-portal-dedup.test.ts": "A", + "packages/app/src/components/titlebar-portal-registry.ts": "A", "packages/app/src/components/titlebar-tab-nav.css": "M", "packages/app/src/components/titlebar-tab-nav.tsx": "M", "packages/app/src/components/titlebar-tab-popover.css": "M", @@ -1071,6 +1153,12 @@ "packages/app/src/context/command.tsx": "M", "packages/app/src/context/directory-sync.test.ts": "A", "packages/app/src/context/directory-sync.ts": "M", + "packages/app/src/context/fleet-reseed-scheduler.test.ts": "A", + "packages/app/src/context/fleet-reseed-scheduler.ts": "A", + "packages/app/src/context/fleet-session-status.e2e-fixture.ts": "A", + "packages/app/src/context/fleet-session-status.e2e.test.ts": "A", + "packages/app/src/context/fleet-session-status.test.ts": "A", + "packages/app/src/context/fleet-session-status.ts": "A", "packages/app/src/context/language.tsx": "M", "packages/app/src/context/layout-side-panel-tabs.test.ts": "A", "packages/app/src/context/layout-side-panel-tabs.ts": "A", @@ -1084,18 +1172,30 @@ "packages/app/src/context/platform.tsx": "M", "packages/app/src/context/server-sdk.test.ts": "M", "packages/app/src/context/server-sdk.tsx": "M", + "packages/app/src/context/server-session-child-active.test.ts": "A", + "packages/app/src/context/server-session-orphan-part.test.ts": "A", "packages/app/src/context/server-session.test.ts": "M", "packages/app/src/context/server-session.ts": "M", "packages/app/src/context/server-sync.tsx": "M", "packages/app/src/context/server.tsx": "M", "packages/app/src/context/session-mirror.test.ts": "A", "packages/app/src/context/session-mirror.ts": "A", + "packages/app/src/context/session-status-reconcile.test.ts": "A", + "packages/app/src/context/session-status-reconcile.ts": "A", "packages/app/src/context/session-warm.test.ts": "A", "packages/app/src/context/session-warm.ts": "A", + "packages/app/src/context/session-working.test.ts": "A", + "packages/app/src/context/session-working.ts": "A", "packages/app/src/context/settings.tsx": "M", "packages/app/src/context/split.tsx": "A", + "packages/app/src/context/sse-gap-frame.test.ts": "A", + "packages/app/src/context/sse-gap-frame.ts": "A", + "packages/app/src/context/sse-origin.test.ts": "A", + "packages/app/src/context/sse-origin.ts": "A", "packages/app/src/context/stream-gap.test.ts": "A", "packages/app/src/context/stream-gap.ts": "A", + "packages/app/src/context/stream-liveness.test.ts": "A", + "packages/app/src/context/stream-liveness.ts": "A", "packages/app/src/context/tab-migration.ts": "M", "packages/app/src/context/tabs.tsx": "M", "packages/app/src/context/vault-panel.ts": "A", @@ -1124,11 +1224,13 @@ "packages/app/src/pages/empty-workspace-landing.tsx": "A", "packages/app/src/pages/error.tsx": "M", "packages/app/src/pages/hold-debug-badge-gate.test.ts": "A", + "packages/app/src/pages/home-archive.test.ts": "A", "packages/app/src/pages/home-projects.test.ts": "A", "packages/app/src/pages/home-projects.ts": "A", "packages/app/src/pages/home-session-archive.test.ts": "M", "packages/app/src/pages/home-session-archive.ts": "M", "packages/app/src/pages/home.tsx": "M", + "packages/app/src/pages/layout-archive.test.ts": "A", "packages/app/src/pages/layout-new.tsx": "M", "packages/app/src/pages/layout.tsx": "M", "packages/app/src/pages/new-session-landing.test.ts": "A", @@ -1140,6 +1242,7 @@ "packages/app/src/utils/amicode-bug-report.test.ts": "A", "packages/app/src/utils/amicode-bug-report.ts": "A", "packages/app/src/utils/amicode-developer.ts": "A", + "packages/app/src/utils/amicode-fetch.test.ts": "A", "packages/app/src/utils/amicode-fetch.ts": "A", "packages/app/src/utils/amicode-hidden-project.test.ts": "A", "packages/app/src/utils/amicode-hidden-project.ts": "A", @@ -1201,6 +1304,7 @@ "packages/llm/src/protocols/bedrock-converse.ts": "M", "packages/llm/test/provider/bedrock-converse.test.ts": "M", "packages/opencode/src/agent/agent.ts": "M", + "packages/opencode/src/effect/runner.ts": "M", "packages/opencode/src/mcp/browser.ts": "M", "packages/opencode/src/mcp/index.ts": "M", "packages/opencode/src/project/instance-context.ts": "M", @@ -1209,6 +1313,7 @@ "packages/opencode/src/provider/error.ts": "M", "packages/opencode/src/provider/provider.ts": "M", "packages/opencode/src/provider/transform.ts": "M", + "packages/opencode/src/server/auth.ts": "M", "packages/opencode/src/server/server.ts": "M", "packages/opencode/src/session/business-record.ts": "A", "packages/opencode/src/session/compaction.ts": "M", @@ -1224,7 +1329,9 @@ "packages/opencode/src/session/receipt.ts": "A", "packages/opencode/src/session/reminders.ts": "M", "packages/opencode/src/session/rollout.ts": "A", + "packages/opencode/src/session/run-state.ts": "M", "packages/opencode/src/session/session.ts": "M", + "packages/opencode/src/session/status.ts": "M", "packages/opencode/src/session/summary.ts": "M", "packages/opencode/src/session/system.ts": "M", "packages/opencode/src/session/turn-output.ts": "A", @@ -1240,9 +1347,13 @@ "packages/opencode/src/tool/widget-author.txt": "A", "packages/opencode/src/tool/write.ts": "M", "packages/opencode/src/worktree/index.ts": "M", + "packages/opencode/test/acp/service-session.test.ts": "M", + "packages/opencode/test/effect/runner-queue.test.ts": "A", + "packages/opencode/test/effect/runner.test.ts": "M", "packages/opencode/test/fixture/fixture.ts": "M", "packages/opencode/test/lib/cli-process.ts": "M", "packages/opencode/test/project/worktree-rename.test.ts": "A", + "packages/opencode/test/provider/combine-abort-signals.test.ts": "A", "packages/opencode/test/provider/harmoniqs-tools.test.ts": "A", "packages/opencode/test/provider/provider.test.ts": "M", "packages/opencode/test/provider/transform.test.ts": "M", @@ -1263,7 +1374,9 @@ "packages/opencode/test/server/amicode-widget-manifest.test.ts": "A", "packages/opencode/test/server/amicode-widgets-src.test.ts": "A", "packages/opencode/test/server/amicode-widgets.test.ts": "A", + "packages/opencode/test/server/auth-accept-set.test.ts": "A", "packages/opencode/test/server/httpapi-compression.test.ts": "M", + "packages/opencode/test/server/httpapi-file-write-gate.test.ts": "A", "packages/opencode/test/server/httpapi-file.test.ts": "M", "packages/opencode/test/server/httpapi-instance-route-auth.test.ts": "M", "packages/opencode/test/server/httpapi-provider.test.ts": "M", @@ -1280,6 +1393,7 @@ "packages/opencode/test/session/llm-native-recorded.test.ts": "M", "packages/opencode/test/session/message-v2.test.ts": "M", "packages/opencode/test/session/mutation.test.ts": "A", + "packages/opencode/test/session/processor-effect.test.ts": "M", "packages/opencode/test/session/prompt.test.ts": "M", "packages/opencode/test/session/prose-guard.test.ts": "A", "packages/opencode/test/session/receipt-privacy.test.ts": "A", @@ -1505,6 +1619,7 @@ "packages/ui/src/context/dialog.tsx": "M", "packages/ui/src/context/marked-math.test.ts": "A", "packages/ui/src/context/marked-parser.tsx": "M", + "packages/ui/src/hooks/create-auto-scroll.tsx": "M", "packages/ui/src/i18n/ar.ts": "M", "packages/ui/src/i18n/br.ts": "M", "packages/ui/src/i18n/bs.ts": "M", @@ -1551,8 +1666,14 @@ "packages/app/src/components/session/preview-file-helpers.test.ts": "A", "packages/app/src/components/session/preview-file-helpers.ts": "A", "packages/app/src/components/session/preview-file-view.tsx": "A", + "packages/app/src/components/session/preview-human-write-gate.ts": "A", + "packages/app/src/components/session/session-archived-fleet.test.ts": "A", "packages/app/src/components/session/session-chats-dropdown.test.ts": "A", "packages/app/src/components/session/session-context-tab.tsx": "M", + "packages/app/src/components/session/session-fleet-control-projection.ts": "A", + "packages/app/src/components/session/session-fleet-peers.test.ts": "A", + "packages/app/src/components/session/session-fleet-peers.ts": "A", + "packages/app/src/components/session/session-header.test.tsx": "A", "packages/app/src/components/session/session-header.tsx": "M", "packages/app/src/components/session/session-new-view.tsx": "M", "packages/app/src/components/session/session-preview-tab.tsx": "A", @@ -1583,7 +1704,7 @@ "packages/app/src/components/settings-v2/skills.tsx": "A", "packages/app/src/components/ui/drawer.tsx": "M", "packages/app/src/context/global-sync/bootstrap.test.ts": "A", - "packages/app/src/context/global-sync/child-store.ts": "A", + "packages/app/src/context/global-sync/child-store.ts": "M", "packages/app/src/context/global-sync/event-reducer.test.ts": "M", "packages/app/src/context/global-sync/event-reducer.ts": "M", "packages/app/src/context/global-sync/session-cache.test.ts": "A", @@ -1604,11 +1725,21 @@ "packages/app/src/pages/layout/worktree-auto-rename.test.ts": "A", "packages/app/src/pages/layout/worktree-auto-rename.ts": "A", "packages/app/src/pages/new-session/new-session-draft-controller.ts": "M", + "packages/app/src/pages/new-session/new-session-machine-mount.test.ts": "A", + "packages/app/src/pages/new-session/new-session-machine-mount.ts": "A", + "packages/app/src/pages/new-session/new-session-machine-picker-mount.tsx": "A", + "packages/app/src/pages/new-session/new-session-machine-picker.test.ts": "A", + "packages/app/src/pages/new-session/new-session-machine-selection.ts": "A", "packages/app/src/pages/new-session/new-session-view.test.ts": "A", "packages/app/src/pages/new-session/new-session-view.tsx": "M", "packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "A", + "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "A", + "packages/app/src/pages/session/fleet-manager-tab.tsx": "A", + "packages/app/src/pages/session/fleet-manager.test.ts": "A", + "packages/app/src/pages/session/fleet-manager.ts": "A", "packages/app/src/pages/session/helpers.test.ts": "M", "packages/app/src/pages/session/helpers.ts": "M", + "packages/app/src/pages/session/preview-human-write-gate.test.ts": "A", "packages/app/src/pages/session/review-tab.tsx": "M", "packages/app/src/pages/session/session-panel-width.test.ts": "M", "packages/app/src/pages/session/session-panel-width.ts": "M", @@ -1642,6 +1773,7 @@ "packages/opencode/src/server/amicode/profile.ts": "A", "packages/opencode/src/server/amicode/project.ts": "A", "packages/opencode/src/server/amicode/run-terminal.ts": "A", + "packages/opencode/src/server/amicode/self-shutdown.ts": "A", "packages/opencode/src/server/amicode/toml-lite.ts": "A", "packages/opencode/src/server/amicode/vault-browser.ts": "A", "packages/opencode/src/server/amicode/vaults.ts": "A", @@ -1660,6 +1792,7 @@ "packages/opencode/src/session/prompt/gemini.txt": "M", "packages/opencode/test/cli/run/question.shared.test.ts": "M", "packages/opencode/test/cli/run/run-process.test.ts": "M", + "packages/opencode/test/cli/run/stream.transport.test.ts": "M", "packages/opencode/test/server/httpapi-exercise/index.ts": "M", "packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap": "M", "packages/session-ui/src/v2/components/attachment-card-v2.css": "M", @@ -1708,6 +1841,7 @@ "packages/ui/src/v2/components/radio-v2.css": "M", "packages/ui/src/v2/components/select-v2.css": "M", "packages/ui/src/v2/components/switch-v2.css": "M", + "packages/ui/src/v2/components/switch-v2.tsx": "M", "packages/ui/src/v2/components/toast-v2.css": "M", "packages/ui/src/v2/components/tooltip-v2.css": "M", "packages/ui/src/v2/components/wordmark-v2.tsx": "M", @@ -1732,6 +1866,7 @@ "packages/app/src/pages/session/composer/session-tour.tsx": "A", "packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx": "M", "packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "A", + "packages/app/src/pages/session/timeline/message-timeline-archive.test.ts": "A", "packages/app/src/pages/session/timeline/message-timeline.tsx": "M", "packages/app/src/pages/session/timeline/model-pure.ts": "A", "packages/app/src/pages/session/timeline/model.test.ts": "M", @@ -1740,6 +1875,8 @@ "packages/app/src/pages/session/timeline/projection.ts": "M", "packages/app/src/pages/session/timeline/rows-current.test.ts": "M", "packages/app/src/pages/session/timeline/rows.ts": "M", + "packages/app/src/pages/session/timeline/session-header-provenance.test.ts": "A", + "packages/app/src/pages/session/timeline/session-header-provenance.ts": "A", "packages/app/src/pages/session/timeline/smooth-scroll.test.ts": "A", "packages/app/src/pages/session/timeline/smooth-scroll.ts": "A", "packages/app/src/pages/session/timeline/thought-rail-pure.ts": "A", @@ -1777,7 +1914,8 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "M", - "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M" + "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M", + "packages/opencode/src/server/routes/instance/httpapi/middleware/file-write-workspace.ts": "A" }, "upstream_base_archive_sha256": "4b5f2e5edfc796082a353fd09c1b1dec3e7a108a4b080fe3a86b12d9fa59fae6" } diff --git a/packages/app-bundle/overlay/packages/app/CONTEXT.md b/packages/app-bundle/overlay/packages/app/CONTEXT.md index 53a5f5e8f..bd0ba7993 100644 --- a/packages/app-bundle/overlay/packages/app/CONTEXT.md +++ b/packages/app-bundle/overlay/packages/app/CONTEXT.md @@ -69,6 +69,20 @@ Chat-only. Selecting an item inside a Panel acts in the Chat rather than navigat away from it. _Avoid_: Modal, page, route, flyout +**Work Column**: +The session's supporting column beside the **Chat** — a strip of tabs over the work a +session produces: the files it changed, the file tabs a researcher opens, and named +surfaces (Context, Pulse Inspector, Preview). Most tabs are scoped to the session's own +content. **Home** and **Fleet Manager** are its two **global exceptions** — window-wide +concerns surfaced as Work Column tabs rather than session content: Home is the +resume/overview surface, and Fleet Manager consolidates fleet management (the device +roster and capability chips, this machine's Server mode / transport / Go-Standalone / +Repair, the durable hub service on a server, and fleet versions — absorbing the retired +Fleet & Versions panel). Every tab closes and reorders alike; the two exceptions aside, a +tab tracks something the session itself did. +_Avoid_: side panel (as the whole), review pane (that is one tab), Fleet & Versions panel +(retired — absorbed into the Fleet Manager tab) + **Account zone**: The cluster at the foot of the **Rail** for identity and configuration, distinct from the navigation surfaces above it. Holds the researcher profile (About You), backend diff --git a/packages/app-bundle/overlay/packages/app/src/components/engine-state-signal.ts b/packages/app-bundle/overlay/packages/app/src/components/engine-state-signal.ts new file mode 100644 index 000000000..866b35137 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/engine-state-signal.ts @@ -0,0 +1,119 @@ +import { createSignal } from "solid-js" +import { + parseEngineStateMessage, + parseFleetRoleMessage, + latchedEngineStateAt, + type EngineState, + type FleetRole, +} from "./engine-toggle-utils" + +// engine-state-signal.ts — #1608: the GLOBAL engine lifecycle signal. +// +// #1598 kept the engine-state signal LOCAL to the status popover, so a push +// that arrived while the popover was closed was lost, and nothing outside the +// popover could read it. This promotes it to a module-level always-mounted +// signal — installed ONCE (see installEngineStateListener) so a single window +// listener owns the channel for the whole app lifetime. +// +// It also carries the LOCAL "stop-requested" latch: the click flips the UI to +// `stopping` instantly, before any extension round-trip. A delivered push then +// reconciles it. CRITICALLY (BUG1 fix), the latch is SELF-EXPIRING: if the +// confirming `off` push is dropped (engine dead → SSE gone → the push never +// arrives), a timer resolves the latch to the intended terminal `off` state so +// the toggle is NEVER permanently grayed — the user can always click to restart. + +// How long `stopping` may show before the latch self-resolves to `off`. The +// deliberate kill is SIGTERM→wait→SIGKILL-bounded at ~3s (server_manager.ts), +// so 4s covers the happy path and still recovers a dropped confirm quickly. +export const STOP_LATCH_TIMEOUT_MS = 4000 + +// Default "on": the app can only render when the engine is already serving it, +// so the resting initial state is on; booting/off/stopping arrive via push. +const [engineState, setEngineStateRaw] = createSignal("on") +// Fleet role default standalone — a window with no fleet topology behaves as a +// normal standalone engine host. +const [fleetRole, setFleetRole] = createSignal("standalone") +// The local latch: the wall-clock time of the stop click, or undefined when +// there is no outstanding optimistic stop. A tick signal advances the derived +// state so the timeout is observed reactively — effectiveEngineState() MUST +// read `tick` (below) or the self-expiry setTimeout fires into the void: the +// popover would only re-derive on reopen (BUG1 follow-up — live update lost). +const [latchedAt, setLatchedAt] = createSignal(undefined) +const [tick, setTick] = createSignal(0) +let latchTimer: ReturnType | undefined +const now = () => Date.now() + +export { engineState, fleetRole } + +/** The state the UI should render: the latch reads `stopping` inside the + * confirm window, then resolves to `off` (BUG1); a delivered push wins. + * + * Reads `tick()` for its reactive side effect ONLY: `now()` is a plain + * non-reactive Date.now(), so nothing here would re-run when the self-expiry + * timer elapses. The timer bumps `tick` (latchStopRequested); reading it makes + * this a tracked dependency so the toggle re-derives to `off` live at the 4s + * boundary WITHOUT the user reopening the popover. */ +export function effectiveEngineState(): EngineState { + tick() // reactive dependency — see the timer in latchStopRequested() + return latchedEngineStateAt(engineState(), latchedAt(), now(), STOP_LATCH_TIMEOUT_MS) +} + +function clearLatchTimer() { + if (latchTimer !== undefined) { + clearTimeout(latchTimer) + latchTimer = undefined + } +} + +/** Flip the toggle to `stopping` locally the instant the user clicks. Records + * the click time and arms the self-expiry timer: if no confirming push lands + * within STOP_LATCH_TIMEOUT_MS, the derived state falls back to `off` so the + * toggle is never stuck grayed. */ +export function latchStopRequested() { + setLatchedAt(now()) + clearLatchTimer() + latchTimer = setTimeout(() => { + // Tick to re-derive effectiveEngineState() → past the window it reads `off`. + // The latch is intentionally NOT cleared: `off` is the intended terminal + // state, and a later real push (e.g. `on` from a restart) still wins. + setTick((t) => t + 1) + latchTimer = undefined + }, STOP_LATCH_TIMEOUT_MS) +} + +/** Apply a delivered engine-state push. Clears the local latch + its timer — + * the real state now owns the rendering, so the optimistic latch steps aside. */ +export function applyEngineState(state: EngineState) { + setLatchedAt(undefined) + clearLatchTimer() + setEngineStateRaw(state) +} + +/** Install the ONE window listener that feeds the global signals. Idempotent — + * a second install is a no-op — so multiple call sites (the always-mounted + * host + the popover) are safe. Returns a disposer for symmetry/testing. */ +let installed = false +export function installEngineStateListener(win: Window = window): () => void { + if (installed) return () => {} + installed = true + const onMsg = (e: MessageEvent) => { + const state = parseEngineStateMessage(e.data) + if (state !== undefined) applyEngineState(state) + const role = parseFleetRoleMessage(e.data) + if (role !== undefined) setFleetRole(role) + } + win.addEventListener("message", onMsg) + return () => { + win.removeEventListener("message", onMsg) + installed = false + } +} + +/** TEST-ONLY: reset the module signals + install guard between cases. */ +export function __resetEngineStateForTest() { + setEngineStateRaw("on") + setFleetRole("standalone") + setLatchedAt(undefined) + clearLatchTimer() + installed = false +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.test.ts b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.test.ts new file mode 100644 index 000000000..ae770f892 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.test.ts @@ -0,0 +1,201 @@ +// engine-toggle-utils.test.ts — #1598: unit tests for the engine toggle logic. +import { describe, it, expect, vi, beforeEach, afterEach } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { + parseEngineStateMessage, + parseFleetRoleMessage, + sendEngineCommand, + engineDotClass, + latchedEngineStateAt, +} from "./engine-toggle-utils" + +describe("parseEngineStateMessage", () => { + it("parses a valid engine-state message for each state", () => { + expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "on" })).toBe("on") + expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "booting" })).toBe("booting") + expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "off" })).toBe("off") + }) + + it("returns undefined for non-amicode messages", () => { + expect(parseEngineStateMessage({ source: "vscode", kind: "engine-state", state: "on" })).toBeUndefined() + }) + + it("returns undefined for different amicode kinds", () => { + expect(parseEngineStateMessage({ source: "amicode", kind: "theme", state: "on" })).toBeUndefined() + }) + + it("returns undefined for invalid state values", () => { + expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "starting" })).toBeUndefined() + }) + + // #1608 AC9: the parser accepts the new `stopping` lifecycle state so the + // extension can narrate a deliberate off (locked/grayed) before the kill. + it("parses the `stopping` state (#1608)", () => { + expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "stopping" })).toBe("stopping") + }) + + it("returns undefined for non-object input", () => { + expect(parseEngineStateMessage(null)).toBeUndefined() + expect(parseEngineStateMessage("string")).toBeUndefined() + expect(parseEngineStateMessage(42)).toBeUndefined() + }) +}) + +describe("parseFleetRoleMessage", () => { + it("parses valid fleet-role messages", () => { + expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "standalone" })).toBe("standalone") + expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "server" })).toBe("server") + expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "client" })).toBe("client") + }) + + it("returns undefined for non-amicode messages", () => { + expect(parseFleetRoleMessage({ source: "vscode", kind: "fleet-role", role: "client" })).toBeUndefined() + }) + + it("returns undefined for invalid roles", () => { + expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "unknown" })).toBeUndefined() + }) +}) + +describe("sendEngineCommand", () => { + let posted: unknown[] + const originalParent = globalThis.window + + beforeEach(() => { + posted = [] + // Mock window.parent.postMessage + if (typeof globalThis.window === "undefined") { + ;(globalThis as any).window = {} + } + ;(globalThis.window as any).parent = { + postMessage: (msg: unknown, _target: string) => { + posted.push(msg) + }, + } + }) + + afterEach(() => { + // Restore + if (originalParent === undefined) { + delete (globalThis as any).window + } + }) + + it("sends amicode.stopServer when engine is on", () => { + sendEngineCommand("on") + expect(posted).toHaveLength(1) + expect(posted[0]).toEqual({ + source: "amicode", + kind: "command", + command: "amicode.stopServer", + }) + }) + + it("sends amicode.restartServer when engine is off", () => { + sendEngineCommand("off") + expect(posted).toHaveLength(1) + expect(posted[0]).toEqual({ + source: "amicode", + kind: "command", + command: "amicode.restartServer", + }) + }) + + it("does nothing when engine is booting (locked)", () => { + sendEngineCommand("booting") + expect(posted).toHaveLength(0) + }) + + // #1608 AC9: `stopping` is a locked transitional state — no command fires. + it("does nothing when engine is stopping (locked) (#1608)", () => { + sendEngineCommand("stopping") + expect(posted).toHaveLength(0) + }) +}) + +// #1608 AC4: the engine dot renders from an EXHAUSTIVE switch. `stopping` and +// `booting` both read warning (grayed/locked); a future state that isn't +// handled is a compile error (assertNever), not a silent grey dot. +describe("engineDotClass (#1608)", () => { + it("maps each lifecycle state to its dot token", () => { + expect(engineDotClass("on")).toBe("bg-icon-success-base") + expect(engineDotClass("booting")).toBe("bg-icon-warning-base") + expect(engineDotClass("stopping")).toBe("bg-icon-warning-base") + expect(engineDotClass("off")).toBe("bg-border-weak-base") + }) +}) + +// #1608 follow-up (BUG1): the latch must be SELF-LIMITING. The stuck-stopping +// bug was `stopping` staying locked forever when the confirming `off` push was +// dropped (engine dead → SSE gone → push never delivered). The latch resolves +// to the INTENDED terminal state `off` after a timeout, so the toggle is never +// permanently grayed — a user can always click to restart. +describe("latchedEngineStateAt — self-expiring latch (#1608 BUG1)", () => { + const TIMEOUT = 4000 + it("reads `stopping` inside the confirm window (instant feedback preserved)", () => { + // latched at t=0, now=1s, still within the 4s window + expect(latchedEngineStateAt("on", 0, 1000, TIMEOUT)).toBe("stopping") + }) + + it("falls back to `off` once the window elapses with no confirming push", () => { + // latched at t=0, now=5s > 4s window → resolve to the intended terminal off + expect(latchedEngineStateAt("on", 0, 5000, TIMEOUT)).toBe("off") + }) + + it("resolves to `off` exactly at the boundary (>= timeout)", () => { + expect(latchedEngineStateAt("on", 0, 4000, TIMEOUT)).toBe("off") + }) + + it("passes raw through when there is no latch (latchedAt undefined)", () => { + expect(latchedEngineStateAt("on", undefined, 9999, TIMEOUT)).toBe("on") + expect(latchedEngineStateAt("off", undefined, 9999, TIMEOUT)).toBe("off") + }) + + it("lets a delivered push win over the latch, even inside the window", () => { + // a real off/booting push landed → raw changed → it wins immediately + expect(latchedEngineStateAt("off", 0, 500, TIMEOUT)).toBe("off") + expect(latchedEngineStateAt("booting", 0, 500, TIMEOUT)).toBe("booting") + }) +}) + +// #1608 follow-up (BUG1b — live update lost): the pure helper above was correct, +// but the toggle only settled to `off` when the popover was CLOSED and REOPENED. +// Root cause: effectiveEngineState() reads `now()` (plain Date.now(), NOT +// reactive), so the self-expiry setTimeout's setTick() invalidated nothing — +// the derived state never re-ran at the 4s boundary. The fix wires a `tick` +// signal that effectiveEngineState() READS, so the timer's setTick re-derives +// it live. solid-js is not resolvable in this package's node_modules (engine +// source tree, built by the bun bundler, not pnpm install — see the session +// ledger's honest-degradation note), so a createRoot/createEffect reactivity +// harness cannot run here; the binary build is the authoritative compile gate. +// We guard the exact regression with a source assertion: effectiveEngineState() +// MUST read the tick, or the setTimeout fires into the void again. +describe("engine-state-signal reactivity wiring (#1608 BUG1b)", () => { + const signalSrc = readFileSync( + fileURLToPath(new URL("./engine-state-signal.ts", import.meta.url)), + "utf8", + ) + + it("names the tick reader (does not discard the setter alone)", () => { + // the bug was `const [, setTick] = createSignal(0)` — a write with no reader + expect(signalSrc).toContain("const [tick, setTick] = createSignal(0)") + }) + + it("effectiveEngineState reads tick() so the self-expiry timer re-derives", () => { + const body = signalSrc.slice( + signalSrc.indexOf("export function effectiveEngineState"), + signalSrc.indexOf("function clearLatchTimer"), + ) + expect(body).toContain("tick()") + expect(body).toContain("latchedEngineStateAt(") + }) + + it("the self-expiry timer bumps tick to invalidate the derived state", () => { + const body = signalSrc.slice( + signalSrc.indexOf("export function latchStopRequested"), + signalSrc.indexOf("export function applyEngineState"), + ) + expect(body).toContain("setTick(") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.ts b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.ts new file mode 100644 index 000000000..253020359 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.ts @@ -0,0 +1,101 @@ +// engine-toggle.ts — #1598: pure logic for the engine on/off toggle. +// +// Message-driven state machine: the extension pushes engine-state and +// fleet-role messages; this module parses them into typed state. The +// component (engine-toggle.tsx) renders from this state. + +/** Engine lifecycle state as pushed by the extension. + * #1608: `stopping` narrates a deliberate off — the toggle grays/locks between + * the click and the resting `off` state. It stays in sync with the extension's + * EngineState (engine_state_push.ts) — the two definitions are the only two. */ +export type EngineState = "on" | "booting" | "off" | "stopping" + +/** Fleet role as pushed by the extension. */ +export type FleetRole = "standalone" | "server" | "client" + +/** Parse an engine-state message from the extension bridge. */ +export function parseEngineStateMessage( + data: unknown, +): EngineState | undefined { + if (!data || typeof data !== "object") return undefined + const d = data as { source?: string; kind?: string; state?: string } + if (d.source !== "amicode" || d.kind !== "engine-state") return undefined + if (d.state === "on" || d.state === "booting" || d.state === "off" || d.state === "stopping") return d.state + return undefined +} + +/** Parse a fleet-role message from the extension bridge. */ +export function parseFleetRoleMessage( + data: unknown, +): FleetRole | undefined { + if (!data || typeof data !== "object") return undefined + const d = data as { source?: string; kind?: string; role?: string } + if (d.source !== "amicode" || d.kind !== "fleet-role") return undefined + if (d.role === "standalone" || d.role === "server" || d.role === "client") return d.role + return undefined +} + +/** Send a bridge command to the extension (stop or restart the engine). */ +export function sendEngineCommand(state: EngineState): void { + if (state === "on") { + // Engine is on → stop it + window.parent?.postMessage( + { source: "amicode", kind: "command", command: "amicode.stopServer" }, + "*", + ) + } else if (state === "off") { + // Engine is off → restart it + window.parent?.postMessage( + { source: "amicode", kind: "command", command: "amicode.restartServer" }, + "*", + ) + } + // booting / stopping → locked transitional states, no action +} + +/** Exhaustiveness helper — a future EngineState addition that isn't handled in + * a switch becomes a COMPILE error here (#1608 AC9), never a silent fallthrough. */ +export function assertNever(x: never): never { + throw new Error(`unhandled engine state: ${String(x)}`) +} + +/** The engine dot's CSS token for a lifecycle state — the exhaustive switch the + * toggle renders from (#1608 AC4). `stopping` reads warning (grayed/locked), + * same family as `booting`. */ +export function engineDotClass(state: EngineState): string { + switch (state) { + case "on": + return "bg-icon-success-base" + case "booting": + case "stopping": + return "bg-icon-warning-base" + case "off": + return "bg-border-weak-base" + default: + return assertNever(state) + } +} + +/** The calm off/stopping/booting narration was removed (#1608 follow-up): the + * toggle's own dot/lock is the whole story, per the user. See engineDotClass. */ + +/** Self-expiring latch resolution (#1608 BUG1). The stuck-stopping bug was the + * latch reading `stopping` forever when the confirming `off` push was dropped + * (engine dead → SSE gone → the push never arrives). This resolves the latch + * to its INTENDED terminal state `off` once `timeoutMs` elapses, so the toggle + * is never permanently grayed: + * - no latch (`latchedAt` undefined) → raw passes through; + * - raw already changed (a push landed) → raw wins immediately; + * - latch set, raw still `on`, inside the window → `stopping`; + * - latch set, raw still `on`, window elapsed → `off` (clickable to restart). + * Pure so it is testable without the solid signal graph or real timers. */ +export function latchedEngineStateAt( + raw: EngineState, + latchedAt: number | undefined, + now: number, + timeoutMs: number, +): EngineState { + if (latchedAt === undefined) return raw + if (raw !== "on") return raw // a real push already moved us off `on` + return now - latchedAt >= timeoutMs ? "off" : "stopping" +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/new-session-machine-picker.ts b/packages/app-bundle/overlay/packages/app/src/components/new-session-machine-picker.ts new file mode 100644 index 000000000..8dac5eae1 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/new-session-machine-picker.ts @@ -0,0 +1,92 @@ +/** + * new-session-machine-picker.ts — #1442 AC4 + * + * Pure-logic module for the new-session machine picker cascade: + * Machine ▸ Project ▸ Worktree + * + * Machine is FIRST in the cascade. The default selected machine comes from the + * focused machine in fleet_focus.ts. When no machine is focused, the local + * machine is the default. The create call carries the chosen machine_id. + * + * This module is the data/state layer; the TSX renderer consumes it. + */ + +// ── types ──────────────────────────────────────────────────────────────────── + +/** A machine available for selection in the new-session picker. */ +export interface MachineOption { + machineId: string + name: string + isLocal: boolean +} + +/** The cascade order for the new-session selectors. */ +export type CascadeStep = "machine" | "project" | "worktree" + +/** Options for creating the machine picker state. */ +export interface MachinePickerOptions { + /** Available machines (from the fleet roster). */ + machines: MachineOption[] + /** The currently focused machine id (from fleet_focus.ts), or undefined for + * home (local). */ + focusedMachineId: string | undefined +} + +/** The create-session payload with the chosen machine. */ +export interface CreateSessionPayload { + machine_id: string +} + +/** The machine picker state — manages selection and produces the create payload. */ +export interface MachinePickerState { + /** The cascade order: machine first, then project, then worktree. */ + readonly cascadeOrder: readonly CascadeStep[] + /** The currently selected machine id. */ + readonly selectedMachineId: string + /** The available machines for the selector. */ + readonly machines: readonly MachineOption[] + /** Select a different machine by id. */ + selectMachine(machineId: string): void + /** Build the create-session payload carrying the chosen machine_id. */ + createPayload(): CreateSessionPayload +} + +// ── state factory ──────────────────────────────────────────────────────────── + +/** The fixed cascade order: Machine ▸ Project ▸ Worktree. */ +const CASCADE_ORDER: readonly CascadeStep[] = ["machine", "project", "worktree"] as const + +/** Create the machine picker state. Default selection = focused machine, or + * the local machine when no focus is set. */ +export function createMachinePickerState(opts: MachinePickerOptions): MachinePickerState { + const { machines, focusedMachineId } = opts + + // Resolve default: focused machine first, then the local machine, then first + const localMachine = machines.find((m) => m.isLocal) + const focusedMachine = focusedMachineId ? machines.find((m) => m.machineId === focusedMachineId) : undefined + const defaultMachineId = focusedMachine?.machineId ?? localMachine?.machineId ?? machines[0]?.machineId ?? "" + + let _selectedMachineId = defaultMachineId + + return { + cascadeOrder: CASCADE_ORDER, + + get selectedMachineId() { + return _selectedMachineId + }, + + get machines() { + return machines + }, + + selectMachine(machineId: string) { + if (machines.some((m) => m.machineId === machineId)) { + _selectedMachineId = machineId + } + }, + + createPayload(): CreateSessionPayload { + return { machine_id: _selectedMachineId } + }, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx index ad9cc7ecb..be54dc73e 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx @@ -28,6 +28,7 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useServerSDK } from "@/context/server-sdk" import { createStreamGap } from "@/context/stream-gap" +import { effectiveEngineState } from "@/components/engine-state-signal" import { createSessionTabs } from "@/pages/session/helpers" import { showToast } from "@/utils/toast" import { bugReportEnabled } from "@/utils/amicode-bug-report" @@ -96,6 +97,12 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): // with the reason instead of optimistically posting into a dead tunnel. const serverSDK = useServerSDK() const streamGap = createStreamGap(() => serverSDK().event.status()) + // #1608 AC6: refuse a send while the engine is deliberately off (or stopping) + // with an engine-specific reason, ahead of the generic stream-gap refusal. + const engineOff = () => { + const s = effectiveEngineState() + return s === "off" || s === "stopping" + } const files = useFile() const layout = useLayout() const comments = useComments() @@ -240,6 +247,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): onAbort: props.onAbort, onSubmit: props.onSubmit, streamGap, + engineOff, model: props.controls.model.selection, }) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx index dc8a124f6..95002e2b6 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx @@ -30,6 +30,7 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useServerSDK } from "@/context/server-sdk" import { createStreamGap } from "@/context/stream-gap" +import { effectiveEngineState } from "@/components/engine-state-signal" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" @@ -110,6 +111,12 @@ export const PromptInput: Component = (props) => { // with the reason instead of optimistically posting into a dead tunnel. const serverSDK = useServerSDK() const streamGap = createStreamGap(() => serverSDK().event.status()) + // #1608 AC6: refuse a send while the engine is deliberately off (or stopping) + // with an engine-specific reason, ahead of the generic stream-gap refusal. + const engineOff = () => { + const s = effectiveEngineState() + return s === "off" || s === "stopping" + } const files = useFile() const prompt = props.state ?? usePrompt() const layout = useLayout() @@ -1232,6 +1239,7 @@ export const PromptInput: Component = (props) => { onAbort: props.onAbort, onSubmit: props.onSubmit, streamGap, + engineOff, model: props.controls.model.selection, }) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts index 1c2a773ff..dfe05fb9f 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts @@ -46,3 +46,42 @@ describe("send-during-gap honest refusal (#1203)", () => { } }) }) + +// #1608 AC6: a send attempted while the engine is OFF is refused with an +// engine-specific message that takes PRECEDENCE over the generic +// connection-dropped (stream-gap) refusal — the specific reason wins. +describe("send-while-engine-off honest refusal (#1608 AC6)", () => { + test("the engine-off refusal is ordered BEFORE the stream-gap refusal", () => { + const engineOff = source.indexOf("input.engineOff?.()") + const streamGap = source.indexOf("input.streamGap?.()") + expect(engineOff).toBeGreaterThan(-1) + expect(streamGap).toBeGreaterThan(-1) + // the specific reason wins: engine-off is consulted first + expect(engineOff).toBeLessThan(streamGap) + }) + + test("both refusals fire before anything is committed to the composer", () => { + const engineOff = source.indexOf("input.engineOff?.()") + const history = source.indexOf("input.addToHistory") + const clear = source.indexOf("clearInput()") + expect(history).toBeGreaterThan(engineOff) + expect(clear).toBeGreaterThan(engineOff) + }) + + test("the engine-off refusal shows an engine-specific notice (not the generic drop)", () => { + expect(source).toContain("prompt.toast.engineOff.title") + expect(source).toContain("prompt.toast.engineOff.description") + }) + + test("the engine-off state is INJECTED, so submission stays testable without the provider tree", () => { + expect(source).toContain("engineOff?: Accessor") + }) + + test("both composers wire the engine-off accessor from the global engine signal", () => { + for (const composer of ["../prompt-input.tsx", "../prompt-input-v2.tsx"]) { + const wiring = readFileSync(join(import.meta.dir, composer), "utf8") + expect(wiring).toContain("effectiveEngineState") + expect(wiring).toContain("engineOff,") + } + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts index a6d6843f6..8a161db4e 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts @@ -6,6 +6,7 @@ import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { batch, startTransition, type Accessor } from "solid-js" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" +import { useServer } from "@/context/server" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal, type ModelSelection } from "@/context/local" @@ -24,6 +25,12 @@ import { createPromptSubmissionState } from "./submission-state" import { normalizeSessionInfo } from "@/utils/session" import { Event } from "@opencode-ai/schema/event" import { blobDataUrl } from "@/utils/draft-store" +import { startReconcileTimer, cancelReconcileTimer } from "@/context/session-status-reconcile" +import { amicodeGet } from "@/utils/amicode-fetch" +import { currentMachineSelection } from "@/pages/new-session/new-session-machine-selection" +import { runCreatePreflight } from "@/components/remote-create-preflight" +import { armRemoteCreate, disarmRemoteCreate } from "@/components/remote-create-arm" +import type { CreationTargetResponse, PeerHomeBaseResponse } from "@/components/remote-create-preflight" type PendingPrompt = { abort: AbortController @@ -62,10 +69,14 @@ export async function sendFollowupDraft(input: FollowupSendInput) { const setBusy = () => { if (!input.optimisticBusy) return input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) + startReconcileTimer(input.draft.sessionID, (sessionId) => { + input.serverSync.session.set("session_status", sessionId, { type: "idle" }) + }) } const setIdle = () => { if (!input.optimisticBusy) return + cancelReconcileTimer(input.draft.sessionID) input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) } @@ -233,6 +244,12 @@ type PromptSubmitInput = { * after a first successful connect); a send is then refused with an honest * notice instead of optimistically posting into a dead tunnel. */ streamGap?: Accessor + /** amicode#1608 — true when the engine is deliberately OFF (or stopping). A + * send is refused with an engine-specific notice that takes PRECEDENCE over + * streamGap, so an intentional off reads as intentional rather than as a + * generic connection drop. Injected (like streamGap) so submission stays + * testable without the provider tree. */ + engineOff?: Accessor model?: ModelSelection } @@ -241,6 +258,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sdk = useSDK() const sync = useSync() const serverSync = useServerSync() + const server = useServer() const local = useLocal() const permission = usePermission() const prompt = input.prompt @@ -340,6 +358,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } + // amicode#1608 AC6 — a send while the engine is deliberately OFF refuses + // with an engine-specific reason, ordered BEFORE the stream-gap refusal so + // the specific "engine is off" reason wins over the generic + // connection-dropped notice. Same honest-refusal contract as the gap: + // nothing was sent, the draft is untouched, nothing pushed to history. + if (input.engineOff?.()) { + showToast({ + title: language.t("prompt.toast.engineOff.title"), + description: language.t("prompt.toast.engineOff.description"), + }) + return + } + // amicode#1203 AC3 — a send during the stream gap refuses with an honest // reason: nothing was sent, the draft is untouched in the composer, nothing // was pushed to history. Never a fake success, never a silent loss. The gap @@ -419,6 +450,35 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { + // #1643 (completes #1484 AC3): the pre-flight gate. When the composer's + // machine picker selected a REMOTE peer, resolve the create target + the + // peer's working directory BEFORE creating. A blocked pick surfaces its + // exact reason and creates NOTHING; a remote pick arms x-amicode-owner + // (the multiplexer routes the path-less create POST to the peer) and + // lands in the peer's directory; a local pick is byte-unchanged. + const picked = currentMachineSelection() + let remoteOwnerArmed = false + if (picked) { + const conn = server.current + const preflight = await runCreatePreflight(picked, { + getCreationTarget: (m) => + amicodeGet(conn, `/amicode/fleet/creation-target?machine=${encodeURIComponent(m)}`) as Promise, + getPeerHomeBase: (m) => + amicodeGet(conn, `/amicode/fleet/peer-home-base?machine=${encodeURIComponent(m)}`) as Promise, + }) + if (!preflight.proceed) { + showToast({ + title: language.t("prompt.toast.sessionCreateFailed.title"), + description: preflight.displayText ?? language.t("common.requestFailed"), + }) + return + } + if (preflight.ownerToArm) { + armRemoteCreate(preflight.ownerToArm) + remoteOwnerArmed = true + if (preflight.remoteDirectory) sessionDirectory = preflight.remoteDirectory + } + } const created = await sdk() .api.session.create({ agent: currentAgent.name, @@ -427,6 +487,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) .then(normalizeSessionInfo) .catch((err) => { + // Never leave a stale arm behind if the create rejected. + if (remoteOwnerArmed) disarmRemoteCreate() showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(err), @@ -536,6 +598,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { clearInput() const messageID = Identifier.ascending("message") serverSync().session.set("session_status", session.id, { type: "busy" }) + startReconcileTimer(session.id, (sessionId) => { + serverSync().session.set("session_status", sessionId, { type: "idle" }) + }) sdk() .api.session.command({ sessionID: session.id, @@ -552,6 +617,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { ), }) .catch((err) => { + cancelReconcileTimer(session.id) serverSync().session.set("session_status", session.id, { type: "idle" }) showToast({ title: language.t("prompt.toast.commandSendFailed.title"), diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.test.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.test.ts new file mode 100644 index 000000000..043a7d21c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.test.ts @@ -0,0 +1,81 @@ +/** + * remote-create-arm.test.ts — #1643 (completes #1484 AC3) + * + * The arming store + fetch-seam attachment. The composer, having pre-flight + * resolved a REMOTE target, ARMS the owner id; the SDK's custom fetch (the + * sseFetch-precedent seam, server-sdk.tsx) reads the armed owner and attaches + * `x-amicode-owner` to the NEXT path-less session-create POST, then disarms. + * A one-shot: it must not leak the header onto later or unrelated requests. + */ +import { describe, expect, test, beforeEach } from "bun:test" +import { + armRemoteCreate, + disarmRemoteCreate, + peekArmedOwner, + attachOwnerHeaderIfArmed, + isSessionCreate, +} from "./remote-create-arm" +import { OWNER_HEADER } from "./remote-create-header" + +describe("#1643 isSessionCreate — recognizes the path-less create POST", () => { + test("POST /session is a create", () => { + expect(isSessionCreate("POST", "http://x/session")).toBe(true) + }) + test("POST /api/session is a create (v2 vendored client)", () => { + expect(isSessionCreate("POST", "http://x/api/session")).toBe(true) + }) + test("POST /session/abc/message is NOT a create", () => { + expect(isSessionCreate("POST", "http://x/session/abc/message")).toBe(false) + }) + test("GET /session is NOT a create", () => { + expect(isSessionCreate("GET", "http://x/session")).toBe(false) + }) +}) + +describe("#1643 arm/disarm store", () => { + beforeEach(() => disarmRemoteCreate()) + + test("peek is undefined by default", () => { + expect(peekArmedOwner()).toBeUndefined() + }) + test("arm then peek returns the owner", () => { + armRemoteCreate("studio-001") + expect(peekArmedOwner()).toBe("studio-001") + }) + test("disarm clears it", () => { + armRemoteCreate("studio-001") + disarmRemoteCreate() + expect(peekArmedOwner()).toBeUndefined() + }) +}) + +describe("#1643 attachOwnerHeaderIfArmed — one-shot header injection", () => { + beforeEach(() => disarmRemoteCreate()) + + test("armed + create POST → header attached AND store disarmed", () => { + armRemoteCreate("studio-001") + const headers = attachOwnerHeaderIfArmed("POST", "http://x/session", {}) + expect(headers[OWNER_HEADER]).toBe("studio-001") + // one-shot: disarmed after use + expect(peekArmedOwner()).toBeUndefined() + }) + + test("armed but NOT a create POST → no header, still armed", () => { + armRemoteCreate("studio-001") + const headers = attachOwnerHeaderIfArmed("POST", "http://x/session/abc/message", {}) + expect(headers[OWNER_HEADER]).toBeUndefined() + expect(peekArmedOwner()).toBe("studio-001") + }) + + test("not armed + create POST → NO header (local create is byte-unchanged)", () => { + const headers = attachOwnerHeaderIfArmed("POST", "http://x/session", {}) + expect(headers[OWNER_HEADER]).toBeUndefined() + }) + + test("preserves existing headers", () => { + armRemoteCreate("studio-001") + const headers = attachOwnerHeaderIfArmed("POST", "http://x/session", { "content-type": "application/json" }) + expect(headers["content-type"]).toBe("application/json") + expect(headers[OWNER_HEADER]).toBe("studio-001") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.ts new file mode 100644 index 000000000..171a29f7e --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.ts @@ -0,0 +1,78 @@ +/** + * remote-create-arm.ts — #1643 (completes #1484 AC3) + * + * The arming store + fetch-seam attachment — the mechanism that carries the + * owner header from the composer's pre-flight decision to the actual create + * request, WITHOUT threading a parameter through the SDK's typed create call + * (which takes no per-call headers). It mirrors the sseFetch precedent + * (server-sdk.tsx): a custom fetch wrapper rewrites the outgoing request. + * + * Flow: + * 1. The composer pre-flight resolves a REMOTE target → armRemoteCreate(id). + * 2. It calls sdk().api.session.create(...); that create is a path-less + * POST /session (the exact case the multiplexer routes on the header). + * 3. The SDK's fetch wrapper calls attachOwnerHeaderIfArmed — on a create + * POST while armed, it attaches x-amicode-owner and DISARMS (one-shot). + * 4. A local create never arms → no header → byte-unchanged behavior. + * + * One-shot by design: the header must land on exactly the create it was armed + * for, never leak onto later requests. Module-level state is the right lifetime + * (a single armed create at a time; a create is synchronous-to-arm here). + */ + +import { OWNER_HEADER } from "./remote-create-header" + +// ── the arming store ───────────────────────────────────────────────────────── + +let _armedOwner: string | undefined + +/** Arm the next session-create to route to this owner peer. */ +export function armRemoteCreate(ownerMachineId: string): void { + const trimmed = ownerMachineId.trim() + _armedOwner = trimmed === "" ? undefined : trimmed +} + +/** Clear any armed owner (called on disarm and after a one-shot attach). */ +export function disarmRemoteCreate(): void { + _armedOwner = undefined +} + +/** Read the armed owner without consuming it (test/inspection). */ +export function peekArmedOwner(): string | undefined { + return _armedOwner +} + +// ── request recognition ────────────────────────────────────────────────────── + +/** Is this the path-less session-create POST? Both client shapes: + * POST /session (v1 legacy client) + * POST /api/session (v2 vendored client) + * A create carries NO trailing sub-segment (message/command/etc. are not + * creates). Mirrors the multiplexer's own path discipline. */ +export function isSessionCreate(method: string, url: string): boolean { + if (method.toUpperCase() !== "POST") return false + let pathname: string + try { + pathname = new URL(url).pathname + } catch { + pathname = url + } + return pathname === "/session" || pathname === "/api/session" +} + +// ── the fetch-seam attachment ──────────────────────────────────────────────── + +/** On a create POST while armed, return headers with x-amicode-owner attached + * and DISARM (one-shot); otherwise return the headers unchanged. Existing + * headers are preserved. */ +export function attachOwnerHeaderIfArmed( + method: string, + url: string, + headers: Record, +): Record { + if (_armedOwner === undefined) return headers + if (!isSessionCreate(method, url)) return headers + const owner = _armedOwner + disarmRemoteCreate() + return { ...headers, [OWNER_HEADER]: owner } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.test.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.test.ts new file mode 100644 index 000000000..56666f0e2 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.test.ts @@ -0,0 +1,65 @@ +/** + * remote-create-header.test.ts — #1643 (completes #1484 AC3) + * + * The owner-header producer: given the pre-flight-resolved creation target, it + * decides what the composer does on submit — attach `x-amicode-owner` and + * create remotely, create locally with NO header, or refuse with a blocked + * reason. This is the missing PRODUCER for ADR 0031 §D1a leg 2 (the multiplexer + * has consumed the header since #1449; nothing emitted it). + */ +import { describe, expect, test } from "bun:test" +import { + OWNER_HEADER, + planRemoteCreate, + type CreationTargetView, +} from "./remote-create-header" + +describe("#1643 planRemoteCreate — the owner-header producer", () => { + test("local target → proceed, no owner header", () => { + const plan = planRemoteCreate({ kind: "local" }) + expect(plan.proceed).toBe(true) + expect(plan.headers).toEqual({}) + expect(plan.blockedReason).toBeUndefined() + }) + + test("remote target → proceed, attach x-amicode-owner = machineId", () => { + const plan = planRemoteCreate({ kind: "remote", machineId: "studio-001" }) + expect(plan.proceed).toBe(true) + expect(plan.headers[OWNER_HEADER]).toBe("studio-001") + }) + + test("blocked target → do NOT proceed, carry the reason, no header", () => { + const plan = planRemoteCreate({ kind: "blocked", machineId: "studio-001", reason: "no-control-grant" }) + expect(plan.proceed).toBe(false) + expect(plan.blockedReason).toBe("no-control-grant") + expect(plan.headers).toEqual({}) + }) + + test("the header key is the exact routing header the multiplexer reads", () => { + // Must byte-match session_multiplexer.ts OWNER_ROUTING_HEADER. + expect(OWNER_HEADER).toBe("x-amicode-owner") + }) + + test("remote target with an empty machineId is refused (never an empty header)", () => { + const plan = planRemoteCreate({ kind: "remote", machineId: "" } as CreationTargetView) + expect(plan.proceed).toBe(false) + expect(plan.headers).toEqual({}) + }) +}) + +describe("#1643 blocked-reason display text", () => { + test("every blocked reason maps to human-readable inline text", () => { + const reasons = [ + "no-control-grant", + "grant-revoked", + "insufficient-scope", + "transport-down", + "no-remote-root", + ] as const + for (const r of reasons) { + const plan = planRemoteCreate({ kind: "blocked", machineId: "m", reason: r }) + expect(plan.displayText).toBeTruthy() + expect(typeof plan.displayText).toBe("string") + } + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.ts new file mode 100644 index 000000000..03bda49fd --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.ts @@ -0,0 +1,81 @@ +/** + * remote-create-header.ts — #1643 (completes #1484 AC3) + * + * The OWNER-HEADER PRODUCER — the missing emitter for ADR 0031 §D1a leg 2. The + * per-session multiplexer has read `x-amicode-owner` since #1449; nothing in the + * app ever set it, so every create resolved local (the AC3-shipped-incomplete + * finding). This module is that producer, as a pure decision function: + * + * given the pre-flight-resolved creation target (from GET + * /amicode/fleet/creation-target), decide whether the composer proceeds and, + * if remote, WHAT header the session.create attaches. + * + * - local → proceed, no header (byte-unchanged from today's local create) + * - remote → proceed, attach x-amicode-owner: + * - blocked → do NOT proceed; carry the reason for inline display + * + * Pure: no I/O. The composer runs the pre-flight fetch, feeds the target here, + * and either dispatches with plan.headers or renders plan.displayText inline. + */ + +/** The routing header. MUST byte-match session_multiplexer.ts + * OWNER_ROUTING_HEADER — the multiplexer reads exactly this key. */ +export const OWNER_HEADER = "x-amicode-owner" + +/** The blocked reasons the service's creation-target route can return, plus + * no-remote-root from the peer-home-base route. Closed vocabulary. */ +export type BlockedReason = + | "no-control-grant" + | "grant-revoked" + | "insufficient-scope" + | "transport-down" + | "no-remote-root" + +/** The creation target as the service route reports it (mirrors CreationTarget + * in session_creation_target.ts, plus no-remote-root from the home-base gate). */ +export type CreationTargetView = + | { kind: "local" } + | { kind: "remote"; machineId: string } + | { kind: "blocked"; machineId: string; reason: BlockedReason } + +export interface RemoteCreatePlan { + /** Whether the composer should proceed with session.create. */ + proceed: boolean + /** Headers to attach to the create request (empty for local). */ + headers: Record + /** The blocked reason, when proceed is false. */ + blockedReason?: BlockedReason + /** Human-readable inline text to render when blocked. */ + displayText?: string +} + +const BLOCKED_TEXT: Record = { + "no-control-grant": "No control grant for that machine — request control before creating a session there.", + "grant-revoked": "The control grant for that machine was revoked.", + "insufficient-scope": "That grant is observe-only — creating a session needs control scope.", + "transport-down": "That machine is currently unreachable.", + "no-remote-root": "Couldn't resolve a working directory on that machine.", +} + +/** Decide the composer's create plan from the resolved target. Pure. */ +export function planRemoteCreate(target: CreationTargetView): RemoteCreatePlan { + if (target.kind === "local") { + return { proceed: true, headers: {} } + } + if (target.kind === "remote") { + // Defensive: a remote target must carry a non-empty machineId, else we would + // emit an empty routing header (which the multiplexer treats as local — the + // exact silent-local-fallback #1382 forbids). Refuse instead. + if (!target.machineId || target.machineId.trim() === "") { + return { proceed: false, headers: {}, displayText: "Remote target is missing a machine id." } + } + return { proceed: true, headers: { [OWNER_HEADER]: target.machineId } } + } + // blocked + return { + proceed: false, + headers: {}, + blockedReason: target.reason, + displayText: BLOCKED_TEXT[target.reason], + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.test.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.test.ts new file mode 100644 index 000000000..7edf6fff6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.test.ts @@ -0,0 +1,84 @@ +/** + * remote-create-preflight.test.ts — #1643 (completes #1484 AC3) + * + * The composer's pre-flight orchestration: given the picked machine, query the + * creation-target gate (and, for a remote target, the peer-home-base gate), + * then decide the create plan — proceed local, proceed remote (armed + remote + * directory), or refuse with an inline reason. Pure over injected fetchers so + * it is unit-testable without a live server. + */ +import { describe, expect, test } from "bun:test" +import { runCreatePreflight, type PreflightFetchers } from "./remote-create-preflight" +import { OWNER_HEADER } from "./remote-create-header" + +function fetchers(over?: Partial): PreflightFetchers { + return { + getCreationTarget: async (machine) => { + if (machine === "studio") return { ok: true, target: { kind: "remote", machineId: "studio" } } + if (machine === "local") return { ok: true, target: { kind: "local" } } + if (machine === "blocked") return { ok: true, target: { kind: "blocked", machineId: "blocked", reason: "no-control-grant" } } + return { ok: true, target: { kind: "local" } } + }, + getPeerHomeBase: async (machine) => { + if (machine === "studio") return { ok: true, directory: "/Users/studio/work" } + return { ok: false, reason: "no-remote-root" } + }, + ...over, + } +} + +describe("#1643 runCreatePreflight", () => { + test("no machine selected → local, proceed, no arm, no remote directory", async () => { + const r = await runCreatePreflight(undefined, fetchers()) + expect(r.proceed).toBe(true) + expect(r.ownerToArm).toBeUndefined() + expect(r.remoteDirectory).toBeUndefined() + }) + + test("local machine → local, proceed, no arm", async () => { + const r = await runCreatePreflight("local", fetchers()) + expect(r.proceed).toBe(true) + expect(r.ownerToArm).toBeUndefined() + }) + + test("remote machine with directory → proceed, arm owner, carry remote dir", async () => { + const r = await runCreatePreflight("studio", fetchers()) + expect(r.proceed).toBe(true) + expect(r.ownerToArm).toBe("studio") + expect(r.remoteDirectory).toBe("/Users/studio/work") + }) + + test("blocked target → refuse with reason, no arm", async () => { + const r = await runCreatePreflight("blocked", fetchers()) + expect(r.proceed).toBe(false) + expect(r.blockedReason).toBe("no-control-grant") + expect(r.ownerToArm).toBeUndefined() + }) + + test("remote but home-base unresolvable → refuse no-remote-root, no arm", async () => { + const r = await runCreatePreflight("studio", fetchers({ + getPeerHomeBase: async () => ({ ok: false, reason: "no-remote-root" }), + })) + expect(r.proceed).toBe(false) + expect(r.blockedReason).toBe("no-remote-root") + expect(r.ownerToArm).toBeUndefined() + }) + + test("a gate fetch that throws → refuse safely (never a silent local remote)", async () => { + const r = await runCreatePreflight("studio", fetchers({ + getCreationTarget: async () => { + throw new Error("network") + }, + })) + expect(r.proceed).toBe(false) + expect(r.ownerToArm).toBeUndefined() + expect(r.blockedReason).toBe("transport-down") + }) + + test("the owner to arm equals what the header producer would emit", async () => { + const r = await runCreatePreflight("studio", fetchers()) + // planRemoteCreate would put ownerToArm under OWNER_HEADER + expect(r.ownerToArm).toBe("studio") + expect(OWNER_HEADER).toBe("x-amicode-owner") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.ts new file mode 100644 index 000000000..cf1d161e4 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.ts @@ -0,0 +1,98 @@ +/** + * remote-create-preflight.ts — #1643 (completes #1484 AC3) + * + * The composer's pre-flight orchestration — the app-side GATE that runs BEFORE + * a session.create so a blocked remote pick surfaces its exact reason inline + * and no session is created. It composes the two service routes: + * + * 1. GET /amicode/fleet/creation-target?machine= → local | remote | blocked + * 2. for a remote target, GET /amicode/fleet/peer-home-base?machine= + * → the directory the remote create lands in (unresolvable → no-remote-root) + * + * The decision: + * - local → proceed, no arm, no remote directory (unchanged path) + * - remote + directory → proceed, arm the owner, carry the remote directory + * - blocked / no-root → refuse, carry the reason for inline display + * - any fetch error → refuse as transport-down (NEVER a silent local + * create dressed as remote — the #1382 fallback the design forbids) + * + * Pure over injected fetchers; the live composer wires them to amicodeGet. + */ + +import { planRemoteCreate, type BlockedReason, type CreationTargetView } from "./remote-create-header" + +export interface CreationTargetResponse { + ok: boolean + target?: CreationTargetView + reason?: string +} + +export interface PeerHomeBaseResponse { + ok: boolean + directory?: string + reason?: string +} + +export interface PreflightFetchers { + getCreationTarget: (machine: string) => Promise + getPeerHomeBase: (machine: string) => Promise +} + +export interface PreflightResult { + /** Whether the composer should proceed with the create. */ + proceed: boolean + /** The owner id to arm (remote target only); undefined for local. */ + ownerToArm?: string + /** The directory the remote create must use (remote target only). */ + remoteDirectory?: string + /** The blocked reason when proceed is false. */ + blockedReason?: BlockedReason + /** Human-readable inline text when blocked. */ + displayText?: string +} + +/** Run the pre-flight gate for a picked machine. No selection or a local pick + * proceeds locally with no arming. */ +export async function runCreatePreflight( + selectedMachineId: string | undefined, + fetchers: PreflightFetchers, +): Promise { + if (selectedMachineId === undefined || selectedMachineId.trim() === "") { + return { proceed: true } + } + + let target: CreationTargetView + try { + const res = await fetchers.getCreationTarget(selectedMachineId) + if (!res.ok || !res.target) { + return { proceed: false, blockedReason: "transport-down", displayText: "Couldn't check that machine." } + } + target = res.target + } catch { + // A gate we cannot reach is NOT a license to create locally under a remote + // pick — refuse as transport-down (the #1382 no-silent-fallback invariant). + return { proceed: false, blockedReason: "transport-down", displayText: "That machine is currently unreachable." } + } + + const plan = planRemoteCreate(target) + if (!plan.proceed) { + return { proceed: false, blockedReason: plan.blockedReason, displayText: plan.displayText } + } + + // Local target → proceed unchanged. + if (target.kind === "local") { + return { proceed: true } + } + + // Remote target → resolve the peer's working directory before arming. + try { + const home = await fetchers.getPeerHomeBase(target.machineId) + if (!home.ok || !home.directory) { + const noRoot = planRemoteCreate({ kind: "blocked", machineId: target.machineId, reason: "no-remote-root" }) + return { proceed: false, blockedReason: "no-remote-root", displayText: noRoot.displayText } + } + return { proceed: true, ownerToArm: target.machineId, remoteDirectory: home.directory } + } catch { + return { proceed: false, blockedReason: "transport-down", displayText: "That machine is currently unreachable." } + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/index.ts b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts index 92b194974..3ac8c2da1 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/index.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts @@ -1,4 +1,4 @@ -export { SessionHeader } from "./session-header" +export { SessionHeader, SessionComposerControlScrim } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SessionPreviewTab } from "./session-preview-tab" export { PanelMenu } from "./panel-menu" diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx index 7ac344b61..c00c3023b 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx @@ -31,6 +31,7 @@ import { PreviewEditor } from "@opencode-ai/session-ui/v2/preview-editor" import type { PreviewViewState } from "@opencode-ai/session-ui/v2/preview-view-state" import { PdfCanvasView } from "./pdf-canvas-view" import { toAbsolutePath, shouldApplyWatcherRead } from "./preview-file-helpers" +import { validateHumanWritePath } from "./preview-human-write-gate" // --------------------------------------------------------------------------- // Helpers @@ -291,6 +292,14 @@ export function PreviewFileView(props: { }) const saveFile = async (filePath: string, content: string, closeAfterSave = false) => { + // #1454 (W5): human write gate — reject a write that resolves outside the + // session workspace (the engine handler stays permissive for the agent; the + // gate is applied UPSTREAM here). Only gate when the workspace is known. + const workspace = sdk().directory + if (workspace && !validateHumanWritePath(filePath, workspace).allowed) { + setSaveStatus("idle") + return + } setSaveStatus("saving") try { await serverSDK().client.file.write({ path: filePath, content }) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-human-write-gate.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-human-write-gate.ts new file mode 100644 index 000000000..f8337ce7a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-human-write-gate.ts @@ -0,0 +1,86 @@ +/** + * preview-human-write-gate.ts — #1442 AC5, extended by #1454 (W5) + * + * Browser-safe workspace-containment gate for the human Preview / review-panel + * write path. It runs in the webview, so — like preview-file-helpers.ts — it + * uses plain string operations, NOT `node:path` (which is not available in the + * browser bundle). + * + * The gate is applied UPSTREAM of the shared engine `file.write` handler + * (`overlay/.../handlers/file.ts`), which deliberately permits absolute paths + * for the agent capability — the handler body is NEVER edited. This gate narrows + * only the HUMAN write path: + * - relative paths → resolved against the workspace root and allowed + * - absolute INSIDE the ws → normalized and allowed (Preview paths are often + * absolute: preview-file-helpers.ts:11-21) + * - anything OUTSIDE the ws → rejected (never reaches the owner) + * - empty paths → rejected + * The agent's in-process write (tool/write.ts) is out of scope and untouched. + * + * @module + */ + +import { isAbsolutePreviewPath } from "./preview-file-helpers" + +// ── types ──────────────────────────────────────────────────────────────────── + +/** The result of validating a human write path. */ +export type HumanWriteValidation = + | { allowed: true; resolvedPath: string } + | { allowed: false; reason: "path-escapes-workspace" | "empty-path" } + +// ── validation ─────────────────────────────────────────────────────────────── + +/** Collapse "." / ".." segments and unify separators to POSIX "/" — a + * browser-safe stand-in for `path.resolve`/`path.normalize`. */ +function normalizePosix(p: string): string { + const isAbs = p.startsWith("/") + const out: string[] = [] + for (const seg of p.replace(/\\/g, "/").split("/")) { + if (seg === "" || seg === ".") continue + if (seg === "..") { + out.pop() + continue + } + out.push(seg) + } + return (isAbs ? "/" : "") + out.join("/") +} + +/** Validate a path for the human Preview/review write gate. + * + * Rules: + * 1. Empty path → rejected. + * 2. Resolve to an absolute path: an already-absolute path is taken as-is; a + * relative path is joined onto the workspace root. + * 3. Normalize "." / ".." segments. + * 4. If the resolved path is inside the workspace (or equals it) → allowed with + * the normalized absolute path; otherwise → rejected as escaping the + * workspace. + * + * Unlike the engine's `file.write` handler (which allows absolute paths for + * agents), this gate accepts an absolute path ONLY when it resolves inside the + * workspace — the human path is narrowed below the raw peer token's reach. */ +export function validateHumanWritePath(filePath: string, workspace: string): HumanWriteValidation { + // Rule 1: empty path + if (!filePath || filePath.trim() === "") { + return { allowed: false, reason: "empty-path" } + } + + const ws = normalizePosix(workspace) + + // Rule 2: resolve to an absolute candidate + const candidateRaw = isAbsolutePreviewPath(filePath) + ? filePath + : `${ws.replace(/\/+$/, "")}/${filePath}` + + // Rule 3: normalize + const resolved = normalizePosix(candidateRaw) + + // Rule 4: containment — must be the workspace root or inside it + const wsWithSep = ws.endsWith("/") ? ws : ws + "/" + if (resolved === ws || resolved.startsWith(wsWithSep)) { + return { allowed: true, resolvedPath: resolved } + } + return { allowed: false, reason: "path-escapes-workspace" } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-archived-fleet.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-archived-fleet.test.ts new file mode 100644 index 000000000..21c7132fc --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-archived-fleet.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { archivedSessionsWithRemote, type DropdownSession } from "./session-fleet-peers" + +// #1647 (S3): the local Archive tab becomes fleet-aware. archivedSessionsWithRemote +// merges this machine's LOCAL archived sessions (authoritative, engine-paginated) +// with the peer-owned archived sessions from GET /amicode/fleet/sessions?archived=true. +// Standalone / fetch-failure ⇒ raw undefined ⇒ the local list is unchanged. + +const localArchived: DropdownSession[] = [ + { id: "loc-1", time: { created: 5, updated: 5, archived: 5 } } as DropdownSession, +] + +function projection(sessions: unknown[]): unknown { + return { ok: true, mode: "fleet", sessions } +} + +describe("#1647 S3 — archivedSessionsWithRemote", () => { + test("raw undefined (standalone / fetch failure) ⇒ local list unchanged", () => { + expect(archivedSessionsWithRemote(localArchived, undefined)).toEqual(localArchived) + }) + + test("remote archived peer sessions are merged in, owner-tagged", () => { + const raw = projection([ + { id: "peer-1", time: { created: 9, updated: 9, archived: 9 }, amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false } }, + ]) + const merged = archivedSessionsWithRemote(localArchived, raw) + const ids = merged.map((s) => s.id).sort() + expect(ids).toEqual(["loc-1", "peer-1"]) + const peer = merged.find((s) => s.id === "peer-1")! + expect(peer.amicode_owner?.owner_machine_id).toBe("mac-studio") + expect(peer.amicode_owner?.is_local).toBe(false) + }) + + test("a LOCAL session in the projection is not double-added (peers-only pull)", () => { + const raw = projection([ + { id: "loc-1", time: { created: 5, updated: 5, archived: 5 }, amicode_owner: { owner_machine_id: "local", owner_name: "Me", is_local: true } }, + { id: "peer-1", time: { created: 9, updated: 9, archived: 9 }, amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false } }, + ]) + const merged = archivedSessionsWithRemote(localArchived, raw) + expect(merged.map((s) => s.id).sort()).toEqual(["loc-1", "peer-1"]) + }) + + test("sorted by last activity descending (most recent first)", () => { + const raw = projection([ + { id: "peer-1", time: { created: 9, updated: 9, archived: 9 }, amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false } }, + ]) + const merged = archivedSessionsWithRemote(localArchived, raw) + expect(merged.map((s) => s.id)).toEqual(["peer-1", "loc-1"]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts index 0ac82300a..4d1625999 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts @@ -1,4 +1,9 @@ import { describe, expect, test } from "bun:test" +// #1537 B2a (AC6): the machine helpers are no longer defined here — they live in +// the ONE real module the component also imports (`session-fleet-peers.ts`). +// `deriveBadge` was a stale duplicate of the real `deriveSessionBadge`; the +// per-machine narrowing `filterSessionsByMachine` is promoted alongside it. +import { deriveSessionBadge, filterSessionsByMachine, sortDropdownSessions, type DropdownSession, type SortMode } from "./session-fleet-peers" /** * Tests for the Session Chats Dropdown logic (amicode#274). @@ -7,12 +12,25 @@ import { describe, expect, test } from "bun:test" * flyout (amicode#273). */ +// Local fixture shape for this suite (a superset-tolerant subset of the SDK +// session — `time.updated` optional, as the dropdown fixtures use it). The +// promoted helpers are structural, so they accept this shape directly. +type SessionOwnerTag = { + owner_machine_id: string + owner_name: string + device_type?: string + directory?: string + is_local: boolean +} + type Session = { id: string title?: string directory: string parentID?: string time: { created: number; updated?: number; archived?: number | null } + /** Fleet-wide owner overlay (#1439 — absent on pre-fleet sessions). */ + amicode_owner?: SessionOwnerTag } // --- Helpers under test (pure logic extracted from the component) --- @@ -232,4 +250,187 @@ describe("Session Chats Dropdown", () => { expect(action.type).toBe("navigate") // would navigate after close }) }) + + // ── fleet-wide machine badges (#1439, AC2) ───────────────────────────────── + + describe("deriveSessionBadge — machine badge for fleet sessions", () => { + const localSession: Session = { + id: "ses_local", + title: "Local work", + directory: "/proj", + time: { created: 100 }, + amicode_owner: { + owner_machine_id: "macbook-pro", + owner_name: "MacBook Pro", + device_type: "laptop", + is_local: true, + }, + } + + const remoteSession: Session = { + id: "ses_remote", + title: "Remote work", + directory: "/proj", + time: { created: 200 }, + amicode_owner: { + owner_machine_id: "mac-studio", + owner_name: "Mac Studio", + device_type: "desktop", + is_local: false, + }, + } + + const preFleetsession: Session = { + id: "ses_old", + title: "Legacy", + directory: "/proj", + time: { created: 50 }, + // no amicode_owner — pre-fleet session + } + + test("local session is unbadged (absence = local)", () => { + expect(deriveSessionBadge(localSession)).toBeUndefined() + }) + + test("remote session shows the owner machine name as badge", () => { + expect(deriveSessionBadge(remoteSession)).toBe("Mac Studio") + }) + + test("pre-fleet session (no owner tag) is unbadged", () => { + expect(deriveSessionBadge(preFleetsession)).toBeUndefined() + }) + }) + + // ── per-machine filter (#1439, AC3) ──────────────────────────────────────── + + describe("filterSessionsByMachine — per-machine narrowing", () => { + const fleetSessions: Session[] = [ + { + id: "ses_a", + title: "Local session", + directory: "/proj", + time: { created: 100 }, + amicode_owner: { owner_machine_id: "macbook", owner_name: "MacBook", is_local: true }, + }, + { + id: "ses_b", + title: "Studio session 1", + directory: "/proj", + time: { created: 200 }, + amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false }, + }, + { + id: "ses_c", + title: "Studio session 2", + directory: "/proj", + time: { created: 300 }, + amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false }, + }, + { + id: "ses_d", + title: "Mini session", + directory: "/proj", + time: { created: 400 }, + amicode_owner: { owner_machine_id: "mac-mini", owner_name: "Mac Mini", is_local: false }, + }, + ] + + test("null machineId returns all sessions (the 'all machines' state)", () => { + expect(filterSessionsByMachine(fleetSessions, null)).toHaveLength(4) + }) + + test("undefined machineId returns all sessions (the 'clear filter' state)", () => { + expect(filterSessionsByMachine(fleetSessions, undefined)).toHaveLength(4) + }) + + test("a specific machineId narrows to that machine's sessions only", () => { + const studio = filterSessionsByMachine(fleetSessions, "mac-studio") + expect(studio).toHaveLength(2) + expect(studio.every((s) => s.amicode_owner?.owner_machine_id === "mac-studio")).toBe(true) + }) + + test("the local machine's sessions are filterable by its machine_id", () => { + const local = filterSessionsByMachine(fleetSessions, "macbook") + expect(local).toHaveLength(1) + expect(local[0].id).toBe("ses_a") + }) + + test("a machineId with no sessions returns an empty list", () => { + expect(filterSessionsByMachine(fleetSessions, "nonexistent")).toHaveLength(0) + }) + + test("filter composes with filterSessionsByQuery", () => { + const getTitle = (s: Session) => s.title || s.id + const studioSessions = filterSessionsByMachine(fleetSessions, "mac-studio") + const searched = filterSessionsByQuery(studioSessions, "session 2", getTitle) + expect(searched).toHaveLength(1) + expect(searched[0].id).toBe("ses_c") + }) + }) + + // ── sort modes compose with open-tab pinning (#1599) ────────────────────── + + describe("sort modes compose with open-tab pinning", () => { + /** + * The display pipeline partitions into open-tab and rest, sorts EACH + * partition with sortDropdownSessions, then concatenates. Open-tab + * sessions must stay first regardless of which sort mode is applied. + * + * This mirrors the real pipeline in session-header.tsx. + */ + function composeSortWithPinning( + sessions: DropdownSession[], + hasOpenTab: (s: DropdownSession) => boolean, + mode: SortMode, + ): DropdownSession[] { + const openTabs: DropdownSession[] = [] + const rest: DropdownSession[] = [] + for (const s of sessions) { + if (hasOpenTab(s)) openTabs.push(s) + else rest.push(s) + } + return [ + ...sortDropdownSessions(openTabs, mode), + ...sortDropdownSessions(rest, mode), + ] + } + + const all: DropdownSession[] = [ + { id: "a", title: "Zebra", directory: "/p", time: { created: 100 } }, + { id: "b", title: "Alpha", directory: "/p", time: { created: 300 } }, + { id: "c", title: "Mid", directory: "/p", time: { created: 200 } }, + ] as DropdownSession[] + const openIds = new Set(["c"]) + const hasOpen = (s: DropdownSession) => openIds.has(s.id) + + test("alpha sort: open-tab session stays first even if title sorts later", () => { + const result = composeSortWithPinning(all, hasOpen, "alpha") + // "c" (Mid) has an open tab — it must be first + expect(result[0].id).toBe("c") + // Rest sorted alphabetically: Alpha (b), Zebra (a) + expect(result.slice(1).map((s) => s.id)).toEqual(["b", "a"]) + }) + + test("recent sort: open-tab session stays first even if older", () => { + const result = composeSortWithPinning(all, hasOpen, "recent") + expect(result[0].id).toBe("c") + // Rest sorted by recency: b (300), a (100) + expect(result.slice(1).map((s) => s.id)).toEqual(["b", "a"]) + }) + + test("machine sort: open-tab session stays first", () => { + const machineAll: DropdownSession[] = [ + { id: "r1", title: "R1", directory: "/p", time: { created: 300 }, amicode_owner: { owner_machine_id: "peer", owner_name: "Box", is_local: false } }, + { id: "loc", title: "Local", directory: "/p", time: { created: 100 } }, + { id: "r2", title: "R2", directory: "/p", time: { created: 200 }, amicode_owner: { owner_machine_id: "peer", owner_name: "Box", is_local: false } }, + ] as DropdownSession[] + const machineOpen = new Set(["r1"]) + const result = composeSortWithPinning(machineAll, (s) => machineOpen.has(s.id), "machine") + // r1 is the open tab — stays first despite being remote + expect(result[0].id).toBe("r1") + // Rest sorted by machine: local first, then remote + expect(result[1].id).toBe("loc") + expect(result[2].id).toBe("r2") + }) + }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx index 6d12fef55..3ad9f7508 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx @@ -284,7 +284,13 @@ export function SessionContextTab() { return map.get(mount) === false } - const busy = createMemo(() => (sync().data.session_status[params.id ?? ""]?.type ?? "idle") !== "idle") + // #1637: the context-tab busy memo reflects session_working (the turn-active + // floor), NOT raw status type, so a stray idle during a live turn cannot flip + // a turn to done in the context tree. + const busy = createMemo(() => { + const id = params.id + return !!id && sync().data.session_working(id) + }) const turns = createMemo(() => { const byPrompt = new Map() diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-control-projection.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-control-projection.ts new file mode 100644 index 000000000..b107fd5ad --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-control-projection.ts @@ -0,0 +1,76 @@ +/** + * session-fleet-control-projection.ts — the SHARED control-projection poll. + * + * Extracted from session-header.tsx so both the session header (composer scrim) + * and the titlebar tab strip (driving-remote icon) can consume the same + * ref-counted, single-interval poll of GET /amicode/fleet/sessions without + * importing each other. The poll runs ≈3 s + refetch on window focus, and tears + * down when the last consumer unmounts. + * + * Two hooks: + * - useSharedControlProjection() — convenience for components inside a + * (calls useServer() internally). + * - useControlProjectionForConnection(conn) — for components that have an + * explicit ServerConnection (e.g. the titlebar tab strip). + */ + +import { createResource, createRoot, createSignal, onCleanup } from "solid-js" +import { useServer, type ServerConnection } from "@/context/server" +import { amicodeGet } from "@/utils/amicode-fetch" + +const FLEET_CONTROL_POLL_MS = 3000 + +type SharedControlProjection = { latest: () => unknown; refetch: () => void } + +let sharedControlPoll: SharedControlProjection | null = null +let sharedControlDispose: (() => void) | null = null +let sharedControlRefs = 0 + +function acquireSharedControlProjection(conn: () => ServerConnection.Any | undefined): SharedControlProjection { + sharedControlRefs += 1 + if (!sharedControlPoll) { + createRoot((dispose) => { + const [tick, setTick] = createSignal(0) + const [projection, { refetch }] = createResource( + () => [conn(), tick()] as const, + ([c]) => (c ? amicodeGet(c, "/amicode/fleet/sessions?scope=all").catch(() => undefined) : undefined), + ) + const interval = setInterval(() => setTick((t) => t + 1), FLEET_CONTROL_POLL_MS) + const onFocus = () => void refetch() + if (typeof window !== "undefined") window.addEventListener("focus", onFocus) + sharedControlPoll = { latest: () => projection.latest, refetch: () => void refetch() } + sharedControlDispose = () => { + clearInterval(interval) + if (typeof window !== "undefined") window.removeEventListener("focus", onFocus) + dispose() + sharedControlPoll = null + sharedControlDispose = null + } + }) + } + return sharedControlPoll! +} + +function releaseSharedControlProjection() { + sharedControlRefs = Math.max(0, sharedControlRefs - 1) + if (sharedControlRefs === 0 && sharedControlDispose) sharedControlDispose() +} + +/** Shared accessor for components inside a context (e.g. + * SessionHeader, SessionComposerControlScrim). Acquires on mount, releases + * on cleanup — exactly one interval poll runs while any consumer is mounted. */ +export function useSharedControlProjection(): () => unknown { + const server = useServer() + const shared = acquireSharedControlProjection(() => server.current) + onCleanup(releaseSharedControlProjection) + return shared.latest +} + +/** Shared accessor for components that hold an explicit connection rather than + * a context (e.g. TitlebarTabStrip). Same ref-counted + * singleton — no duplicate polling when both hooks are live. */ +export function useControlProjectionForConnection(conn: () => ServerConnection.Any | undefined): () => unknown { + const shared = acquireSharedControlProjection(conn) + onCleanup(releaseSharedControlProjection) + return shared.latest +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts new file mode 100644 index 000000000..31f7a1312 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts @@ -0,0 +1,885 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { + peerSessionsFromProjection, + allSessionsFromProjection, + mergePeerSessions, + stabilizeSessionIdentity, + sessionRowSignature, + hideArchivedTombstones, + settleArchivedTombstones, + deriveSessionBadge, + isRemotePeerSession, + resolveDropdownOpenAction, + readSessionControl, + isControlHeld, + writeAffordanceEnabled, + failClosedChip, + controlAffordance, + drivingBanner, + drivingBannerFromProjection, + drivenByBanner, + drivenByBannerFromProjection, + remoteDeleteAction, + findSessionControlInProjection, + groupSessionsByOwner, + sortDropdownSessions, + CONTROL_CHIP_REASONS, + type DropdownSession, + type SessionControlProjection, + type SessionGroup, + type SortMode, +} from "./session-fleet-peers" + +// #1525 B1 (read-only): merge PEER sessions from the fleet projection into the +// titlebar Sessions dropdown. These pure helpers are the data layer the +// component consumes; every reader is tolerant (never throws, [] on garbage). + +const localTag = { owner_machine_id: "test-laptop", owner_name: "MacBook Pro", is_local: true } +const studioTag = { owner_machine_id: "test-desktop", owner_name: "Test Desktop", device_type: "desktop", is_local: false } + +function projection(sessions: unknown[]): unknown { + return { sessions, sources: {} } +} + +describe("#1525 peerSessionsFromProjection — keep only remote peer sessions", () => { + test("keeps a remote (is_local:false) session, coerced with title/time/owner", () => { + const raw = projection([ + { id: "ses_studio", title: "Free port 4096", directory: "/proj", time: { created: 3, updated: 7 }, amicode_owner: studioTag }, + ]) + const out = peerSessionsFromProjection(raw) + expect(out).toHaveLength(1) + expect(out[0].id).toBe("ses_studio") + expect(out[0].title).toBe("Free port 4096") + expect(out[0].time).toEqual({ created: 3, updated: 7 }) + expect(out[0].amicode_owner).toMatchObject({ owner_machine_id: "test-desktop", is_local: false }) + }) + + test("drops LOCAL-owned and UNOWNED entries (the local list already has those)", () => { + const raw = projection([ + { id: "ses_local", title: "local", time: { created: 1 }, amicode_owner: localTag }, + { id: "ses_plain", title: "plain", time: { created: 2 } }, + { id: "ses_studio", title: "remote", time: { created: 3 }, amicode_owner: studioTag }, + ]) + const out = peerSessionsFromProjection(raw) + expect(out.map((s) => s.id)).toEqual(["ses_studio"]) + }) + + test("tolerant: undefined / {} / non-array sessions / malformed rows → []", () => { + expect(peerSessionsFromProjection(undefined)).toEqual([]) + expect(peerSessionsFromProjection({})).toEqual([]) + expect(peerSessionsFromProjection({ sessions: "nope" })).toEqual([]) + expect(peerSessionsFromProjection(projection([{ no_id: true, amicode_owner: studioTag }]))).toEqual([]) + expect(peerSessionsFromProjection(projection([{ id: "x", amicode_owner: { owner_name: 1, is_local: false } }]))).toEqual([]) + }) + + test("a remote entry missing time still coerces (created defaults to 0)", () => { + const out = peerSessionsFromProjection(projection([{ id: "ses_studio", amicode_owner: studioTag }])) + expect(out).toHaveLength(1) + expect(out[0].time.created).toBe(0) + }) + + // #1646 regression: the peer row MUST carry the projection's amicode_control + // overlay. Without it, readSessionControl on the row always fell to the + // remote fail-closed default (`no-control-grant`), so an enabled `interactive` + // grant never reached the row and archive/delete stayed refused forever. + test("#1646: a peer entry's amicode_control overlay is carried onto the row", () => { + const out = peerSessionsFromProjection( + projection([ + { + id: "ses_studio", + amicode_owner: studioTag, + amicode_control: { controlState: "interactive", reason: null, eligibility: "none" }, + }, + ]), + ) + expect(out).toHaveLength(1) + expect(readSessionControl(out[0]).controlState).toBe("interactive") + }) + + test("#1646: a peer entry WITHOUT control still coerces (degrades to fail-closed at read time)", () => { + const out = peerSessionsFromProjection(projection([{ id: "ses_studio", amicode_owner: studioTag }])) + expect(out[0].amicode_control).toBeUndefined() + // No overlay on the row → readSessionControl applies the remote fail-closed default. + expect(readSessionControl(out[0]).reason).toBe("no-control-grant") + }) + + // #archive-lag follow-up: a REMOTE subagent child (parentID set) must NOT show + // as a dropdown row — the dropdown lists root sessions only (mirrors roots()). + test("drops a REMOTE child session (parentID set)", () => { + const out = peerSessionsFromProjection( + projection([ + { id: "ses_peer_root", amicode_owner: studioTag }, + { id: "ses_peer_child", parentID: "ses_peer_root", amicode_owner: studioTag }, + ]), + ) + expect(out.map((s) => s.id)).toEqual(["ses_peer_root"]) + }) +}) + +describe("#1525 deriveSessionBadge / isRemotePeerSession", () => { + test("remote session badges with the owner name", () => { + expect(deriveSessionBadge({ amicode_owner: studioTag })).toBe("Test Desktop") + expect(isRemotePeerSession({ amicode_owner: studioTag })).toBe(true) + }) + test("local and unowned sessions are unbadged / not remote", () => { + expect(deriveSessionBadge({ amicode_owner: localTag })).toBeUndefined() + expect(deriveSessionBadge({})).toBeUndefined() + expect(deriveSessionBadge(undefined)).toBeUndefined() + expect(isRemotePeerSession({ amicode_owner: localTag })).toBe(false) + expect(isRemotePeerSession(undefined)).toBe(false) + }) +}) + +describe("#1525 mergePeerSessions — dedupe (local wins) + sort by last activity", () => { + const mk = (id: string, updated: number, owner?: DropdownSession["amicode_owner"]): DropdownSession => + ({ id, directory: "/d", time: { created: 0, updated }, ...(owner ? { amicode_owner: owner } : {}) }) as DropdownSession + + test("peers append to local, sorted by updated desc", () => { + const local = [mk("a", 5), mk("b", 1)] + const peers = [mk("c", 9, studioTag), mk("d", 3, studioTag)] + expect(mergePeerSessions(local, peers).map((s) => s.id)).toEqual(["c", "a", "d", "b"]) + }) + + test("id collision: the local row wins but is enriched with the peer's owner tag", () => { + const local = [mk("dup", 5)] + const peers = [mk("dup", 99, studioTag)] + const out = mergePeerSessions(local, peers) + expect(out).toHaveLength(1) + // The local row stays (its id, its time — live state), but #1599 enriches + // it with the peer's owner overlay so the machine badge survives dedup. + expect(out[0].id).toBe("dup") + expect(out[0].time.updated).toBe(5) // local row's data, not the peer's + expect(out[0].amicode_owner).toMatchObject({ owner_machine_id: "test-desktop", is_local: false }) + }) + + test("id collision: an already-tagged local row keeps its OWN owner tag (no clobber)", () => { + const laptop = { owner_machine_id: "other", owner_name: "Other", is_local: false } + const local = [mk("dup", 5, laptop)] + const peers = [mk("dup", 99, studioTag)] + const out = mergePeerSessions(local, peers) + expect(out).toHaveLength(1) + expect(out[0].amicode_owner).toMatchObject({ owner_machine_id: "other" }) + }) + + test("empty peers → local list unchanged (degrade path)", () => { + const local = [mk("a", 5), mk("b", 9)] + expect(mergePeerSessions(local, []).map((s) => s.id)).toEqual(["b", "a"]) + }) + + // #1599 regression: opening a remote session pulls an UNTAGGED copy of it + // into the local directory store (the intended open flow — openSession calls + // projects.open + session.sync to render it). On the next dropdown compute, + // mergePeerSessions sees the local (untagged) copy AND the projection + // (tagged) copy for the same id. Previously local won outright and the badge + // vanished the moment the remote session went live. The fix enriches the + // local copy with the projection's owner tag so the badge persists. + test("#1599 regression: opening a remote session keeps its badge (local copy enriched from projection)", () => { + const liveLocalCopy = mk("ses_remote", 5) // untagged — pulled in by opening it + const fromProjection = mk("ses_remote", 5, studioTag) // authoritative owner overlay + const out = mergePeerSessions([liveLocalCopy], [fromProjection]) + expect(out).toHaveLength(1) + // Badge survives — this is the corrected behavior (was `undefined` pre-#1599). + expect(out[0].amicode_owner).toBeDefined() + expect(deriveSessionBadge(out[0])).toBe("Test Desktop") + }) + + // #1646 regression: the SAME open flow that pulls an untagged local copy also + // strips the fleet CONTROL overlay — the local copy has no control channel of + // its own. Enabling control flips the PROJECTION's copy to `interactive`, but + // pre-#1646 the merge kept the control-less local copy, so the row read + // fail-closed and archive/delete refused with "Control not enabled" even + // though control was held. The fix grafts the peer's control overlay across. + test("#1646 regression: an enabled control grant on the projection reaches the merged row", () => { + const liveLocalCopy = mk("ses_remote", 5) // untagged, control-less — pulled in by opening it + const fromProjection = { + ...mk("ses_remote", 5, studioTag), + amicode_control: { controlState: "interactive", reason: null, eligibility: "none" }, + } as DropdownSession + const out = mergePeerSessions([liveLocalCopy], [fromProjection]) + expect(out).toHaveLength(1) + // Control is HELD on the merged row (was fail-closed `read-only` pre-#1646), + // so writeAffordanceEnabled — the archive/delete gate — is true. + expect(readSessionControl(out[0]).controlState).toBe("interactive") + expect(writeAffordanceEnabled(readSessionControl(out[0]))).toBe(true) + }) + + test("#1646: a revoked/read-only grant on the projection also reaches the merged row (not just interactive)", () => { + const liveLocalCopy = mk("ses_remote", 5) + const fromProjection = { + ...mk("ses_remote", 5, studioTag), + amicode_control: { controlState: "read-only", reason: "grant-revoked", eligibility: "enable-control" }, + } as DropdownSession + const out = mergePeerSessions([liveLocalCopy], [fromProjection]) + // The projection is SoT both ways: a revocation must reach the row too. + expect(readSessionControl(out[0]).reason).toBe("grant-revoked") + expect(writeAffordanceEnabled(readSessionControl(out[0]))).toBe(false) + }) + + test("#1539 fixed: when directory store is clean, projection badge persists through dedup", () => { + // After the fix: the remote session is ONLY in the projection (with badge), + // never contaminated into the local store. The dropdown merges correctly. + const localSessions = [mk("ses_local", 5)] + const projectionPeers = [mk("ses_remote", 3, studioTag)] + const out = mergePeerSessions(localSessions, projectionPeers) + expect(out).toHaveLength(2) + const remote = out.find((s) => s.id === "ses_remote")! + expect(remote.amicode_owner).toBeDefined() + expect(remote.amicode_owner!.is_local).toBe(false) + expect(deriveSessionBadge(remote)).toBe("Test Desktop") + }) +}) + +// #1537 B2a (AC4): owner-routed OPEN. `resolveDropdownOpenAction` is the pure +// decision the dropdown's openSession() dispatches on. A REMOTE peer row must +// resolve to the owner-routed navigate (the multiplex routes reads by owner) — +// NOT the B1 "lives on " guard toast. A LOCAL row is byte-unchanged +// from B1: existing tab → select-tab; no tab → navigate. No branch produces a +// mutation/remote-write action — open is a read. +describe("#1537 resolveDropdownOpenAction — owner-routed open of a peer row", () => { + const encodePath = (dir: string, id: string) => `/${btoa(dir)}/session/${id}` + const studioTag = { owner_machine_id: "test-desktop", owner_name: "Test Desktop", is_local: false } + + const localRow: DropdownSession = { id: "ses_local", directory: "/proj", time: { created: 1 } } as DropdownSession + const remoteRow: DropdownSession = { + id: "ses_studio", + directory: "/studio-proj", + time: { created: 2 }, + amicode_owner: studioTag, + } as DropdownSession + + test("REMOTE row → owner-routed navigate to its raw directory/id (never a toast)", () => { + const action = resolveDropdownOpenAction(remoteRow, false, encodePath) + expect(action.type).toBe("navigate") + expect((action as { type: "navigate"; path: string }).path).toBe(`/${btoa("/studio-proj")}/session/ses_studio`) + }) + + test("REMOTE row with an already-open tab → select that tab (no re-navigate)", () => { + const action = resolveDropdownOpenAction(remoteRow, true, encodePath) + expect(action.type).toBe("select-tab") + expect((action as { type: "select-tab"; sessionId: string }).sessionId).toBe("ses_studio") + }) + + test("LOCAL row without a tab → navigate (byte-unchanged from B1)", () => { + const action = resolveDropdownOpenAction(localRow, false, encodePath) + expect(action.type).toBe("navigate") + expect((action as { type: "navigate"; path: string }).path).toBe(`/${btoa("/proj")}/session/ses_local`) + }) + + test("LOCAL row with an existing tab → select-tab (byte-unchanged from B1)", () => { + const action = resolveDropdownOpenAction(localRow, true, encodePath) + expect(action.type).toBe("select-tab") + expect((action as { type: "select-tab"; sessionId: string }).sessionId).toBe("ses_local") + }) + + test("no branch yields a remote-write action — the union is only navigate | select-tab", () => { + for (const row of [localRow, remoteRow]) { + for (const hasTab of [true, false]) { + expect(["navigate", "select-tab"]).toContain(resolveDropdownOpenAction(row, hasTab, encodePath).type) + } + } + }) +}) + +// ── #1544 (slice 4): the app-side control surface ───────────────────────────── +// The state channel `amicode_control` ({ controlState, reason, eligibility }) +// rides the fleet projection beside `amicode_owner` (SoT: the extension's +// remote_session_state). These pure helpers are the data layer the session +// surface consumes: the fail-closed chip, the enable/request affordance, the +// persistent driving banner, and the owner-routed remote-delete gate. Every +// reader is tolerant (defaults to `local`/no-affordance on garbage). +const ctrl = (over: Partial = {}): SessionControlProjection => ({ + controlState: "read-only", + reason: "no-control-grant", + eligibility: "enable-control", + ...over, +}) +const remoteWith = (control: SessionControlProjection, machineId = "test-desktop"): DropdownSession => + ({ + id: "ses_studio", + directory: "/studio-proj", + time: { created: 1 }, + amicode_owner: { owner_machine_id: machineId, owner_name: "Studio", is_local: false }, + amicode_control: control, + }) as unknown as DropdownSession + +describe("#1544 readSessionControl — tolerant read of amicode_control", () => { + test("absent / malformed on a LOCAL / unowned session → the local no-affordance default (never throws)", () => { + expect(readSessionControl(undefined)).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + expect(readSessionControl({} as DropdownSession)).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + expect(readSessionControl({ amicode_control: { nope: 1 } } as unknown as DropdownSession).controlState).toBe("local") + }) + test("absent / malformed on a REMOTE peer session → FAIL-CLOSED (amicode#1544 follow-up)", () => { + // A remote row with no control channel must NOT read as held — the server + // write gate denies with no-control-grant, so the client must agree (no + // silently-failing delete button; a "Control not enabled" chip instead). + const remoteOwner = { owner_machine_id: "studio", owner_name: "Studio", is_local: false as const } + const missing = readSessionControl({ amicode_owner: remoteOwner } as unknown as DropdownSession) + expect(missing).toEqual({ controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" }) + expect(writeAffordanceEnabled(missing)).toBe(false) + const malformed = readSessionControl({ + amicode_owner: remoteOwner, + amicode_control: { nope: 1 }, + } as unknown as DropdownSession) + expect(malformed.controlState).toBe("read-only") + expect(writeAffordanceEnabled(malformed)).toBe(false) + }) + test("a well-formed projection round-trips", () => { + expect(readSessionControl(remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" })))).toEqual({ + controlState: "interactive", + reason: null, + eligibility: "none", + }) + }) +}) + +// ── sortDropdownSessions (#1599) ────────────────────────────────────────────── + +describe("sortDropdownSessions", () => { + // helper: create a minimal DropdownSession fixture + function mk( + id: string, + title: string, + created: number, + updated?: number, + ownerTag?: { owner_machine_id: string; owner_name: string; is_local: boolean }, + ): DropdownSession { + return { + id, + title, + directory: "/p", + time: { created, ...(updated !== undefined ? { updated } : {}) }, + ...(ownerTag ? { amicode_owner: ownerTag } : {}), + } as DropdownSession + } + + const local = (id: string, title: string, created: number, updated?: number) => + mk(id, title, created, updated) + const remote = (id: string, title: string, created: number, owner: string, updated?: number) => + mk(id, title, created, updated, { owner_machine_id: owner, owner_name: owner, is_local: false }) + + describe("recent mode", () => { + test("sorts by updated desc, falls back to created when no updated", () => { + const sessions = [local("a", "A", 100), local("b", "B", 300), local("c", "C", 200)] + const result = sortDropdownSessions(sessions, "recent") + expect(result.map((s) => s.id)).toEqual(["b", "c", "a"]) + }) + + test("prefers updated over created when both present", () => { + const sessions = [local("a", "A", 100, 500), local("b", "B", 600, 200)] + const result = sortDropdownSessions(sessions, "recent") + expect(result.map((s) => s.id)).toEqual(["a", "b"]) + }) + + test("tie-breaks by id ascending", () => { + const sessions = [local("z", "Z", 100), local("a", "A", 100)] + const result = sortDropdownSessions(sessions, "recent") + expect(result.map((s) => s.id)).toEqual(["a", "z"]) + }) + }) + + describe("alpha mode", () => { + test("sorts by title case-insensitive ascending", () => { + const sessions = [local("1", "Zebra", 300), local("2", "alpha", 100), local("3", "Middle", 200)] + const result = sortDropdownSessions(sessions, "alpha") + expect(result.map((s) => s.id)).toEqual(["2", "3", "1"]) + }) + + test("tie-breaks by recency desc", () => { + const sessions = [local("a", "Same", 100), local("b", "Same", 300)] + const result = sortDropdownSessions(sessions, "alpha") + expect(result.map((s) => s.id)).toEqual(["b", "a"]) + }) + + test("falls back to id when title is missing", () => { + const noTitle = { id: "zzz", directory: "/p", time: { created: 100 } } as DropdownSession + const withTitle = local("aaa", "Beta", 200) + const result = sortDropdownSessions([noTitle, withTitle], "alpha") + // "Beta" < "zzz" + expect(result.map((s) => s.id)).toEqual(["aaa", "zzz"]) + }) + }) + + describe("machine mode", () => { + test("local/unowned sessions come first, then by owner_name asc", () => { + const sessions = [ + remote("r1", "R1", 300, "Studio"), + local("l1", "L1", 100), + remote("r2", "R2", 200, "Box"), + ] + const result = sortDropdownSessions(sessions, "machine") + expect(result.map((s) => s.id)).toEqual(["l1", "r2", "r1"]) + }) + + test("tie-breaks by recency within the same machine", () => { + const sessions = [ + remote("r1", "R1", 100, "Box"), + remote("r2", "R2", 300, "Box"), + ] + const result = sortDropdownSessions(sessions, "machine") + expect(result.map((s) => s.id)).toEqual(["r2", "r1"]) + }) + + test("sessions with is_local=true are treated as local", () => { + const localTagged = mk("l", "L", 100, undefined, { owner_machine_id: "me", owner_name: "Me", is_local: true }) + const remoteTagged = remote("r", "R", 200, "Peer") + const result = sortDropdownSessions([remoteTagged, localTagged], "machine") + expect(result[0].id).toBe("l") + expect(result[1].id).toBe("r") + }) + }) + + test("never mutates the input array", () => { + const sessions = [local("b", "B", 100), local("a", "A", 200)] + const original = [...sessions] + sortDropdownSessions(sessions, "alpha") + expect(sessions.map((s) => s.id)).toEqual(original.map((s) => s.id)) + }) + + test("returns empty array for empty input", () => { + expect(sortDropdownSessions([], "recent")).toEqual([]) + expect(sortDropdownSessions([], "alpha")).toEqual([]) + expect(sortDropdownSessions([], "machine")).toEqual([]) + }) +}) + +describe("#1544 write affordances gated on control held", () => { + test("local + interactive → control held → writes enabled", () => { + expect(isControlHeld(ctrl({ controlState: "local", reason: null, eligibility: "none" }))).toBe(true) + expect(isControlHeld(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBe(true) + expect(writeAffordanceEnabled(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBe(true) + }) + test("read-only + suspended → control NOT held → writes disabled", () => { + expect(isControlHeld(ctrl({ controlState: "read-only" }))).toBe(false) + expect(isControlHeld(ctrl({ controlState: "suspended", reason: "transport-down" }))).toBe(false) + expect(writeAffordanceEnabled(ctrl({ controlState: "read-only" }))).toBe(false) + }) +}) + +describe("#1544 failClosedChip — disabled-with-reason, derived from the SoT reason (never the collapsed gate reason)", () => { + test("null (no chip) when control is held (local / interactive)", () => { + expect(failClosedChip(ctrl({ controlState: "local", reason: null, eligibility: "none" }))).toBeNull() + expect(failClosedChip(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBeNull() + }) + test("EVERY one of the five SoT reasons yields a distinct, human chip label", () => { + const labels = new Set() + for (const reason of CONTROL_CHIP_REASONS) { + const controlState = reason === "transport-down" || reason === "revocation-pending" ? "suspended" : "read-only" + const chip = failClosedChip(ctrl({ controlState, reason })) + expect(chip, `reason ${reason} must produce a chip`).not.toBeNull() + expect(chip!.reason).toBe(reason) + expect(typeof chip!.label).toBe("string") + expect(chip!.label.length).toBeGreaterThan(0) + labels.add(chip!.label) + } + // revocation-pending and grant-revoked must NOT share a label (the SoT keeps + // them distinct where the write gate collapses them). + expect(failClosedChip(ctrl({ controlState: "suspended", reason: "revocation-pending" }))!.label).not.toBe( + failClosedChip(ctrl({ controlState: "read-only", reason: "grant-revoked" }))!.label, + ) + expect(labels.size).toBe(CONTROL_CHIP_REASONS.length) + }) +}) + +describe("#1544 controlAffordance — enable (self) / request (shared) / none", () => { + test("eligibility enable-control → an Enable affordance, live (not inert)", () => { + const a = controlAffordance(ctrl({ eligibility: "enable-control" })) + expect(a.kind).toBe("enable-control") + expect(a.inert).toBe(false) + expect(a.label.length).toBeGreaterThan(0) + }) + test("eligibility request-control → a Request affordance, INERT (backend is #1545)", () => { + const a = controlAffordance(ctrl({ eligibility: "request-control" })) + expect(a.kind).toBe("request-control") + expect(a.inert).toBe(true) + }) + test("eligibility none → no affordance", () => { + expect(controlAffordance(ctrl({ controlState: "interactive", reason: null, eligibility: "none" })).kind).toBe("none") + }) +}) + +describe("#1544 drivingBanner — persistent, pinned to the peer being driven", () => { + test("interactive (control held over a remote peer) → banner carries the peer machineId", () => { + const session = remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }), "test-desktop") + expect(drivingBanner(session)).toEqual({ machineId: "test-desktop" }) + }) + test("not interactive (read-only / local) → no banner", () => { + expect(drivingBanner(remoteWith(ctrl({ controlState: "read-only" })))).toBeNull() + const local = { id: "l", directory: "/d", time: { created: 1 } } as DropdownSession + expect(drivingBanner(local)).toBeNull() + }) +}) + +describe("#1544 remoteDeleteAction — owner-routed delete, gated on control (arm→confirm reused, not this gate)", () => { + test("control held → allowed + an OWNER-ROUTED request carrying the owner machineId", () => { + const session = remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }), "test-desktop") + const action = remoteDeleteAction(session) + expect(action.allowed).toBe(true) + expect(action.request).toEqual({ sessionID: "ses_studio", directory: "/studio-proj", ownerMachineId: "test-desktop" }) + }) + test("control NOT held → disallowed, no request, carries the fail-closed reason (never a live erroring button)", () => { + const session = remoteWith(ctrl({ controlState: "read-only", reason: "no-control-grant" })) + const action = remoteDeleteAction(session) + expect(action.allowed).toBe(false) + expect(action.request).toBeUndefined() + expect(action.reason).toBe("no-control-grant") + }) +}) + +describe("#1544 findSessionControlInProjection — the current session's control off the fleet projection", () => { + const raw = { + sessions: [ + { id: "ses_local", time: { created: 1 }, amicode_owner: { owner_machine_id: "me", owner_name: "Me", is_local: true }, amicode_control: { controlState: "local", reason: null, eligibility: "none" } }, + { id: "ses_studio", time: { created: 2 }, amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, amicode_control: { controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" } }, + ], + } + test("finds a remote entry's control by id", () => { + expect(findSessionControlInProjection(raw, "ses_studio")).toEqual({ + controlState: "read-only", + reason: "no-control-grant", + eligibility: "enable-control", + }) + }) + test("unknown id / garbage → the local default (never throws)", () => { + expect(findSessionControlInProjection(raw, "nope")).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + expect(findSessionControlInProjection(undefined, "x")).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + }) + + test("drivingBannerFromProjection lights the banner for a driven (interactive) current session", () => { + const driving = { + sessions: [ + { id: "ses_studio", amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, amicode_control: { controlState: "interactive", reason: null, eligibility: "none" } }, + ], + } + expect(drivingBannerFromProjection(driving, "ses_studio")).toEqual({ machineId: "studio" }) + // read-only current session → no banner; unknown id → no banner + expect(drivingBannerFromProjection(raw, "ses_studio")).toBeNull() + expect(drivingBannerFromProjection(driving, "nope")).toBeNull() + expect(drivingBannerFromProjection(undefined, "x")).toBeNull() + }) +}) + +// ── #1562-followup (Slice C): GROUP the dropdown by owner machine ───────────── +// The dropdown merged peer + local sessions and sorted by recency, so a single +// Studio session sat at the bottom under ~100 local ones — effectively +// invisible. The projection data is correct; this is PRESENTATION. groupSessions- +// ByOwner is the pure decision: the LOCAL / unowned rows first (one unlabeled +// group, order preserved), then ONE labeled group per peer machine (grouped by +// owner_machine_id, labeled with the owner name), in first-seen order. Read-only +// — no row is dropped or reordered within a group. +describe("#1562 groupSessionsByOwner — local first, then a labeled group per peer machine", () => { + const laptopTag = { owner_machine_id: "test-laptop", owner_name: "MacBook Pro", is_local: true } + const studio = { owner_machine_id: "test-desktop", owner_name: "Test Desktop", is_local: false } + const tower = { owner_machine_id: "lab-tower", owner_name: "Lab Tower", is_local: false } + const mk = (id: string, owner?: DropdownSession["amicode_owner"]): DropdownSession => + ({ id, directory: "/d", time: { created: 0, updated: 0 }, ...(owner ? { amicode_owner: owner } : {}) }) as DropdownSession + + test("local + unowned first (one UNLABELED group), then one LABELED group per peer machine", () => { + const groups: SessionGroup[] = groupSessionsByOwner([ + mk("a"), + mk("b", laptopTag), + mk("s1", studio), + mk("c"), + mk("s2", studio), + mk("t1", tower), + ]) + expect(groups).toHaveLength(3) + // group 0 = local / unowned, no machineId, no label, input order preserved + expect(groups[0].machineId).toBeNull() + expect(groups[0].label).toBeUndefined() + expect(groups[0].sessions.map((s) => s.id)).toEqual(["a", "b", "c"]) + // then one labeled group per peer machine, FIRST-SEEN order (studio before tower) + expect(groups[1].machineId).toBe("test-desktop") + expect(groups[1].label).toBe("Test Desktop") + expect(groups[1].sessions.map((s) => s.id)).toEqual(["s1", "s2"]) + expect(groups[2].machineId).toBe("lab-tower") + expect(groups[2].label).toBe("Lab Tower") + expect(groups[2].sessions.map((s) => s.id)).toEqual(["t1"]) + }) + + test("empty peers → JUST the local list (one group, machineId null)", () => { + const groups = groupSessionsByOwner([mk("a"), mk("b", laptopTag)]) + expect(groups).toHaveLength(1) + expect(groups[0].machineId).toBeNull() + expect(groups[0].sessions.map((s) => s.id)).toEqual(["a", "b"]) + }) + + test("empty input → a single empty local group (never blank / never throws)", () => { + const groups = groupSessionsByOwner([]) + expect(groups).toHaveLength(1) + expect(groups[0].machineId).toBeNull() + expect(groups[0].sessions).toEqual([]) + }) + + test("peer order is FIRST-SEEN; rows within a peer group keep input (recency) order", () => { + const groups = groupSessionsByOwner([mk("t1", tower), mk("s1", studio), mk("t2", tower)]) + expect(groups.map((g) => g.machineId)).toEqual([null, "lab-tower", "test-desktop"]) + expect(groups[1].sessions.map((s) => s.id)).toEqual(["t1", "t2"]) + expect(groups[2].sessions.map((s) => s.id)).toEqual(["s1"]) + }) + + test("only-peer input → an empty local group followed by the labeled peer group(s)", () => { + const groups = groupSessionsByOwner([mk("s1", studio)]) + expect(groups).toHaveLength(2) + expect(groups[0].machineId).toBeNull() + expect(groups[0].sessions).toEqual([]) + expect(groups[1].machineId).toBe("test-desktop") + expect(groups[1].sessions.map((s) => s.id)).toEqual(["s1"]) + }) +}) + +// #1599: the dropdown now renders a FLAT list with per-row machine badges +// (no per-machine group headers). Source-assertion confirms the old grouping +// code was removed and the sort utility is wired in. +describe("#1599 the dropdown renders a flat sorted list (no machine group headers)", () => { + const headerSource = readFileSync(resolve(__dirname, "session-header.tsx"), "utf8") + test("the dropdown uses sortDropdownSessions and does NOT group by owner", () => { + expect(headerSource).toContain("sortDropdownSessions(") + expect(headerSource).not.toContain("groupSessionsByOwner(") + expect(headerSource).not.toContain('data-slot="session-group-label"') + }) +}) + +// ── #1568: drivenByBanner — presence indicator for locally-owned sessions +// being remotely controlled. The REVERSE of drivingBanner: a local session +// whose projection entry carries `amicode_controlled_by` shows who is driving. +describe("#1568 drivenByBanner — presence indicator for a locally-controlled session", () => { + test("local session with amicode_controlled_by → banner carries the controller machine info", () => { + const session = { + id: "ses_local", + directory: "/proj", + time: { created: 1 }, + amicode_owner: localTag, + amicode_controlled_by: { machine_id: "peer-studio", machine_name: "Mac Studio" }, + } as DropdownSession + expect(drivenByBanner(session)).toEqual({ machineId: "peer-studio", machineName: "Mac Studio" }) + }) + + test("local session without amicode_controlled_by → no banner", () => { + const session = { + id: "ses_local", + directory: "/proj", + time: { created: 1 }, + amicode_owner: localTag, + } as DropdownSession + expect(drivenByBanner(session)).toBeNull() + }) + + test("remote session with amicode_controlled_by → no banner (only local sessions show it)", () => { + const session = { + id: "ses_remote", + directory: "/proj", + time: { created: 1 }, + amicode_owner: studioTag, + amicode_controlled_by: { machine_id: "peer-x", machine_name: "X" }, + } as DropdownSession + expect(drivenByBanner(session)).toBeNull() + }) + + test("undefined / garbage → no banner (tolerant, never throws)", () => { + expect(drivenByBanner(undefined)).toBeNull() + expect(drivenByBanner({} as DropdownSession)).toBeNull() + }) + + test("drivenByBannerFromProjection reads the overlay off the fleet projection by session id", () => { + const raw = { + sessions: [ + { + id: "ses_local", + time: { created: 1 }, + amicode_owner: { owner_machine_id: "me", owner_name: "Me", is_local: true }, + amicode_controlled_by: { machine_id: "studio", machine_name: "Mac Studio" }, + }, + { + id: "ses_no_control", + time: { created: 2 }, + amicode_owner: { owner_machine_id: "me", owner_name: "Me", is_local: true }, + }, + ], + } + expect(drivenByBannerFromProjection(raw, "ses_local")).toEqual({ machineId: "studio", machineName: "Mac Studio" }) + expect(drivenByBannerFromProjection(raw, "ses_no_control")).toBeNull() + expect(drivenByBannerFromProjection(raw, "unknown")).toBeNull() + expect(drivenByBannerFromProjection(undefined, "x")).toBeNull() + }) +}) + +// The Studio dropdown must show LOCAL sessions too — including local sessions in +// directories the local store never iterates (a remote-created session that +// landed in the owner's ambient temp cwd, or any project not in projects.list()). +// peerSessionsFromProjection drops all is_local rows on the assumption "the local +// list already carries those" — false for an unlisted-directory session, so such a +// session was invisible on the owning machine's dropdown. allSessionsFromProjection +// keeps local AND remote rows; mergePeerSessions then dedupes (local store wins), +// so listed-dir sessions are unchanged and only the missing ones are surfaced. +describe("allSessionsFromProjection — local + remote (the Studio-dropdown fix)", () => { + test("keeps BOTH local and remote rows (unlike peerSessionsFromProjection)", () => { + const raw = projection([ + { id: "ses_local_temp", time: { created: 2 }, title: "Testing greeting", directory: "/var/folders/T/engine-x", amicode_owner: localTag }, + { id: "ses_remote", time: { created: 1 }, title: "Peer one", amicode_owner: studioTag }, + ]) + const out = allSessionsFromProjection(raw) + const ids = out.map((s) => s.id).sort() + expect(ids).toEqual(["ses_local_temp", "ses_remote"]) + }) + + test("surfaces a local temp-dir session the local store list is missing", () => { + // The local store list only has sessions from listed project dirs. + const localList: DropdownSession[] = [ + { id: "ses_in_project", time: { created: 5 }, directory: "/Users/jj/harmoniqs/amicode" } as DropdownSession, + ] + // The projection (cross-project) carries that one PLUS the temp-dir local one. + const raw = projection([ + { id: "ses_in_project", time: { created: 5 }, directory: "/Users/jj/harmoniqs/amicode", amicode_owner: localTag }, + { id: "ses_local_temp", time: { created: 9 }, title: "Testing greeting", directory: "/var/folders/T/engine-x", amicode_owner: localTag }, + ]) + const merged = mergePeerSessions(localList, allSessionsFromProjection(raw)) + expect(merged.map((s) => s.id)).toContain("ses_local_temp") + }) + + test("tolerant: garbage/empty ⇒ []", () => { + expect(allSessionsFromProjection(undefined)).toEqual([]) + expect(allSessionsFromProjection({})).toEqual([]) + expect(allSessionsFromProjection({ sessions: "nope" })).toEqual([]) + }) + + // #archive-lag follow-up: the dropdown is a ROOT-session list. The fleet + // projection carries subagent CHILD sessions (parentID set — @explore / + // @implementer / critic sub-sessions); without filtering them they flooded the + // dropdown (76 of 100 sessions were children) and bloated every merge/render. + // Mirror the local path's roots() filter. + test("drops CHILD sessions (parentID set) — mirrors roots()", () => { + const raw = projection([ + { id: "ses_root", time: { created: 2 }, title: "Root", directory: "/p", amicode_owner: localTag }, + { id: "ses_child", time: { created: 3 }, title: "Lane A (@explore subagent)", directory: "/p", parentID: "ses_root", amicode_owner: localTag }, + { id: "ses_remote_child", time: { created: 4 }, title: "slice (@implementer)", parentID: "ses_x", amicode_owner: studioTag }, + ]) + const ids = allSessionsFromProjection(raw).map((s) => s.id) + expect(ids).toEqual(["ses_root"]) + expect(ids).not.toContain("ses_child") + expect(ids).not.toContain("ses_remote_child") + }) +}) + +// #archive-lag ROOT CAUSE (measured): the dropdown merges the CACHED fleet +// projection, which lists LOCAL sessions too (allSessionsFromProjection). Once a +// local archive removes the row from the local store, mergePeerSessions re-fills +// it from the stale projection until the next 3s poll lands — the "couple of +// seconds" lag. Archive tombstones hide the id immediately and retire only once a +// FRESH projection stops listing it (or on unarchive / failure / TTL). +describe("#archive-lag archive tombstones — hide immediately, settle on a fresh projection", () => { + const row = (id: string, created: number, extra: Partial = {}): DropdownSession => + ({ id, title: id, directory: "/d", time: { created }, ...extra }) as DropdownSession + + test("a tombstoned id is hidden even when the stale projection re-surfaces it", () => { + // The local store has ALREADY dropped ses_archived (splice); the cached + // projection still lists it as active, so the merge alone resurrects it. + const local = [row("ses_keep", 2)] + const staleProjection = projection([ + { id: "ses_keep", time: { created: 2 }, directory: "/d", amicode_owner: localTag }, + { id: "ses_archived", time: { created: 3 }, directory: "/d", amicode_owner: localTag }, + ]) + const merged = mergePeerSessions(local, allSessionsFromProjection(staleProjection)) + expect(merged.map((s) => s.id)).toContain("ses_archived") // the bug, reproduced + const shown = hideArchivedTombstones(merged, new Set(["ses_archived"])) + expect(shown.map((s) => s.id)).toEqual(["ses_keep"]) + }) + + test("hides REMOTE rows too (one optimistic path for local + remote)", () => { + const merged = [row("ses_local", 2), row("ses_peer", 1, { amicode_owner: studioTag })] + expect(hideArchivedTombstones(merged, new Set(["ses_peer"])).map((s) => s.id)).toEqual(["ses_local"]) + }) + + test("no tombstones ⇒ the SAME array (no needless downstream re-render)", () => { + const merged = [row("ses_a", 1)] + expect(hideArchivedTombstones(merged, new Set())).toBe(merged) + }) + + test("a fresh projection that no longer lists the id retires its tombstone", () => { + const fresh = projection([{ id: "ses_keep", time: { created: 2 }, amicode_owner: localTag }]) + const next = settleArchivedTombstones(new Set(["ses_archived"]), fresh) + expect([...next]).toEqual([]) + }) + + test("a STALE projection (in flight across the PATCH) still listing the id keeps it hidden", () => { + const tombstones = new Set(["ses_archived", "ses_other"]) + const stale = projection([ + { id: "ses_archived", time: { created: 3 }, amicode_owner: localTag }, + { id: "ses_other", time: { created: 4 }, amicode_owner: studioTag }, + ]) + // nothing settled ⇒ the SAME set back (a signal write is a no-op) + expect(settleArchivedTombstones(tombstones, stale)).toBe(tombstones) + }) + + test("settles only the confirmed ids, keeps the rest", () => { + const fresh = projection([{ id: "ses_still_listed", time: { created: 1 }, amicode_owner: localTag }]) + const next = settleArchivedTombstones(new Set(["ses_gone", "ses_still_listed"]), fresh) + expect([...next]).toEqual(["ses_still_listed"]) + }) + + test("absent / errored / garbage projection settles NOTHING (keep hiding; TTL backstops)", () => { + const tombstones = new Set(["ses_archived"]) + expect(settleArchivedTombstones(tombstones, undefined)).toBe(tombstones) + expect(settleArchivedTombstones(tombstones, {})).toBe(tombstones) + expect(settleArchivedTombstones(tombstones, { sessions: "nope" })).toBe(tombstones) + }) +}) + +// amicode#1652: the identity cache that stops remounting (and flashing) a +// tagged row on every 3s fleet poll. `mergePeerSessions` mints new objects each +// poll; `stabilizeSessionIdentity` returns the PREVIOUS object when a row's +// render signature is unchanged, so the reference (and its DOM node) is stable. +describe("stabilizeSessionIdentity — stable row references across polls (#1652)", () => { + const row = (id: string, updated: number, extra: Partial = {}): DropdownSession => + ({ id, title: id, directory: "/d", time: { created: 0, updated }, ...extra }) as DropdownSession + + test("an unchanged row keeps the SAME object reference across two emits", () => { + const cache = new Map() + const first = [row("ses_a", 1, { amicode_owner: studioTag })] + const out1 = stabilizeSessionIdentity(first, cache) + // A fresh object with identical render-relevant content (as mergePeerSessions + // would mint on the next poll) — different reference, same signature. + const second = [row("ses_a", 1, { amicode_owner: { ...studioTag } })] + const out2 = stabilizeSessionIdentity(second, cache) + expect(out2[0]).toBe(out1[0]) // SAME reference → preserves the DOM node + expect(second[0]).not.toBe(out2[0]) // the fresh input object was discarded + }) + + test("a genuine content change (title/owner/control) allocates a NEW reference", () => { + const cache = new Map() + const out1 = stabilizeSessionIdentity([row("ses_a", 1, { title: "old" })], cache) + const changed = row("ses_a", 1, { title: "new" }) + const out2 = stabilizeSessionIdentity([changed], cache) + expect(out2[0]).toBe(changed) // content changed → the new object is used + expect(out2[0]).not.toBe(out1[0]) + }) + + test("time.updated churn alone does NOT re-key (streaming tokens must not remount)", () => { + const cache = new Map() + const out1 = stabilizeSessionIdentity([row("ses_a", 1)], cache) + const out2 = stabilizeSessionIdentity([row("ses_a", 99999)], cache) // only updated moved + expect(out2[0]).toBe(out1[0]) + }) + + test("preserves order and evicts ids that leave the list (no unbounded growth)", () => { + const cache = new Map() + stabilizeSessionIdentity([row("ses_a", 1), row("ses_b", 2), row("ses_c", 3)], cache) + expect(cache.size).toBe(3) + const out = stabilizeSessionIdentity([row("ses_c", 3), row("ses_a", 1)], cache) + expect(out.map((s) => s.id)).toEqual(["ses_c", "ses_a"]) // order follows input + expect(cache.has("ses_b")).toBe(false) // evicted + expect(cache.size).toBe(2) + }) + + test("sessionRowSignature ignores time.updated but tracks owner + control", () => { + const base = row("ses_a", 1, { amicode_owner: studioTag }) + expect(sessionRowSignature(base)).toBe(sessionRowSignature(row("ses_a", 12345, { amicode_owner: studioTag }))) + expect(sessionRowSignature(base)).not.toBe(sessionRowSignature(row("ses_a", 1, { amicode_owner: localTag }))) + const withControl = row("ses_a", 1, { + amicode_owner: studioTag, + amicode_control: { controlState: "interactive", reason: null, eligibility: "none" }, + }) + expect(sessionRowSignature(withControl)).not.toBe(sessionRowSignature(base)) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts new file mode 100644 index 000000000..0e645e6f6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts @@ -0,0 +1,684 @@ +/** + * session-fleet-peers.ts — #1525 B1 (read-only) + * + * Pure logic for merging PEER sessions from the fleet-wide projection + * (GET /amicode/fleet/sessions, W2 #1447) into the titlebar Sessions dropdown. + * + * B1 scope is READ-ONLY: a peer's sessions APPEAR in the dropdown, badged with + * the owner machine name, searchable, and de-duped against the local list. + * Owner-routed OPEN and remote control (prompt / archive / delete) are B2 — + * this module deliberately carries no control surface. `isRemotePeerSession` + * is the predicate the component uses to keep destructive/local-only actions + * off a remote row. + * + * The projection's session entries carry the FULL raw session fields (id, + * title, time, directory) PLUS the `amicode_owner` overlay + * (merged_projection.ts `tagSessionsWithOwner`), so a remote entry is directly + * renderable as a dropdown row. Every reader here is TOLERANT: a malformed / + * absent / errored response yields [] and never throws (the dropdown must + * degrade to the local list, never blank out). + */ + +import type { Session } from "@opencode-ai/sdk/v2/client" + +/** The per-session owner overlay the projection tags each entry with + * (mirrors merged_projection.ts `SessionOwnerTag`). */ +export interface SessionOwnerTag { + owner_machine_id: string + owner_name: string + device_type?: string + directory?: string + is_local: boolean +} + +/** #1568: the presence indicator overlay — which remote machine is controlling + * this (local) session. Stamped on LOCAL session entries only. */ +export interface ControlledByTag { + machine_id: string + machine_name: string +} + +/** A dropdown session row — the SDK session shape plus the optional owner + * overlay a peer entry carries. */ +export type DropdownSession = Session & { + amicode_owner?: SessionOwnerTag + /** #1544 (slice 4): the state channel — the app-visible control shape the + * fleet projection stamps beside `amicode_owner` (see SessionControlProjection). */ + amicode_control?: SessionControlProjection + /** #1568: the presence indicator — which remote machine is controlling this + * local session, if any. Absent on remote sessions and when no control grant + * is active. */ + amicode_controlled_by?: ControlledByTag +} + +// ── #1544 (slice 4): the CONTROL STATE CHANNEL (mirror of the extension's +// remote_session_state.SessionControlProjection, carried on GET +// /amicode/fleet/sessions as the `amicode_control` sibling of `amicode_owner`). +// +// This module is the SINGLE consumer of that channel on the session surface: +// the fail-closed chip, the enable/request affordance, the persistent driving +// banner, and the owner-routed remote-delete gate all derive from it. The chip +// reason is the SoT reason (the extension's write gate collapses +// revocation-pending→grant-revoked; this channel keeps them distinct — ADR 0034 +// D4/D5), so the UI never reads the raw gate reason. + +/** EXACTLY the five reasons the SoT emits — mirrors the extension's + * CONTROL_CHIP_REASONS (kept in sync as a literal, the same seam-crossing + * precedent SessionOwnerTag sets). */ +export const CONTROL_CHIP_REASONS = [ + "no-control-grant", + "grant-revoked", + "revocation-pending", + "insufficient-scope", + "transport-down", +] as const + +export type ControlChipReason = (typeof CONTROL_CHIP_REASONS)[number] + +/** Which control affordance should appear. */ +export type ControlEligibility = "enable-control" | "request-control" | "none" + +/** The app-visible control shape (mirror of the extension's projection). */ +export interface SessionControlProjection { + controlState: "local" | "interactive" | "read-only" | "suspended" + reason: ControlChipReason | null + eligibility: ControlEligibility +} + +const CONTROL_STATES = new Set(["local", "interactive", "read-only", "suspended"]) +const CONTROL_REASONS = new Set(CONTROL_CHIP_REASONS) + +/** The default (control-held, no-affordance) projection — a local/unowned or + * malformed session degrades to this, never to a live erroring affordance. */ +const CONTROL_LOCAL_DEFAULT: SessionControlProjection = { controlState: "local", reason: null, eligibility: "none" } + +/** Human, DISTINCT chip labels — one per SoT reason. revocation-pending and + * grant-revoked are deliberately different (the write gate collapses them; the + * chip must not). */ +const CONTROL_CHIP_LABELS: Record = { + "no-control-grant": "Control not enabled", + "grant-revoked": "Control revoked", + "revocation-pending": "Revoking…", + "insufficient-scope": "Observe only", + "transport-down": "Peer unreachable", +} + +/** The fail-closed default for a REMOTE peer session whose control channel is + * absent/malformed. A missing channel must NOT read as "control held" on a + * remote row — the server-side write gate (evaluateRemoteWriteGate) denies with + * `no-control-grant` in exactly that case, so defaulting the client to + * `local` (held) produced a delete button that silently failed against the + * owner (amicode#1544 follow-up). Fail-closed keeps the client honest: no + * live-erroring write button, a "Control not enabled" chip instead. */ +const CONTROL_REMOTE_FAIL_CLOSED: SessionControlProjection = { + controlState: "read-only", + reason: "no-control-grant", + eligibility: "enable-control", +} + +/** Tolerant read of the control channel off a session. Absent / malformed → + * a default that DEPENDS on ownership: a local/unowned session degrades to the + * held no-affordance default (you own it); a REMOTE peer session degrades to + * fail-closed (you do NOT hold control until the channel says so), matching the + * server write gate. Never throws. */ +export function readSessionControl( + session: { amicode_control?: unknown; amicode_owner?: SessionOwnerTag } | undefined, +): SessionControlProjection { + const missingDefault = isRemotePeerSession(session) ? CONTROL_REMOTE_FAIL_CLOSED : CONTROL_LOCAL_DEFAULT + const raw = session?.amicode_control + if (!raw || typeof raw !== "object") return missingDefault + const o = raw as Record + if (typeof o.controlState !== "string" || !CONTROL_STATES.has(o.controlState)) return missingDefault + const reason = typeof o.reason === "string" && CONTROL_REASONS.has(o.reason) ? (o.reason as ControlChipReason) : null + const eligibility = + o.eligibility === "enable-control" || o.eligibility === "request-control" ? o.eligibility : "none" + return { controlState: o.controlState as SessionControlProjection["controlState"], reason, eligibility } +} + +/** Control is HELD when the session is local or interactively controlled. */ +export function isControlHeld(control: SessionControlProjection): boolean { + return control.controlState === "local" || control.controlState === "interactive" +} + +/** Write affordances (composer→peer, archive, delete) are enabled ONLY under + * held control — otherwise disabled with a reason chip (never a live 500). */ +export function writeAffordanceEnabled(control: SessionControlProjection): boolean { + return isControlHeld(control) +} + +/** The composer scrim's GATE decision, pure: a session is gated (composer + * blurred + made inert, CTA centered) IFF it is a REMOTE peer session whose + * control is NOT held (read-only / suspended). A local / unowned session, or a + * remote one already driving (interactive → control held), is NOT gated. This + * is the single home for the ungate-on-interactive rule the composer scrim and + * its test both read, so a projection that flips to `interactive` provably + * clears the scrim. */ +export function isComposerGated( + owner: SessionOwnerTag | undefined, + control: SessionControlProjection, +): boolean { + return isRemotePeerSession({ amicode_owner: owner }) && !isControlHeld(control) +} + +/** The fail-closed chip: disabled-with-reason when control is not held. Null + * when control is held (no chip). The label is derived from the SoT reason. */ +export function failClosedChip(control: SessionControlProjection): { reason: ControlChipReason; label: string } | null { + if (isControlHeld(control) || control.reason === null) return null + return { reason: control.reason, label: CONTROL_CHIP_LABELS[control.reason] } +} + +/** The enable/request affordance derived from eligibility. `request-control` is + * present-but-INERT here — its backend (the request→approve handshake) is + * #1545; `enable-control` is live (the self-owned one-act enable). */ +export function controlAffordance(control: SessionControlProjection): { + kind: ControlEligibility + label: string + inert: boolean +} { + if (control.eligibility === "enable-control") return { kind: "enable-control", label: "Enable control", inert: false } + if (control.eligibility === "request-control") return { kind: "request-control", label: "Request control", inert: true } + return { kind: "none", label: "", inert: true } +} + +/** The persistent driving banner's target: the peer machineId being driven, + * or null when not interactively driving a peer. Read from the owner overlay + * (the state channel carries no machineId — the owner tag does). */ +export function drivingBanner( + session: { amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined, +): { machineId: string } | null { + const control = readSessionControl(session) + if (control.controlState !== "interactive") return null + const machineId = session?.amicode_owner?.owner_machine_id + if (!machineId) return null + return { machineId } +} + +/** The owner-routed remote-delete action, GATED on held control. When allowed, + * the request carries the owner machineId so the caller (and a reviewer) can + * see it is owner-routed — the #1542 write plane resolves the non-GET to the + * peer-owned session by pathname; this descriptor names the owner it targets. + * When control is not held, it is disallowed and carries the fail-closed reason + * (never a live erroring button). */ +export function remoteDeleteAction( + session: { id: string; directory?: string; amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined, +): { allowed: boolean; request?: { sessionID: string; directory: string; ownerMachineId: string }; reason?: ControlChipReason } { + const control = readSessionControl(session) + if (!writeAffordanceEnabled(control) || !session) { + return { allowed: false, ...(control.reason ? { reason: control.reason } : {}) } + } + const ownerMachineId = session.amicode_owner?.owner_machine_id ?? "" + return { + allowed: true, + request: { sessionID: session.id, directory: session.directory ?? "", ownerMachineId }, + } +} + +/** Find a session's control off a raw GET /amicode/fleet/sessions response by + * id — the session surface's read for the CURRENT session's banner/affordance. + * Unknown id / garbage → the local default (never throws). */ +export function findSessionControlInProjection(raw: unknown, sessionId: string): SessionControlProjection { + return readSessionControl(findSessionEntryInProjection(raw, sessionId)) +} + +/** Find a raw projection entry by id (owner + control overlays intact), or + * undefined. Tolerant. */ +function findSessionEntryInProjection( + raw: unknown, + sessionId: string, +): { amicode_owner?: SessionOwnerTag; amicode_control?: unknown; amicode_controlled_by?: unknown } | undefined { + if (!raw || typeof raw !== "object") return undefined + const sessions = (raw as { sessions?: unknown }).sessions + if (!Array.isArray(sessions)) return undefined + const hit = sessions.find((s) => s && typeof s === "object" && (s as { id?: unknown }).id === sessionId) + return hit as { amicode_owner?: SessionOwnerTag; amicode_control?: unknown; amicode_controlled_by?: unknown } | undefined +} + +/** The driving banner for the CURRENT session id, read off the fleet projection. + * Non-null only when that session is interactively driving a remote peer — the + * persistent "driving " banner's data source. Unknown id / not-driving / + * garbage → null (no banner). */ +export function drivingBannerFromProjection(raw: unknown, sessionId: string): { machineId: string } | null { + return drivingBanner(findSessionEntryInProjection(raw, sessionId)) +} + +/** #1568: the REVERSE of drivingBanner — presence indicator for a locally-owned + * session being remotely controlled. Non-null only when the session is LOCAL + * (is_local === true) AND carries `amicode_controlled_by` (a remote machine + * holds an active control grant targeting this machine). Remote sessions never + * show this (they are the controller's sessions, not ours). */ +export function drivenByBanner( + session: { amicode_owner?: SessionOwnerTag; amicode_controlled_by?: unknown } | undefined, +): { machineId: string; machineName: string } | null { + if (!session) return null + // Only local sessions can be "driven by" a remote controller + if (!session.amicode_owner || session.amicode_owner.is_local === false) return null + const cb = session.amicode_controlled_by + if (!cb || typeof cb !== "object") return null + const o = cb as Record + if (typeof o.machine_id !== "string" || typeof o.machine_name !== "string") return null + return { machineId: o.machine_id, machineName: o.machine_name } +} + +/** #1568: the driven-by banner for the CURRENT session id, read off the fleet + * projection. Non-null only when that session is a LOCAL session being remotely + * controlled. Unknown id / no controller / garbage → null. */ +export function drivenByBannerFromProjection(raw: unknown, sessionId: string): { machineId: string; machineName: string } | null { + return drivenByBanner(findSessionEntryInProjection(raw, sessionId)) +} + +/** The owner tag for the CURRENT session id, read off the fleet projection — the + * composer scrim's source for the peer NAME (the CTA copy) and the owner + * machineId (the enable-control envelope's target). Unknown id / local / + * garbage → undefined. Tolerant (never throws). */ +export function findSessionOwnerInProjection(raw: unknown, sessionId: string): SessionOwnerTag | undefined { + return readOwnerTag(findSessionEntryInProjection(raw, sessionId)?.amicode_owner) +} + +/** True when a session is a REMOTE peer session (has an owner overlay whose + * `is_local` is explicitly false). Local / unowned sessions are false — + * absence of an overlay means local (ADR 0031 §D6). B1 uses this to keep + * local-only actions (archive) and the local open OFF a remote row. */ +export function isRemotePeerSession(session: { amicode_owner?: SessionOwnerTag } | undefined): boolean { + return !!session?.amicode_owner && session.amicode_owner.is_local === false +} + +/** The machine badge for a dropdown row: the owner machine name for a remote + * session, `undefined` for a local/unowned one (absence = local, unbadged). */ +export function deriveSessionBadge(session: { amicode_owner?: SessionOwnerTag } | undefined): string | undefined { + if (!isRemotePeerSession(session)) return undefined + return session!.amicode_owner!.owner_name +} + +/** Filter dropdown rows to a specific owner machine (#1439, promoted here in + * #1537 B2a AC6 as the single real home for the dropdown's machine helpers). + * `null`/`undefined` machineId = all machines (the "clear filter" state). */ +export function filterSessionsByMachine( + sessions: T[], + machineId: string | null | undefined, +): T[] { + if (machineId == null) return sessions + return sessions.filter((s) => s.amicode_owner?.owner_machine_id === machineId) +} + +/** A grouped block of dropdown rows for display (#1562): either the LOCAL / + * unowned sessions (machineId null, unlabeled) or ONE peer machine's sessions + * (machineId set, labeled with the owner name). */ +export interface SessionGroup { + /** null for the local / unowned group; the owner machineId for a peer group. */ + machineId: string | null + /** The owner machine name for a peer group; undefined for the local group. */ + label?: string + sessions: DropdownSession[] +} + +/** Group dropdown rows so a peer's sessions are not buried by recency under the + * local list (#1562): the LOCAL / unowned rows first as ONE unlabeled group + * (input order preserved — the caller's recency / open-tab-first ordering), + * then ONE labeled group per peer machine (by owner_machine_id), in first-seen + * order, each labeled with the owner name. Read-only presentation — no row is + * dropped or reordered within a group. The local group is ALWAYS present + * (possibly empty) so the caller renders its empty-state uniformly. Reuses + * filterSessionsByMachine + deriveSessionBadge. */ +export function groupSessionsByOwner(sessions: DropdownSession[]): SessionGroup[] { + const local = sessions.filter((s) => !isRemotePeerSession(s)) + const peers = sessions.filter((s) => isRemotePeerSession(s)) + const groups: SessionGroup[] = [{ machineId: null, sessions: local }] + const seen = new Set() + for (const s of peers) { + const machineId = s.amicode_owner!.owner_machine_id + if (seen.has(machineId)) continue + seen.add(machineId) + groups.push({ + machineId, + label: deriveSessionBadge(s) ?? machineId, + sessions: filterSessionsByMachine(peers, machineId), + }) + } + return groups +} + +/** Tolerant reader for one raw owner overlay — returns the tag only when its + * load-bearing fields are well-typed, else undefined. */ +function readOwnerTag(raw: unknown): SessionOwnerTag | undefined { + if (!raw || typeof raw !== "object") return undefined + const o = raw as Record + if (typeof o.owner_machine_id !== "string") return undefined + if (typeof o.owner_name !== "string") return undefined + if (typeof o.is_local !== "boolean") return undefined + return { + owner_machine_id: o.owner_machine_id, + owner_name: o.owner_name, + ...(typeof o.device_type === "string" ? { device_type: o.device_type } : {}), + ...(typeof o.directory === "string" ? { directory: o.directory } : {}), + is_local: o.is_local, + } +} + +/** Coerce one raw projection entry into a renderable DropdownSession, keeping + * only the fields the dropdown needs. Returns undefined when the entry has no + * usable id (never a fabricated row). */ +function readProjectionSession(raw: unknown): DropdownSession | undefined { + if (!raw || typeof raw !== "object") return undefined + const o = raw as Record + if (typeof o.id !== "string" || o.id === "") return undefined + const owner = readOwnerTag(o.amicode_owner) + const timeRaw = (o.time && typeof o.time === "object" ? o.time : {}) as Record + const created = typeof timeRaw.created === "number" ? timeRaw.created : 0 + const updated = typeof timeRaw.updated === "number" ? timeRaw.updated : created + const time: DropdownSession["time"] = { + created, + updated, + ...(typeof timeRaw.archived === "number" ? { archived: timeRaw.archived } : {}), + } + return { + id: o.id, + ...(typeof o.title === "string" ? { title: o.title } : {}), + directory: typeof o.directory === "string" ? o.directory : "", + ...(typeof o.parentID === "string" ? { parentID: o.parentID } : {}), + time, + ...(owner ? { amicode_owner: owner } : {}), + // #1646: carry the raw control overlay onto the row. Without this the peer + // row has NO control channel, so readSessionControl always falls to the + // fail-closed default (`no-control-grant`) — the row could never reflect a + // real `interactive` grant, and archive/delete stayed refused forever. The + // overlay is passed through verbatim (an object) — readSessionControl is the + // single tolerant validator, so a malformed one still degrades safely there. + ...(o.amicode_control && typeof o.amicode_control === "object" + ? { amicode_control: o.amicode_control as SessionControlProjection } + : {}), + } as DropdownSession +} + +/** From the raw GET /amicode/fleet/sessions response, extract the REMOTE peer + * sessions (owner overlay present, `is_local === false`) as renderable rows. + * A malformed / error / not-yet-resolved response → []. Local-owned and + * unowned entries are dropped (the local list already carries those). */ +export function peerSessionsFromProjection(raw: unknown): DropdownSession[] { + if (!raw || typeof raw !== "object") return [] + const sessions = (raw as { sessions?: unknown }).sessions + if (!Array.isArray(sessions)) return [] + return sessions.flatMap((s) => { + const row = readProjectionSession(s) + // Drop CHILD sessions (parentID set) — the dropdown is a ROOT-session list, + // mirroring the local path's roots() filter (pages/layout/helpers.ts). The + // fleet projection carries every session, INCLUDING subagent children + // (@explore / @implementer / critic sub-sessions), and without this filter + // they flood the dropdown (amicode#archive-lag follow-up: 76 of 100 sessions + // were subagent children). Owner-routing reads the raw projection directly, + // so filtering the display extractor here does not affect routing. + if (!row || row.parentID || !isRemotePeerSession(row)) return [] + return [row] + }) +} + +/** From the raw GET /amicode/fleet/sessions response, extract ALL renderable + * rows — LOCAL and REMOTE alike (any entry with a usable id). This is the + * complete cross-project, cross-machine session set the projection already + * computes. + * + * Distinct from `peerSessionsFromProjection`, which keeps ONLY remote rows on + * the assumption that the dropdown's LOCAL list (iterated from the known + * project directories) already carries every local session. That assumption + * breaks for a local session in a directory the local store never iterates — + * a remote-created session that landed in the owner's ambient temp cwd, or any + * project not in `projects.list()`. Such a session is local (so dropped by the + * peer-only extractor) AND absent from the directory-scoped local list, so it + * was invisible on the owning machine's dropdown. Feeding THIS set through + * `mergePeerSessions` (local store wins an id collision) leaves listed-dir + * sessions unchanged and surfaces exactly the ones the local list is missing. + * Tolerant: malformed / absent / errored ⇒ []. */ +export function allSessionsFromProjection(raw: unknown): DropdownSession[] { + if (!raw || typeof raw !== "object") return [] + const sessions = (raw as { sessions?: unknown }).sessions + if (!Array.isArray(sessions)) return [] + return sessions.flatMap((s) => { + const row = readProjectionSession(s) + // Drop CHILD sessions (parentID set): the dropdown lists ROOT sessions only, + // mirroring the local path's roots() filter. The projection carries subagent + // children (@explore / @implementer / critic sub-sessions); without this they + // flood the dropdown AND bloat every merge/sort/render (the active list was + // ~100 rows instead of ~24). Owner-routing reads the raw projection directly. + return row && !row.parentID ? [row] : [] + }) +} + +/** Merge peer sessions into the local active list: local wins on an id + * collision (a session that is somehow in both stays local, keeping its live + * state), and the result is sorted by last activity (updated ?? created) + * descending — the same order the dropdown already uses for the local list. + * + * #1599 (badge-on-open fix): when a local row collides with a peer that + * carries an `amicode_owner` overlay AND the local row has none, the local + * row is enriched with the peer's owner tag. Opening a remote session pulls + * an untagged copy into the local directory store (the intended open flow — + * `openSession` calls projects.open + session.sync to render it); without + * this enrichment that untagged copy would win the dedup and the machine + * badge would vanish the moment a remote session goes live. The projection is + * the source of truth for ownership, so carrying its tag onto the local copy + * is honest: the row stays local (live state) but keeps its remote badge. + * + * #1646 (stale-control fix): the projection is ALSO the source of truth for + * the control channel (`amicode_control`), which flips read-only→interactive + * when you enable control of a peer. A locally-pulled copy (from opening the + * remote session) has NO control overlay, so on an id collision the merge must + * carry the peer's control overlay across too — otherwise the merged row reads + * fail-closed (`no-control-grant`) even after control is enabled, and the + * archive/delete gate refuses with "Control not enabled" while the projection + * says interactive. Owner AND control are grafted together, from the same peer. */ +export function mergePeerSessions(local: DropdownSession[], peers: DropdownSession[]): DropdownSession[] { + const peerById = new Map(peers.map((p) => [p.id, p])) + const merged = local.map((s) => { + const peer = peerById.get(s.id) + if (!peer) return s + // Graft the projection's ownership + control overlays onto a local copy that + // lacks them (the projection is SoT for both). Owner grafts only when the + // local row has none (#1599); control grafts from the peer whenever the peer + // is a remote row carrying one (#1646) — a live local copy has no fleet + // control channel of its own, so the peer's is strictly fresher. + const needsOwner = !s.amicode_owner && !!peer.amicode_owner + const needsControl = isRemotePeerSession(peer) && peer.amicode_control !== undefined + if (!needsOwner && !needsControl) return s + const grafted: DropdownSession = { ...s } + if (needsOwner) grafted.amicode_owner = peer.amicode_owner + if (needsControl) grafted.amicode_control = peer.amicode_control + return grafted + }) + const seen = new Set(local.map((s) => s.id)) + for (const p of peers) { + if (seen.has(p.id)) continue + seen.add(p.id) + merged.push(p) + } + return merged.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) +} + +/** #archive-lag (the measured root cause): hide optimistically-archived ids + * from the merged active list. The dropdown merges the CACHED fleet projection, + * and `allSessionsFromProjection` keeps LOCAL rows too — so once an archive + * drops a row from the local store, `mergePeerSessions` re-fills it from the + * stale projection (a twin row) until the next 3s poll lands. A tombstone hides + * the id from the click onward, for local and remote rows alike. No tombstones + * ⇒ the SAME array back, so the common path allocates nothing. */ +export function hideArchivedTombstones(rows: DropdownSession[], tombstones: ReadonlySet): DropdownSession[] { + if (tombstones.size === 0) return rows + return rows.filter((s) => !tombstones.has(s.id)) +} + +/** Retire the tombstones a FRESH active projection has confirmed: an id the + * projection no longer lists is archived server-side, so hiding it is no longer + * needed. An id still listed stays tombstoned — that is a stale response (e.g. + * a poll in flight across the archive PATCH), exactly the resurrection the + * tombstone exists to block. An absent / errored / malformed projection settles + * nothing (keep hiding; the caller's TTL is the backstop). Returns the SAME set + * when nothing settled, so writing it back to a signal is a no-op. */ +export function settleArchivedTombstones(tombstones: ReadonlySet, raw: unknown): ReadonlySet { + if (tombstones.size === 0 || !raw || typeof raw !== "object") return tombstones + const sessions = (raw as { sessions?: unknown }).sessions + if (!Array.isArray(sessions)) return tombstones + const listed = new Set() + for (const s of sessions) { + const id = s && typeof s === "object" ? (s as { id?: unknown }).id : undefined + if (typeof id === "string") listed.add(id) + } + let settled = false + const next = new Set() + for (const id of tombstones) { + if (listed.has(id)) next.add(id) + else settled = true + } + return settled ? next : tombstones +} + +/** The render-relevant signature of a dropdown row — every field the + * SessionDropdownRow / ArchivedSessionDropdownRow actually paint. Two rows with + * the same signature are visually identical, so the `` should keep the + * same DOM node for them. `time.updated` is deliberately EXCLUDED: it churns on + * every streamed token but never changes what the row renders (the status dot + * is driven by the live avatar-state subscription, not by this object), and + * including it would defeat the whole point — a live row would re-key on every + * frame. */ +export function sessionRowSignature(s: DropdownSession): string { + const owner = s.amicode_owner + const control = s.amicode_control + return [ + s.id, + s.title ?? "", + s.directory ?? "", + owner ? `${owner.is_local ? "L" : "R"}:${owner.owner_name ?? ""}:${owner.owner_machine_id ?? ""}` : "-", + control ? `${control.controlState}:${control.reason ?? ""}:${control.eligibility}` : "-", + ].join("\u0001") +} + +/** Stabilize row object identity across polls so SolidJS `` (which keys by + * referential identity) preserves each row's DOM node instead of disposing and + * remounting it. Every 3s the fleet projection re-fetches and `mergePeerSessions` + * mints brand-new objects for peer / owner-tagged rows; `` then sees a new + * reference at that index and remounts the row, resetting its CSS `:hover` state + * and replaying the machine-tag opacity transition — the "flash" a user sees + * when the pointer is resting on a tagged row (amicode#1652). + * + * This keeps a caller-owned `Map` cache: when a row's + * render signature is unchanged since last emit, the PREVIOUS object is + * returned (stable reference → no remount); only a genuine content change (or a + * new id) allocates. Order follows `next` exactly; ids absent from `next` are + * evicted from the cache so it can't grow without bound. Pure aside from the + * passed-in cache — the cache is the reuse state the caller persists across + * memo runs. */ +export function stabilizeSessionIdentity( + next: readonly DropdownSession[], + cache: Map, +): DropdownSession[] { + const nextIds = new Set() + const out = next.map((row) => { + nextIds.add(row.id) + const sig = sessionRowSignature(row) + const prev = cache.get(row.id) + if (prev && prev.sig === sig) return prev.row + cache.set(row.id, { sig, row }) + return row + }) + for (const id of cache.keys()) { + if (!nextIds.has(id)) cache.delete(id) + } + return out +} + +/** #1647 (S3): the ARCHIVED list, fleet-aware. Given this machine's LOCAL + * archived sessions (authoritative, engine-paginated) and the raw + * `/amicode/fleet/sessions?archived=true` projection, return the merged + * archived list: local archived PLUS every peer-owned archived session + * (owner-tagged), local winning an id collision, sorted by last activity + * descending. Standalone / fetch-failure ⇒ raw is undefined ⇒ + * `peerSessionsFromProjection` yields [] ⇒ the local list is unchanged + * (byte-identical to the pre-fleet behavior). This is the composition of the + * two already-tested peer helpers, named for the one place S3 consumes it. */ +export function archivedSessionsWithRemote( + localArchived: DropdownSession[], + archivedProjectionRaw: unknown, +): DropdownSession[] { + return mergePeerSessions(localArchived, peerSessionsFromProjection(archivedProjectionRaw)) +} + +/** The action the dropdown's openSession() dispatches on when a row is clicked. + * A READ-ONLY union — there is no delete/archive/prompt variant, so a reviewer + * can confirm this open path carries no remote-write surface (#1537 B2a). */ +export type DropdownOpenAction = + | { type: "select-tab"; sessionId: string } + | { type: "navigate"; path: string } + +// ── Sort utilities (amicode#1599) ───────────────────────────────────────────── + +export type SortMode = "recent" | "alpha" | "machine" + +/** Title extraction for sort — falls back to id when title is empty/absent. */ +const sortTitle = (s: DropdownSession): string => + typeof s.title === "string" && s.title ? s.title : s.id + +/** + * Sort a flat list of dropdown sessions by the given mode. + * Returns a new array — never mutates the input. + * + * - `recent`: updated (or created) desc, tie-break by id asc + * - `alpha`: title case-insensitive asc, tie-break by recency desc + * - `machine`: local/unowned first, then by owner_name asc, recency desc within group + */ +export function sortDropdownSessions( + sessions: readonly DropdownSession[], + mode: SortMode, +): DropdownSession[] { + const sorted = [...sessions] + switch (mode) { + case "recent": + return sorted.sort((a, b) => { + const diff = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created) + if (diff !== 0) return diff + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + }) + case "alpha": + return sorted.sort((a, b) => { + const cmp = sortTitle(a).toLowerCase().localeCompare(sortTitle(b).toLowerCase()) + if (cmp !== 0) return cmp + return (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created) + }) + case "machine": { + const isLocal = (s: DropdownSession): boolean => + !s.amicode_owner || s.amicode_owner.is_local === true + const ownerName = (s: DropdownSession): string => + s.amicode_owner?.owner_name ?? "" + return sorted.sort((a, b) => { + const aLocal = isLocal(a) + const bLocal = isLocal(b) + if (aLocal !== bLocal) return aLocal ? -1 : 1 + if (!aLocal) { + const nameCmp = ownerName(a).localeCompare(ownerName(b)) + if (nameCmp !== 0) return nameCmp + } + return (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created) + }) + } + } +} + +/** Resolve how to open a dropdown row — the seam #1537 B2a wires for BOTH local + * and REMOTE peer rows. + * + * Before B2a, a remote (peer-owned) row short-circuited to a "lives on + * " guard toast because opening a remote session was never wired. + * B2a removes that dead end: a remote row resolves to the SAME owner-routed + * navigate as a local one — the session store lives on the owner, and the + * multiplex routes reads by owner at the API boundary, so navigating to the + * row's raw `directory`/`id` IS the owner-routed open. This is a read: no + * branch mutates a remote session. + * + * - an already-open tab → select it (no re-navigate, no transition); + * - otherwise → navigate to the encoded `directory`/`id` path. + * + * Local-row behavior is byte-unchanged from B1 (same two outcomes). */ +export function resolveDropdownOpenAction( + session: DropdownSession, + hasExistingTab: boolean, + encodePath: (directory: string, id: string) => string, +): DropdownOpenAction { + if (hasExistingTab) return { type: "select-tab", sessionId: session.id } + return { type: "navigate", path: encodePath(session.directory, session.id) } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx new file mode 100644 index 000000000..ccb658459 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx @@ -0,0 +1,263 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { + isComposerGated, + findSessionControlInProjection, + findSessionOwnerInProjection, +} from "./session-fleet-peers" + +// #1544 (slice 4): the session-surface control wiring. Following the repo's +// component-source-assertion pattern (vscode-explorer-file-icon.test.tsx): the +// DECISION logic is pure + unit-tested in session-fleet-peers.ts; here we assert +// the SolidJS wiring binds those pure functions to the session surface — the +// fail-closed reason chip, the owner-routed archive on an active remote row, and +// the owner-routed delete that now lives on the Archived tab (remote matches +// local: an active row archives, the Archived row deletes). +// +// The "Driving " indicator moved from a fixed banner Portal in +// session-header.tsx to a monitor icon on the session tab in the titlebar +// (titlebar-tab-nav.tsx). The session header no longer renders any driving +// banner; the shared control projection was extracted to its own module. +const source = readFileSync(resolve(__dirname, "session-header.tsx"), "utf8") +const tabNavSource = readFileSync(resolve(__dirname, "../titlebar-tab-nav.tsx"), "utf8") +const tabStripSource = readFileSync(resolve(__dirname, "../titlebar-tab-strip.tsx"), "utf8") +const projectionSource = readFileSync(resolve(__dirname, "session-fleet-control-projection.ts"), "utf8") + +describe("#1544 session-header control wiring", () => { + test("the driving banner Portal is REMOVED from session-header (moved to the tab strip)", () => { + // The fixed-position "Driving " banner no longer lives here — + // the indicator is now a monitor icon on the session tab itself. + expect(source).not.toContain('data-slot="amicode-driving-banner"') + expect(source).not.toContain("data-driving-peer=") + expect(source).not.toContain("drivingBannerFromProjection") + }) + + test("the driving indicator lives on the session tab in the titlebar", () => { + // The tab strip derives the driving state from the shared projection + expect(tabStripSource).toContain("drivingBannerFromProjection(controlProjection()") + expect(tabStripSource).toContain("drivingMachineId") + // The tab nav item renders a monitor icon with the driving machine tooltip + expect(tabNavSource).toContain('data-slot="tab-driving-remote"') + expect(tabNavSource).toContain("drivingMachineId") + expect(tabNavSource).toContain('name="monitor"') + }) + + test("Enable control is re-homed onto the composer scrim, NOT a fixed top-right Portal (#1551 DEFECT 2)", () => { + // The old placement (a Portal to document.body at top:8px/right:12px over + // the titlebar) is REMOVED — no top-right affordance, no command-lane post. + expect(source).not.toContain('data-action="session-enable-control"') + expect(source).not.toContain('top: "8px"') + expect(source).not.toContain("postAmicode(ENABLE_CONTROL_COMMAND)") + expect(source).not.toContain("ENABLE_CONTROL_COMMAND") + }) + + test("the composer-anchored scrim gates the composer + centers the CTA when control is not held (#1551)", () => { + // The affordance now lives on a composer-anchored scrim. + expect(source).toContain("export function SessionComposerControlScrim") + expect(source).toContain('data-slot="amicode-composer-control-scrim"') + expect(source).toContain('data-action="composer-enable-control"') + // the composer subtree is BLURRED + made non-editable (inert) while gated + expect(source).toContain("blur(") + expect(source).toContain("inert") + // gating is derived from the SHARED pure gate (remote peer + control not + // held) — one home for the ungate-on-interactive rule, never the titlebar + expect(source).toContain("isComposerGated(") + // the CTA names the peer the session lives on + expect(source).toContain("This session lives on") + // the CTA click posts DEFECT 1's payload envelope (not the retired command lane) + expect(source).toContain("postAmicodeFleetEnableControl(") + }) + + test("a not-held remote row shows a disabled reason chip (never a live erroring button)", () => { + expect(source).toContain("failClosedChip(control())") + expect(source).toContain('data-slot="session-control-chip"') + expect(source).toContain("data-control-reason={c.reason}") + // writes are gated on held control + expect(source).toContain("writeAffordanceEnabled(control())") + }) + + test("the active remote row is ARCHIVE-ONLY — remote delete moved to the Archived tab", () => { + // A remote peer row on the ACTIVE list offers only the owner-routed archive, + // gated on held control. Remote now matches local: no delete on an active + // row — archive first, then delete from the Archived tab. + expect(source).toContain('data-action="session-remote-archive"') + expect(source).toContain("") + // the active-row remote-delete + its arm→confirm interaction are GONE + expect(source).not.toContain('data-action="session-remote-delete"') + expect(source).not.toContain('data-action="session-remote-delete-confirm"') + expect(source).not.toContain("armRemoteDelete") + expect(source).not.toContain("confirmRemoteDelete") + expect(source).not.toContain("remoteDeleteSession") + }) + + test("remote delete lives on the Archived row — owner-routed AND control-gated", () => { + // deleteArchivedSession routes a remote delete to the owner (same SDK delete + // surface, intercepted by the #1542 write plane) and is gated on held control + // — a control-less remote archived row shows the fail-closed chip instead. + expect(source).toContain("async function deleteArchivedSession") + expect(source).toContain("remoteDeleteAction(dropdownSession).allowed") + // the archived row renders the arm→confirm permanent delete + expect(source).toContain('data-action="session-dropdown-delete"') + expect(source).toContain('data-action="session-dropdown-delete-confirm"') + }) + + // #archive-lag cleanup: the archive handler removes the row by splicing it out + // of its directory store, NOT by re-fetching + reconciling the whole session + // list (`loadSessions` re-fetched + re-`remember`ed 100+ sessions on every + // archive — wasted work, though NOT the measured lag: that was the cached + // fleet projection re-filling the row; see the tombstone test below). The + // composer's archived banner still flips via the INFO-ONLY resolve (metadata; + // no message re-fetch). Mirrors the Rail + chat-menu archive paths + // (layout.tsx / message-timeline.tsx), which splice. + test("#archive-lag archive splices the row from the store (no full list reload) + flips the composer info-only", () => { + // the archived row is removed by a targeted splice, not a whole-list reload + expect(source).toContain("draft.session.splice(") + // …AND the composer's archived banner flips via the INFO-ONLY resolve + expect(source).toContain("session.resolve(session.id, { force: true })") + // …and must NOT use the heavy sync(force), which re-pulls the full message + // history + parents and was the earlier (wrong) suspected cause. + expect(source).not.toContain("session.sync(session.id, { force: true })") + }) + + // #archive-lag ROOT CAUSE (measured in headless Chromium against the live + // service): the row's DOM node detached in 14ms, but a TWIN row re-minted from + // the CACHED fleet projection (allSessionsFromProjection lists LOCAL sessions + // too) stayed visible until the next 3s poll landed (1.3–2.2s). Remote archive + // felt instant only because it refetched the projection. Fix: tombstone the id + // at click, filter the merged list through it, settle on a fresh projection, + // and refetch the projection on LOCAL archive too. + test("#archive-lag archive tombstones the row so the stale projection cannot resurrect it", () => { + // the merged active list is filtered through the tombstones + expect(source).toContain("hideArchivedTombstones(") + // tombstones settle only when a FRESH projection lands + expect(source).toContain("settleArchivedTombstones(") + // the archive handler tombstones optimistically and un-tombstones on failure + expect(source).toContain("tombstoneArchived(session.id)") + expect(source).toContain("untombstoneArchived(session.id)") + // a LOCAL archive refetches the projection too (not only the remote path) + const archiveBody = source.slice(source.indexOf("async function archiveSession("), source.indexOf("async function unarchiveSession(")) + expect(archiveBody).toContain("void refetchFleetProjection()") + expect(archiveBody).not.toContain("} else {\n // #1647 (S9)") + }) +}) + +// amicode#1652: the owner tag is a SINGLE element that slides left by the action +// cluster's exact rendered width — the cluster reveals IN FLOW via a 0fr→1fr grid +// column (no hardcoded pixel shift), which shrinks the row button and pushes the +// tag over. The tag is never faded out and never truncated on hover, and the +// archive stays pinned at the row's right edge (it does not move/teleport). +describe("#1652 owner-tag shifts (does not teleport) on hover", () => { + test("there is ONE owner badge per row — no duplicate 'teleported' copy", () => { + expect(source).toContain('data-slot="session-owner-badge"') + expect(source).toContain('data-slot="archived-session-owner-badge"') + // the earlier fade-out + second-copy approach is gone + expect(source).not.toContain('data-slot="session-owner-badge-resting"') + expect(source).not.toContain('data-slot="session-owner-badge-teleported"') + expect(source).not.toContain('data-slot="archived-session-owner-badge-teleported"') + }) + + test("the tag is NOT faded out on hover (it shifts, it does not vanish)", () => { + expect(source).not.toContain("group-hover/session:opacity-0") + expect(source).not.toContain("group-focus-within/session:opacity-0") + expect(source).not.toContain("group-hover/archived:opacity-0") + }) + + test("the action cluster reveals in flow via a 0fr→1fr grid column (reserves exact width)", () => { + // active row (session group) and archived row (archived group) + expect(source).toContain("grid-cols-[0fr]") + expect(source).toContain("group-hover/session:grid-cols-[1fr]") + expect(source).toContain("group-hover/archived:grid-cols-[1fr]") + // the archive controls no longer overlay via an absolute right-edge box + expect(source).not.toContain("absolute right-1.5 top-1/2 flex -translate-y-1/2") + }) +}) + +// amicode#1652: the flyout list must not remount (and flash) tagged rows on the +// 3s fleet poll. The dropdown feeds through stabilizeSessionIdentity with a +// component-scoped id-keyed cache, so unchanged rows keep their object reference. +describe("#1652 stable row identity across the fleet poll", () => { + test("the dropdown stabilizes row identity before (active + archived)", () => { + expect(source).toContain("stabilizeSessionIdentity") + expect(source).toContain("activeIdentityCache") + expect(source).toContain("archivedIdentityCache") + }) +}) + +// #1562-followup (Slice A): the interactive flip must be OBSERVED. The shared +// control projection was extracted to session-fleet-control-projection.ts — the +// same ref-counted singleton the session header, the composer scrim, and now the +// titlebar tab strip all consume. +describe("Slice A — the control projection is polled + shared (the interactive flip is observed)", () => { + test("the shared polled accessor lives in its own module and is consumed by session-header", () => { + // session-header imports the shared accessor + expect(source).toContain("useSharedControlProjection") + // the projection module has the poll machinery + expect(projectionSource).toContain("setInterval(") + expect(projectionSource).toContain('addEventListener("focus"') + expect(projectionSource).toContain("clearInterval(") + }) + + test("the tab strip also consumes the shared projection (for the driving indicator)", () => { + expect(tabStripSource).toContain("useControlProjectionForConnection") + }) + + test("the duplicated one-shot createResource(() => server.current, …) is GONE from header + scrim", () => { + // the bare `() => server.current` resource source appeared twice (header + + // scrim); both are replaced by the shared polled accessor. (The dropdown's + // `() => (open() ? server.current : undefined)` source is a different string + // and is intentionally untouched.) + expect(source).not.toContain("() => server.current,") + }) + + test("a projection reporting `interactive` ungates the composer scrim (gated === false)", () => { + const interactive = { + sessions: [ + { + id: "ses_studio", + amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, + amicode_control: { controlState: "interactive", reason: null, eligibility: "none" }, + }, + ], + } + const owner = findSessionOwnerInProjection(interactive, "ses_studio") + const control = findSessionControlInProjection(interactive, "ses_studio") + expect(isComposerGated(owner, control)).toBe(false) + + // and a read-only projection for the same remote session STILL gates it + const readOnly = { + sessions: [ + { + id: "ses_studio", + amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, + amicode_control: { controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" }, + }, + ], + } + expect( + isComposerGated( + findSessionOwnerInProjection(readOnly, "ses_studio"), + findSessionControlInProjection(readOnly, "ses_studio"), + ), + ).toBe(true) + + // a LOCAL/unowned session is never gated regardless of control read + expect(isComposerGated(undefined, control)).toBe(false) + }) +}) + +// #1568: the "Driven by {machine}" presence indicator — shows on the session tab +// when a remote machine holds an active control grant targeting this machine. +describe("#1568 driven-by presence indicator wiring", () => { + test("the tab strip derives driven-by state from the shared fleet projection", () => { + expect(tabStripSource).toContain("drivenByBannerFromProjection(controlProjection()") + expect(tabStripSource).toContain("drivenByMachine") + }) + + test("the tab nav item renders an eye icon with the driven-by machine tooltip", () => { + expect(tabNavSource).toContain('data-slot="tab-driven-by-remote"') + expect(tabNavSource).toContain("drivenByMachine") + expect(tabNavSource).toContain('name="eye"') + expect(tabNavSource).toContain("Driven by") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx index e51273f55..e9c787d42 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx @@ -10,9 +10,10 @@ import { showToast } from "@/utils/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { getFilename } from "@opencode-ai/core/util/path" -import { batch, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { batch, createEffect, createMemo, createResource, createRoot, createSignal, For, on, onCleanup, onMount, Show, untrack, type JSX } from "solid-js" +import { createStore, produce } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" @@ -41,6 +42,40 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers" import { useNavigate } from "@solidjs/router" import type { Session } from "@opencode-ai/sdk/v2/client" +import { amicodeGet } from "@/utils/amicode-fetch" +import { postAmicodeFleetEnableControl } from "@/utils/amicode-bridge" +import { + allSessionsFromProjection, + mergePeerSessions, + hideArchivedTombstones, + settleArchivedTombstones, + stabilizeSessionIdentity, + archivedSessionsWithRemote, + deriveSessionBadge, + isRemotePeerSession, + isComposerGated, + resolveDropdownOpenAction, + readSessionControl, + writeAffordanceEnabled, + failClosedChip, + controlAffordance, + + findSessionControlInProjection, + findSessionOwnerInProjection, + remoteDeleteAction, + sortDropdownSessions, + type DropdownSession, + type SortMode, +} from "./session-fleet-peers" + +// AMICODE #1551 (DEFECT 2): the self-owned Enable-control affordance no longer +// lives as a fixed top-right Portal over the titlebar. It is re-homed onto a +// COMPOSER-ANCHORED scrim (SessionComposerControlScrim, below) that blurs + +// gates the composer and centers the CTA while control is not held. The CTA +// posts the #1551 payload envelope (postAmicodeFleetEnableControl); the extension +// host shows the ADR 0034 D4 native modal and mints the grant. On success the +// next fleet-projection poll flips the projected control to `interactive` — the +// app never self-declares interactive. // AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator // noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is @@ -55,6 +90,14 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { reviewTooltipKeybind } from "../command-tooltip-keybind" import { useTitlebarRightMount, useTitlebarControlMount } from "../titlebar" +// #1577: portal dedup registry — enforces exactly one portal source per +// session-scoped mount point across concurrent SessionHeader mounts +// (the startTransition double-mount during remote session focus). +import { claimPortalMount, isPortalOwner, releasePortalMount } from "../titlebar-portal-registry" +// #1562-followup (Slice A): the shared control-projection poll — extracted to +// session-fleet-control-projection.ts so the titlebar tab strip can share the +// same ref-counted singleton without importing this component file. +import { useSharedControlProjection } from "./session-fleet-control-projection" const OPEN_APPS = [ "vscode", @@ -170,6 +213,18 @@ export function SessionHeader() { const terminal = useTerminal() const { params, view } = useSessionLayout() + // #1544 (slice 4): the control state channel for the CURRENT session, read off + // the fleet projection (GET /amicode/fleet/sessions — the SAME carrier the + // Sessions dropdown consumes). It drives the persistent "driving " + // banner and the Enable/Request-control affordance on the SESSION SURFACE + // (control affordances live here + in Fleet Manager, NEVER the read-only + // sidebar — ADR 0034 D7). Tolerant: a 404 / no-fleet resolves to undefined and + // every derived value degrades to "no banner / no affordance". + // #1562-followup (Slice A): read via the SHARED polled accessor so the + // interactive flip (grant landed) is actually OBSERVED — the banner lights on + // the next poll instead of never (the B2b one-shot never re-fetched). + const controlProjection = useSharedControlProjection() + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const project = createMemo(() => { const directory = projectDirectory() @@ -318,12 +373,34 @@ export function SessionHeader() { const sessionsMount = useTitlebarControlMount("sessions") const statusMount = useTitlebarControlMount("status") const sidePanelMount = useTitlebarControlMount("side-panel") + + // #1577: claim exclusive ownership of each session-scoped mount point. + // During startTransition, a new SessionHeader can mount while the old one + // is still alive — both render portals into the same mount divs, doubling + // controls. The registry makes isPortalOwner() return false for the stale + // component, so its portals conditionally hide. + const sessionsToken = claimPortalMount("sessions") + const statusToken = claimPortalMount("status") + const sidePanelToken = claimPortalMount("side-panel") + onCleanup(() => { + releasePortalMount("sessions", sessionsToken) + releasePortalMount("status", statusToken) + releasePortalMount("side-panel", sidePanelToken) + }) + onMount(() => { setCenterMount(document.getElementById("opencode-titlebar-center")) }) return ( <> + {/* #1544 (slice 4): the "driving " indicator moved from a fixed + banner Portal to a monitor icon on the session tab in the titlebar + (titlebar-tab-nav.tsx). The tab strip now consumes the shared fleet + projection directly. The composer scrim control gate is unchanged. */} + {/* #1551 (DEFECT 2): the Enable-control affordance moved OFF the titlebar + and onto the composer scrim (SessionComposerControlScrim, below). No + fixed top-right Portal renders here anymore. */} {(mount) => ( @@ -562,9 +639,11 @@ export function SessionHeader() { )} - {/* V2 per-button portals — each session-scoped control portals to its own mount point */} + {/* V2 per-button portals — each session-scoped control portals to its own mount point. + #1577: each portal is gated on isPortalOwner so that during startTransition + (two concurrent SessionHeaders), only the latest owner renders. */} - + {(mount) => ( @@ -574,7 +653,7 @@ export function SessionHeader() { )} - + {(mount) => ( @@ -587,7 +666,7 @@ export function SessionHeader() { - + {(mount) => ( + findSessionControlInProjection(controlProjection(), params.id ?? ""), + ) + const owner = createMemo(() => findSessionOwnerInProjection(controlProjection(), params.id ?? "")) + // Gated iff a REMOTE peer session whose control is NOT held (read-only / + // suspended). Local / unowned / already-driving (interactive) → not gated. + // The rule lives in one pure place (isComposerGated), shared with the test. + const gated = createMemo(() => isComposerGated(owner(), control())) + const peerLabel = createMemo(() => owner()?.owner_name || owner()?.owner_machine_id || "this peer") + const enableControl = () => { + const o = owner() + const id = params.id + if (!o || !id) return + // The CTA click path is identical to DEFECT 1's envelope. + postAmicodeFleetEnableControl({ ownerMachineId: o.owner_machine_id, sessionID: id }) + } + return ( +

+ {/* The composer subtree — blurred + inert (non-editable, unfocusable) + while gated; untouched otherwise. */} +
+ {props.children} +
+ +
+
+ + + This session lives on {peerLabel()} — Enable control to drive it + + +
+
+
+
+ ) +} + type SessionHeaderV2ActionsState = { /** AMICODE (#174 AC2): the trigger itself always renders in a session; the * show-status setting only drives this health-dot flag. */ @@ -692,7 +885,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { // (amicode#273) inside the session header: tabbed Active/Archived, search, // open-tab indicators, archive/unarchive actions, and cursor-based pagination. const SESSION_DROPDOWN_ROW = - "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-sm bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base" + "flex min-w-0 flex-1 cursor-default items-center rounded-sm bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base" export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) { const tabs = useTabs() @@ -711,16 +904,159 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) const [archivedHasMore, setArchivedHasMore] = createSignal(true) const ARCHIVED_PAGE_SIZE = 20 + // --- Sort mode (amicode#1599 part C) --- + const [sortMode, setSortMode] = createSignal( + (() => { + try { + const stored = localStorage.getItem("amicode:sessions-dropdown-sort") + if (stored === "recent" || stored === "alpha" || stored === "machine") return stored + } catch { /* localStorage unavailable */ } + return "recent" + })(), + ) + createEffect(() => { + try { localStorage.setItem("amicode:sessions-dropdown-sort", sortMode()) } catch { /* best-effort */ } + }) + + // --- Active tab pagination (amicode#1599 part B) --- + const ACTIVE_PAGE_SIZE = 50 + const [activeLimit, setActiveLimit] = createSignal(ACTIVE_PAGE_SIZE) + const [loadingMore, setLoadingMore] = createSignal(false) + let flyoutRoot: HTMLDivElement | undefined + let triggerRef: HTMLButtonElement | undefined + let scrollContainerRef: HTMLDivElement | undefined + + /** Save scrollTop and restore it on the next animation frame. + * Call before any action that triggers a reactive list rebuild + * (archive / delete / unarchive) to prevent the scroll container + * from jumping to the top when diffs the updated array. */ + function restoreScrollAfter(fn: () => Promise): Promise { + const saved = scrollContainerRef?.scrollTop ?? 0 + return fn().finally(() => { + requestAnimationFrame(() => { + if (scrollContainerRef) scrollContainerRef.scrollTop = saved + }) + }) + } + + // --- Cached flyout position (amicode#1599 part A) --- + const [flyoutPos, setFlyoutPos] = createSignal<{ top: number; right: number }>({ top: 0, right: 0 }) + const recomputePos = () => { + if (!triggerRef) return + const rect = triggerRef.getBoundingClientRect() + setFlyoutPos({ + top: rect.bottom + 8, + right: document.documentElement.clientWidth - rect.right, + }) + } + createEffect(() => { + if (!open()) return + recomputePos() + window.addEventListener("resize", recomputePos) + onCleanup(() => window.removeEventListener("resize", recomputePos)) + }) const currentSessionID = createMemo(() => props.currentSessionID) + // #1525 B1 (read-only): peer sessions from the fleet-wide projection + // (GET /amicode/fleet/sessions). Fetched only while the flyout is open; + // tolerant — a 404 (route not mounted / no fleet) or any error resolves to + // undefined, so the dropdown degrades to the local list, never blank. The + // merge below adds REMOTE (is_local:false) sessions, badged with the owner + // machine name; owner-routed open + remote control (prompt/archive/delete) + // are B2 (#1525) and deliberately absent here. + const [fleetProjection, { refetch: refetchFleetProjection }] = createResource( + () => (open() ? ([server.current, fleetPoll()] as const) : undefined), + ([conn]) => amicodeGet(conn, "/amicode/fleet/sessions").catch(() => undefined), + ) + // #1647 (S10): poll the fleet projection while the flyout is open. It is the + // SOURCE of a peer row's owner badge (peerSessionsFromProjection); without a + // poll it was fetched ONCE on open, so a REMOTE archive/unarchive done on the + // OWNER machine (which this machine only learns of via the SSE event) left the + // projection stale — the row reappeared from the local store on unarchive but + // WITHOUT its machine tag until the flyout was reopened. A 3s poll (matching + // the control projection) refreshes the owner overlay so the badge returns. + const [fleetPoll, setFleetPoll] = createSignal(0) + createEffect(() => { + if (!open()) return + const timer = setInterval(() => setFleetPoll((t) => t + 1), 3000) + onCleanup(() => clearInterval(timer)) + }) + + // #archive-lag (the measured root cause — round 6). The active list merges the + // CACHED fleet projection, which lists LOCAL sessions too. Removing an archived + // row from the local store (splice or reload — either) is therefore NOT enough: + // mergePeerSessions re-fills the id from the stale projection as a twin row + // until the next 3s poll lands. Measured in headless Chromium against the live + // service: the original DOM node detached in 14ms, the twin stayed visible + // 1.3–2.2s — the "couple of seconds". A REMOTE archive only felt instant because + // its path refetched the projection immediately. + // + // Tombstones hide an archived id from the CLICK onward (local and remote alike) + // and retire when (a) a fresh projection response no longer lists it, (b) it is + // unarchived, (c) the archive PATCH fails, or (d) a TTL backstop fires (so a + // session unarchived elsewhere before the projection ever confirmed can't stay + // hidden forever). + const ARCHIVE_TOMBSTONE_TTL_MS = 15_000 + const [archivedTombstones, setArchivedTombstones] = createSignal>(new Set()) + const tombstoneTimers = new Map>() + function untombstoneArchived(id: string) { + const timer = tombstoneTimers.get(id) + if (timer) clearTimeout(timer) + tombstoneTimers.delete(id) + setArchivedTombstones((prev) => { + if (!prev.has(id)) return prev + const next = new Set(prev) + next.delete(id) + return next + }) + } + function tombstoneArchived(id: string) { + setArchivedTombstones((prev) => (prev.has(id) ? prev : new Set(prev).add(id))) + const existing = tombstoneTimers.get(id) + if (existing) clearTimeout(existing) + tombstoneTimers.set(id, setTimeout(() => untombstoneArchived(id), ARCHIVE_TOMBSTONE_TTL_MS)) + } + // Settle ONLY when a new projection response lands — not when a tombstone is + // added (untracked), or the still-cached pre-archive projection would be judged + // against a fresh tombstone before the PATCH even returned. An id whose PATCH is + // still in flight is never settled: its local-store row has not been removed + // yet, so retiring the tombstone early would flash the row back. + const archivePending = new Set() + createEffect( + on( + () => fleetProjection.latest, + (latest) => { + const current = untrack(archivedTombstones) + const next = settleArchivedTombstones(current, latest) + if (next === current) return + for (const id of current) if (!next.has(id) && !archivePending.has(id)) untombstoneArchived(id) + }, + { defer: true }, + ), + ) + onCleanup(() => { + for (const timer of tombstoneTimers.values()) clearTimeout(timer) + tombstoneTimers.clear() + }) + // Active sessions — only computed when the flyout is open to avoid // triggering reactive subscriptions (serverSync().child pins the directory // and can cascade re-renders to the parent Portal). // Source from ALL project directories (same as dashboard) — not just the // server's cwd, which may not be where sessions live (amicode#138). - const activeSessions = createMemo(() => { + // + // PERF (amicode#1599): reading each store's session rows subscribes this memo + // to per-row fields including `time.updated`. A LIVE session streaming tokens + // bumps `time.updated` on nearly every frame, which would re-run the whole + // dropdown pipeline (merge → sort → filter → re-diff) at frame rate and + // make the open flyout lag. We split the compute in two: + // • activeSessionsRaw — the reactive read (subscribes to the stores). + // • activeSessions — a throttled mirror that only re-emits at most once + // per THROTTLE_MS, EXCEPT it passes through immediately when the session + // SET changes (ids/titles/count) so open/close/rename stay instant. + const activeSessionsRaw = createMemo(() => { if (!open()) return [] try { const conn = server.current @@ -744,6 +1080,57 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } }) + // The SET signature: what actually needs an instant re-render (rows added / + // removed / renamed). Streaming token churn only moves `time.updated`, which + // is deliberately NOT in the signature — those updates ride the throttle. + const activeSetSignature = createMemo(() => + activeSessionsRaw() + .map((s) => `${s.id}:${s.title ?? ""}`) + .join("|"), + ) + + const ACTIVE_THROTTLE_MS = 500 + const [activeThrottleTick, setActiveThrottleTick] = createSignal(0) + createEffect(() => { + if (!open()) return + // Depend on the raw list so the timer runs while updates are flowing. + activeSessionsRaw() + const t = setTimeout(() => setActiveThrottleTick((n) => n + 1), ACTIVE_THROTTLE_MS) + onCleanup(() => clearTimeout(t)) + }) + + const activeSessions = createMemo(() => { + if (!open()) return [] + // Re-emit ONLY on a set change (add/remove/rename) or a throttle tick. + activeSetSignature() + activeThrottleTick() + // Read the raw list WITHOUT subscribing to its per-row fields — otherwise a + // streaming `time.updated` would re-run this memo and defeat the throttle. + return untrack(activeSessionsRaw) + }) + + + // #1525 B1: fold the projection's sessions into the active list (deduped + // against local, sorted by last activity). Empty/errored projection → the + // local list unchanged. Use the FULL projection set (local + remote): the + // local list is iterated only over KNOWN project directories, so a local + // session in a directory the store never iterates (a remote-created session + // that landed in the owner's ambient temp cwd, any unlisted project) was + // absent from BOTH the local list and the peer-only extractor → invisible on + // the owning machine's dropdown. mergePeerSessions dedupes (local store wins), + // so listed-dir rows are unchanged and only the missing ones are surfaced. + const activeSessionsWithPeers = createMemo(() => { + if (!open()) return [] + const projectionSessions = allSessionsFromProjection(fleetProjection.latest) + // #archive-lag: the projection above is a CACHE (≤3s old) that lists LOCAL + // sessions too, so a just-archived row the local store already dropped would + // be re-filled from it by the merge. Tombstones hide it from the click on. + return hideArchivedTombstones( + mergePeerSessions(activeSessions() as DropdownSession[], projectionSessions), + archivedTombstones(), + ) + }) + // Fresh clients have no bootstrapped child stores for the fallback // directories (the dropdown reads with bootstrap: false) — kick the loads // once per open. Converges: re-runs find the stores populated and skip. @@ -759,10 +1146,10 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } }) - // Sort: open-tab sessions first + // Sort: open-tab sessions first, each partition sorted by the active mode. const sortedActiveSessions = createMemo(() => { if (!open()) return [] - const all = activeSessions() + const all = activeSessionsWithPeers() const openTabs: Session[] = [] const rest: Session[] = [] for (const session of all) { @@ -772,7 +1159,11 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) rest.push(session) } } - return [...openTabs, ...rest] + const mode = sortMode() + return [ + ...sortDropdownSessions(openTabs as DropdownSession[], mode), + ...sortDropdownSessions(rest as DropdownSession[], mode), + ] }) // Search filtering @@ -786,13 +1177,47 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) return title.toLowerCase().includes(q) }) }) + // amicode#1599 follow-up — actually paginate the DOM. `activeLimit` bounded the + // backend fetch and the Show-more button, but the rendered EVERY loaded + // + peer row: hundreds of rows, each carrying the per-row + // useSessionTabAvatarState subscriptions, re-diffed on every 3s fleet poll — + // the source of the flyout lag. Slice the rendered list to the limit. Search + // spans the full loaded set, so slice AFTER the filter (a query renders all + // matches; Show-more is hidden while searching). + // amicode#1652 — the 3s fleet poll (fleetPoll) re-fetches the projection, and + // mergePeerSessions mints a NEW object for every peer / owner-tagged row on each + // fetch. SolidJS keys by referential identity, so those new references make + // it dispose + remount the row — resetting the CSS :hover state and replaying the + // machine-tag opacity transition under a resting pointer (the "flash"). An + // id-keyed identity cache returns the PREVIOUS row object when a row's render + // signature is unchanged, so unchanged rows keep their reference (and DOM node) + // across the poll. The cache is component-scoped so it survives memo re-runs; it + // self-evicts ids that leave the list. + const activeIdentityCache = new Map() + const pagedActiveSessions = createMemo(() => { + const sliced = searchQuery() + ? filteredActiveSessions() + : filteredActiveSessions().slice(0, activeLimit()) + return stabilizeSessionIdentity(sliced as DropdownSession[], activeIdentityCache) + }) + // "More" = rows hidden by the slice, OR the backend fetch cap was hit (more may + // be loadable). Show-more reveals the next page and fetches the next backend page. + const hasMoreActive = createMemo( + () => filteredActiveSessions().length > pagedActiveSessions().length || activeSessions().length >= activeLimit(), + ) + // amicode#1652 — same identity-stabilization as the active list: the archived + // tab has its own 5s poll (loadArchivedSessions) that setArchivedSessions with + // fresh objects, remounting owner-tagged archived rows and flashing their tag. + const archivedIdentityCache = new Map() const filteredArchivedSessions = createMemo(() => { const q = searchQuery() - if (!q) return archivedSessions() - return archivedSessions().filter((session) => { - const title = sessionTitle(session.title) || session.id - return title.toLowerCase().includes(q) - }) + const list = !q + ? archivedSessions() + : archivedSessions().filter((session) => { + const title = sessionTitle(session.title) || session.id + return title.toLowerCase().includes(q) + }) + return stabilizeSessionIdentity(list as DropdownSession[], archivedIdentityCache) }) // SDK access for archived sessions @@ -811,14 +1236,22 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) const result = await ctx.sdk.client.experimental.session.list( { archived: true, limit: ARCHIVED_PAGE_SIZE, ...(cursor ? { cursor: Number(cursor) } : {}) }, ) - const sessions: Session[] = (result.data ?? []) as Session[] + const local: Session[] = (result.data ?? []) as Session[] if (reset) { - setArchivedSessions(sessions) + // #1647 (S3): on the first page, fold in peer-owned archived sessions + // from the fleet projection (owner-tagged). Bounded recent window — not + // per-peer cursor-paginated (pragmatic first cut). A standalone machine + // or a fetch failure yields no remote rows → local list unchanged. + const proj = await amicodeGet(server.current, "/amicode/fleet/sessions?archived=true").catch(() => undefined) + setArchivedSessions(archivedSessionsWithRemote(local as DropdownSession[], proj) as Session[]) } else { - setArchivedSessions((prev) => [...prev, ...sessions]) + // Load-more appends the next LOCAL page; remote rows were merged on reset. + const have = new Set(archivedSessions().map((s) => s.id)) + setArchivedSessions((prev) => [...prev, ...local.filter((s) => !have.has(s.id))]) } - const lastSession = sessions[sessions.length - 1] - if (sessions.length >= ARCHIVED_PAGE_SIZE && lastSession) { + // Cursor tracks the LOCAL engine's pagination only (remote is a fixed window). + const lastSession = local[local.length - 1] + if (local.length >= ARCHIVED_PAGE_SIZE && lastSession) { setArchivedCursor(String(lastSession.time.updated ?? lastSession.time.created)) setArchivedHasMore(true) } else { @@ -832,69 +1265,190 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } } + // #1647 (S6): keep the Archived tab live while open (local + fan-in-relayed + // remote archive/unarchive), on the same 5s cadence the fleet projection uses. + // Bounded real-time (≤5s), robust, cleaned up on tab-close / unmount. + createEffect(() => { + if (flyoutTab() !== "archived") return + const timer = setInterval(() => void loadArchivedSessions(true), 5000) + onCleanup(() => clearInterval(timer)) + }) + async function archiveSession(session: Session) { const ctx = getServerCtx() if (!ctx) return - try { - await (ctx.sdk.client.session.update as Function)({ - sessionID: session.id, - directory: session.directory, - time: { archived: Date.now() }, - }) - setArchivedSessions((prev) => [session, ...prev]) - // Reload active sessions for this session's directory - await serverSync().project.loadSessions(session.directory, { limit: 64 }) - } catch (cause) { - showToast({ - title: language.t("common.requestFailed"), - description: String(cause), - }) + const dropdownSession = session as DropdownSession + const isRemote = isRemotePeerSession(dropdownSession) + // A REMOTE peer session is archived on its OWNER: the same session.update + // PATCH, keyed on the session's own id+directory, is intercepted by the + // #1542 observation WRITE plane (any non-GET to a peer-owned session routes + // to the owner by pathname) — provided control is held. The row only shows + // the archive affordance for a remote row when canWrite() is true, so this + // is reached only under held control; a control-less remote row shows the + // fail-closed chip instead and never gets here. + if (isRemote) { + const action = remoteDeleteAction(dropdownSession) // reuse the gate: allowed iff control held + if (!action.allowed) { + showToast({ + title: "Control not enabled", + description: `Enable control of ${dropdownSession.amicode_owner?.owner_name ?? "the owner machine"} to archive its sessions from here.`, + }) + return + } } + // #archive-lag: hide the row NOW — before the PATCH round-trip — for local + // and remote alike. The tombstone also blocks the cached fleet projection from + // re-minting the row (the measured couple-of-seconds lag); see the tombstone + // block above. A failed PATCH un-tombstones so the row comes back. + tombstoneArchived(session.id) + archivePending.add(session.id) + let patched = false + await restoreScrollAfter(async () => { + try { + await (ctx.sdk.client.session.update as Function)({ + sessionID: session.id, + directory: session.directory, + time: { archived: Date.now() }, + }) + patched = true + if (!isRemote) { + // A LOCAL archive moves the row into THIS machine's archived tab. + setArchivedSessions((prev) => [session, ...prev]) + // Remove the archived row from its directory store DIRECTLY instead of + // re-fetching + reconciling the whole session list (`loadSessions` + // re-`remember`ed every row on every archive — wasted work). roots() + // (pages/layout/helpers.ts) filters archived rows anyway, and the next + // natural loadSessions reconciles from the server. Matches the Rail + // (layout.tsx) and chat-menu (message-timeline.tsx) archive paths. + const [, setChildStore] = serverSync().child(session.directory, { bootstrap: false }) + setChildStore( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === session.id) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + } + // Refetch the fleet projection for BOTH paths. #1647 (S9): a remote row + // lives only in the projection. #archive-lag: a LOCAL row is ALSO in the + // projection (allSessionsFromProjection keeps local rows), so the cache + // must converge too — the fresh response is what retires the tombstone. + void refetchFleetProjection() + // Refresh the Archived tab so the just-archived session appears there too. + void loadArchivedSessions(true) + // #1646: refresh the PER-SESSION store so the composer flips to its + // archived read-only banner immediately. The removal above touches the + // LIST store, but the composer's `archived` memo reads the per-session + // store (sync().session.get(id).time.archived) — which the list change + // does not touch. Without this the banner appeared only when the change + // lazily propagated (SSE / next projection cycle), so a remote + // (owner-routed) archive left the composer live for seconds. + // INFO-ONLY `resolve` (session.get → remember), NOT `sync`: the archived + // flag is METADATA, so re-fetching the whole message page is pure waste. + // `resolve` lands the flag without touching messages, matching the + // composer controller's own convergence (session-composer-region-controller). + await serverSync().session.resolve(session.id, { force: true }).catch(() => {}) + } catch (cause) { + // Only a FAILED archive brings the row back; a post-PATCH hiccup must not + // resurrect a session the server has already archived. + if (!patched) untombstoneArchived(session.id) + showToast({ + title: language.t("common.requestFailed"), + description: String(cause), + }) + } finally { + archivePending.delete(session.id) + } + }) } async function unarchiveSession(session: Session) { const ctx = getServerCtx() if (!ctx) return - try { - await (ctx.sdk.client.session.update as Function)({ - sessionID: session.id, - directory: session.directory, - time: { archived: null }, - }) - setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id)) - await serverSync().project.loadSessions(session.directory, { limit: 64 }) - } catch (cause) { + // #1647 (S4): a REMOTE archived row unarchives on its OWNER (the same + // owner-routed session.update, now routable because the owner map unions + // archived ids — S2). Gate on control exactly like the active archive path; + // a control-less remote row shows the fail-closed chip and never reaches here. + const dropdownSession = session as DropdownSession + if (isRemotePeerSession(dropdownSession) && !remoteDeleteAction(dropdownSession).allowed) { showToast({ - title: language.t("common.requestFailed"), - description: String(cause), + title: "Control not enabled", + description: `Enable control of ${dropdownSession.amicode_owner?.owner_name ?? "the owner machine"} to unarchive its sessions from here.`, }) + return } + await restoreScrollAfter(async () => { + try { + await (ctx.sdk.client.session.update as Function)({ + sessionID: session.id, + directory: session.directory, + time: { archived: null }, + }) + // #archive-lag: a quick archive→unarchive must not leave the row hidden + // behind a still-live tombstone. + untombstoneArchived(session.id) + setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id)) + await serverSync().project.loadSessions(session.directory, { limit: 64 }) + // #1647 (S9): a remote unarchive re-activates the session on its owner — + // refetch the fleet projection so it reappears in the active peer list. + if (isRemotePeerSession(dropdownSession)) void refetchFleetProjection() + } catch (cause) { + showToast({ + title: language.t("common.requestFailed"), + description: String(cause), + }) + } + }) } // amicode#255: permanently delete an archived session (inline confirm in row). async function deleteArchivedSession(session: Session) { const ctx = getServerCtx() if (!ctx) return - try { - await (ctx.sdk.client.session.delete as Function)({ - sessionID: session.id, - directory: session.directory, - }) - setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id)) - } catch (cause) { + // #1647 (S4): remote delete routes to the owner; gate on control. + const dropdownSession = session as DropdownSession + if (isRemotePeerSession(dropdownSession) && !remoteDeleteAction(dropdownSession).allowed) { showToast({ - title: language.t("session.delete.failed.title"), - description: String(cause), + title: "Control not enabled", + description: `Enable control of ${dropdownSession.amicode_owner?.owner_name ?? "the owner machine"} to delete its sessions from here.`, }) + return } + await restoreScrollAfter(async () => { + try { + await (ctx.sdk.client.session.delete as Function)({ + sessionID: session.id, + directory: session.directory, + }) + setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id)) + // #1647 (S9): drop the deleted peer session from the active list too. + if (isRemotePeerSession(dropdownSession)) void refetchFleetProjection() + } catch (cause) { + showToast({ + title: language.t("session.delete.failed.title"), + description: String(cause), + }) + } + }) } async function openSession(session: Session) { + // #1537 B2a: OPEN a peer (remote) session by routing through the owner. + // Open is a READ — the session store lives on the owner machine and the + // multiplex routes reads by owner at the API boundary, so navigating to the + // row's raw directory/id IS the owner-routed open. B1's guard toast ("lives + // on , coming soon") was the dead end this replaces; a remote row + // now lands in the normal session view, byte-for-byte the same navigate a + // local row takes. No remote-write surface is added here (prompt / archive / + // delete of a peer row remain absent — that is B2b, design-gated). The row + // still hides its LOCAL-only archive action via `isRemotePeerSession`. + // Close flyout first so its Portal unmounts cleanly. setOpen(false) // Mirror the dashboard's project setup: ensure the directory is registered and - // touched so the workspace context is warm when the session page mounts. + // touched so the workspace context is warm when the session page mounts. For a + // remote row this registers the owner's directory string locally — harmless + // bookkeeping; the actual session reads route to the owner via the multiplex. const conn = server.current if (conn) { const ctx = globalCtx.ensureServerCtx(conn) @@ -919,11 +1473,15 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) const existingTab = tabs.store.find( (t) => t.type === "session" && t.sessionId === session.id, ) - if (existingTab) { - tabs.select(existingTab) + const action = resolveDropdownOpenAction( + session as DropdownSession, + !!existingTab, + (dir, id) => `/${base64Encode(dir)}/session/${id}`, + ) + if (action.type === "select-tab") { + tabs.select(existingTab!) } else { - const path = `/${base64Encode(session.directory)}/session/${session.id}` - navigate(path) + navigate(action.path) } } @@ -943,7 +1501,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) if (flyoutRoot?.contains(target)) return if (triggerRef?.contains(target)) return // Don't dismiss if the click landed inside a dialog (e.g. delete confirmation) - if (target instanceof Element && target.closest("[data-dialog-layer], [data-component='dialog-overlay']")) return + if (target instanceof Element && target.closest("[data-dialog-layer], [data-component='dialog-overlay'], [data-component='menu-v2-content']")) return setOpen(false) } const onKey = (e: KeyboardEvent) => { @@ -959,8 +1517,6 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) onCleanup(() => clearTimeout(timer)) }) - let triggerRef: HTMLButtonElement | undefined - return ( <> @@ -984,8 +1540,8 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) aria-label={language.t("sidebar.project.recentSessions")} style={{ position: "fixed", - top: `${(triggerRef?.getBoundingClientRect().bottom ?? 0) + 8}px`, - right: `${document.documentElement.clientWidth - (triggerRef?.getBoundingClientRect().right ?? 0)}px`, + top: `${flyoutPos().top}px`, + right: `${flyoutPos().right}px`, "z-index": "9999", width: "min(440px, 88vw)", "max-height": "min(70vh, 680px)", @@ -1053,6 +1609,41 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
+ {/* modal={false} is load-bearing: a modal Kobalte DropdownMenu + sets document.body { pointer-events: none } while open, so + clicks on the flyout (rendered in a solid Portal under body) + retarget to — which the flyout's outside-click + dismiss handler reads as "outside" and closes the whole + session list. Non-modal keeps body interactive; the menu + still dismisses itself on outside-click via its own layer. */} + + + Sort by + + + + setSortMode(v as SortMode)}> + Recent + A–Z + Machine + + + {/* Tab content */} -
+
0} @@ -1076,7 +1667,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } >
- + {(session) => (
+ + +
@@ -1150,6 +1767,19 @@ function SessionDropdownRow(props: { }) { const language = useLanguage() const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) + // #1525 B1: a remote peer session shows the owner machine name as a badge and + // hides local-only actions (archive is B2). Local/unowned rows are unbadged. + const badge = createMemo(() => deriveSessionBadge(props.session as DropdownSession)) + const isRemote = createMemo(() => isRemotePeerSession(props.session as DropdownSession)) + // #1544 (slice 4): the control state channel for THIS remote row. When control + // is NOT held, the write affordance is DISABLED with a visible reason chip + // (failClosedChip) — never a live erroring button. When held, the owner-routed + // ARCHIVE affordance appears. Delete is NOT offered on an active row: a remote + // session (like a local one) is archived first, then deleted from the Archived + // tab (ArchivedSessionDropdownRow, owner-routed via deleteArchivedSession). + const control = createMemo(() => readSessionControl(props.session as DropdownSession)) + const chip = createMemo(() => failClosedChip(control())) + const canWrite = createMemo(() => writeAffordanceEnabled(control())) const rowServer = useServer() // #1292 hover prewarm: a hovered row is a click away — pull its first // message page the instant the pointer lands, so the open renders from @@ -1196,23 +1826,87 @@ function SessionDropdownRow(props: { {title()} + {/* The owner machine tag — the row's resting identity. On hover the + action cluster to the right reveals IN FLOW (a 0fr→1fr grid column, + so it reserves exactly the controls' rendered width), which shrinks + this button and slides the whole tag left by that width. The tag is + never faded and never truncated on hover — it just moves over to make + room, and the archive stays pinned at the row's right edge. */} + + + + {badge()} + + -
- - } - aria-label={language.t("common.archive")} - onClick={(event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - void props.onArchive(props.session) - }} - /> - -
+ +
+
+
+ + } + aria-label={language.t("common.archive")} + onClick={(event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + void props.onArchive(props.session) + }} + /> + +
+
+
+
+ {/* #1544 (slice 4): a REMOTE peer row's control affordances. Control NOT + held → the write affordance is DISABLED with a visible reason chip + (failClosedChip, derived from the SoT reason) — never a live erroring + button. Control HELD → the owner-routed ARCHIVE. Delete lives on the + Archived tab (remote matches local: archive first, then delete). */} + +
+
+
+ + {(c) => ( + + + {c.label} + + )} + + + + } + aria-label="Archive on peer" + onClick={(event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + void props.onArchive(props.session) + }} + /> + + +
+
+
+
) } @@ -1224,6 +1918,15 @@ function ArchivedSessionDropdownRow(props: { onDelete: (session: Session) => void }) { const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) + // #1647 (S4): archived rows now carry amicode_owner / amicode_control (they + // come from the fleet projection). A remote archived row shows the owner + // badge; its unarchive/delete are control-gated — fail-closed to a lock chip + // when control is not held, exactly like the active SessionDropdownRow. + const badge = createMemo(() => deriveSessionBadge(props.session as DropdownSession)) + const isRemote = createMemo(() => isRemotePeerSession(props.session as DropdownSession)) + const control = createMemo(() => readSessionControl(props.session as DropdownSession)) + const canWrite = createMemo(() => writeAffordanceEnabled(control())) + const chip = createMemo(() => failClosedChip(control())) const [armed, setArmed] = createSignal(false) let resetTimer: ReturnType | undefined @@ -1262,50 +1965,87 @@ function ArchivedSessionDropdownRow(props: { {title()} - -
- - } - aria-label="Unarchive" - onClick={(event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - void props.onUnarchive(props.session) - }} - /> - - - } - aria-label="Delete permanently" - onClick={arm} - /> - - } - > - e.key === "Escape" && disarm()} + {/* Owner tag — the row's resting identity. Same behavior as the active + row: on hover the unarchive/delete cluster reveals IN FLOW (0fr→1fr + grid, reserving the controls' exact rendered width) and slides this + tag left by that width. Never faded, never truncated on hover. */} + + - Delete - + + {badge()} + + +
+
+
+ + {(c) => ( + + + {c.label} + + )} + + } + > + + } + aria-label="Unarchive" + onClick={(event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + void props.onUnarchive(props.session) + }} + /> + + + } + aria-label="Delete permanently" + onClick={arm} + /> + + } + > + e.key === "Escape" && disarm()} + > + Delete + + + +
+
) diff --git a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/settings-v2.css b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/settings-v2.css index 6b22f7c21..b2120fc73 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/settings-v2.css @@ -146,8 +146,8 @@ justify-content: flex-end; } -[data-slot="settings-v2-row-control"] > div:has([data-component="switch"]), -[data-slot="settings-v2-row-control"] > [data-component="switch"] { +[data-slot="settings-v2-row-control"] > div:has([data-component="switch-v2"]), +[data-slot="settings-v2-row-control"] > [data-component="switch-v2"] { display: inline-flex; align-items: center; padding: 4px; diff --git a/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx b/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx index 8fa3b6e9c..4f145eac3 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx @@ -49,6 +49,8 @@ import { authTokenFromCredentials } from "@/utils/server" import { GLOBAL_STATUS_DEFAULT_TAB } from "./status-popover-model" import { useServerProtocol } from "@/context/server-sdk" import { beginSolverSwitch } from "@/components/solver-switch-banner" +import { sendEngineCommand, engineDotClass } from "./engine-toggle-utils" +import { effectiveEngineState, latchStopRequested, installEngineStateListener } from "./engine-state-signal" const pluginEmptyMessage = (value: string, file: string): JSXElement => { const parts = value.split(file) @@ -317,6 +319,16 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( return listServersByHealth(list, server.key, global.servers.health) }) const toggleMcp = useMcpToggle() + + // #1598/#1608: engine lifecycle — the "amicode" MCP row doubles as the engine + // toggle. The state now lives in a GLOBAL always-mounted signal + // (engine-state-signal.ts), so a push arriving while this popover is closed is + // not lost. We read effectiveEngineState() (the delivered state, or the local + // self-expiring "stopping" latch during the click→push gap). The ONE listener + // install is owned by the always-mounted layout (layout-new.tsx); this call is + // idempotent belt-and-braces so the toggle still works if that host changes. + installEngineStateListener() + const engineState = () => effectiveEngineState() const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status @@ -343,6 +355,7 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( return (
+
; onClose?: ( > {(name) => { + // #1598: the "amicode" MCP row IS the engine toggle — its + // dot/switch reflect engine lifecycle state rather than MCP + // connection status, and toggling it starts/stops the engine. + const isEngine = name === "amicode" const status = () => mcpStatus(name) - const enabled = () => status() === "connected" + const enabled = () => (isEngine ? engineState() === "on" : status() === "connected") + const locked = () => + isEngine + ? engineState() === "booting" || engineState() === "stopping" + : toggleMcp.isPending && toggleMcp.variables === name + + const handleToggle = () => { + if (locked()) return + if (isEngine) { + // #1608 AC5: flip off instantly via the local latch + // BEFORE the extension round-trip, so the switch reflects + // intent immediately. The delivered push reconciles it. + if (engineState() === "on") latchStopRequested() + sendEngineCommand(engineState()) + } else { + toggleMcp.mutate(name) + } + } + + const dotClass = (): string => { + if (isEngine) return engineDotClass(engineState()) + const s = status() + if (s === "connected") return "bg-icon-success-base" + if (s === "failed") return "bg-icon-critical-base" + if (s === "needs_auth" || s === "needs_client_registration") return "bg-icon-warning-base" + return "bg-border-weak-base" + } + return ( @@ -556,6 +598,7 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( +
) } diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts b/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts index ebbcf8340..9d69b76bd 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts @@ -107,6 +107,32 @@ describe("titlebar layout", () => { expect(isSessionScoped("profile")).toBe(false) expect(isSessionScoped("settings")).toBe(false) }) + + // ── #1442 AC3: fleet-studio titlebar-unchanged contract ────────────────── + // The fleet studio slice (session-header provenance, machine picker, Preview + // remote edit) adds NO controls to the titlebar. The session-header provenance + // icon + caret live in the data-session-title sticky header, not the titlebar. + test("fleet studio adds no titlebar controls — TITLEBAR_CONTROL_IDS is exactly the canonical five (#1442 AC3)", () => { + // The canonical set is exactly these five, in this order + expect(TITLEBAR_CONTROL_IDS).toEqual(["sessions", "status", "side-panel", "profile", "settings"]) + // No fleet/machine/provenance control was added + const ids = [...TITLEBAR_CONTROL_IDS] + expect(ids).not.toContain("machine") + expect(ids).not.toContain("fleet") + expect(ids).not.toContain("provenance") + expect(ids.length).toBe(5) + }) + + test("fleet studio does not change the default layout (#1442 AC3)", () => { + expect(defaultTitlebarLayout).toEqual({ + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + }) + // The union of left + right is exactly TITLEBAR_CONTROL_IDS (no new entries) + const all = [...defaultTitlebarLayout.left, ...defaultTitlebarLayout.right] + expect(all.length).toBe(TITLEBAR_CONTROL_IDS.length) + expect(new Set(all)).toEqual(new Set(TITLEBAR_CONTROL_IDS)) + }) }) describe("edit mode state", () => { diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-portal-dedup.test.ts b/packages/app-bundle/overlay/packages/app/src/components/titlebar-portal-dedup.test.ts new file mode 100644 index 000000000..79d7d8b17 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-portal-dedup.test.ts @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { + claimPortalMount, + isPortalOwner, + releasePortalMount, + _resetPortalRegistry, +} from "./titlebar-portal-registry" + +afterEach(() => { + _resetPortalRegistry() +}) + +describe("#1577 titlebar portal dedup — registry core", () => { + test("claimPortalMount returns a unique token on each call", () => { + const t1 = claimPortalMount("sessions") + const t2 = claimPortalMount("sessions") + const t3 = claimPortalMount("status") + expect(t1).not.toBe(t2) + expect(t2).not.toBe(t3) + expect(t1).not.toBe(t3) + }) + + test("the latest claimant is the owner — prior claimant is not", () => { + createRoot((dispose) => { + const t1 = claimPortalMount("sessions") + expect(isPortalOwner("sessions", t1)).toBe(true) + + // A second claim ejects the first + const t2 = claimPortalMount("sessions") + expect(isPortalOwner("sessions", t2)).toBe(true) + expect(isPortalOwner("sessions", t1)).toBe(false) + dispose() + }) + }) + + test("different mount points are independent", () => { + createRoot((dispose) => { + const tSessions = claimPortalMount("sessions") + const tStatus = claimPortalMount("status") + expect(isPortalOwner("sessions", tSessions)).toBe(true) + expect(isPortalOwner("status", tStatus)).toBe(true) + + // Claiming sessions again doesn't affect status + const tSessions2 = claimPortalMount("sessions") + expect(isPortalOwner("sessions", tSessions2)).toBe(true) + expect(isPortalOwner("sessions", tSessions)).toBe(false) + expect(isPortalOwner("status", tStatus)).toBe(true) // unchanged + dispose() + }) + }) + + test("releasePortalMount clears ownership when token matches", () => { + createRoot((dispose) => { + const t1 = claimPortalMount("sessions") + expect(isPortalOwner("sessions", t1)).toBe(true) + + releasePortalMount("sessions", t1) + expect(isPortalOwner("sessions", t1)).toBe(false) + dispose() + }) + }) + + test("releasePortalMount is a no-op when token is stale", () => { + createRoot((dispose) => { + const t1 = claimPortalMount("side-panel") + const t2 = claimPortalMount("side-panel") + + // t1 is stale — releasing it must NOT eject t2 + releasePortalMount("side-panel", t1) + expect(isPortalOwner("side-panel", t2)).toBe(true) + dispose() + }) + }) +}) + +describe("#1577 AC2 — repeated focus switches never accumulate owners", () => { + test("cycling claim/release simulating local↔remote↔local always has exactly one owner", () => { + createRoot((dispose) => { + const ids = ["sessions", "status", "side-panel"] as const + + // Simulate 5 focus switches (new SessionHeader mounts, old unmounts) + let prevTokens: number[] = [] + for (let i = 0; i < 5; i++) { + // Release previous tokens (old component's onCleanup) + for (let j = 0; j < prevTokens.length; j++) { + releasePortalMount(ids[j], prevTokens[j]) + } + + // Claim all three mount points (new component mounts) + const newTokens = ids.map((id) => claimPortalMount(id)) + + // Exactly one owner per mount point + for (let j = 0; j < ids.length; j++) { + expect(isPortalOwner(ids[j], newTokens[j])).toBe(true) + } + + prevTokens = newTokens + } + dispose() + }) + }) + + test("concurrent mount (startTransition): new claim ejects old before old cleans up", () => { + createRoot((dispose) => { + // Simulate: old SessionHeader is still mounted, new one mounts + const oldToken = claimPortalMount("sessions") + expect(isPortalOwner("sessions", oldToken)).toBe(true) + + // New SessionHeader mounts — claims the same mount point + const newToken = claimPortalMount("sessions") + expect(isPortalOwner("sessions", newToken)).toBe(true) + expect(isPortalOwner("sessions", oldToken)).toBe(false) + + // Eventually old SessionHeader unmounts — release is a no-op + releasePortalMount("sessions", oldToken) + expect(isPortalOwner("sessions", newToken)).toBe(true) // still owner + dispose() + }) + }) +}) + +describe("#1577 AC1 — ownership transfer gives exactly one winner per mount", () => { + test("isPortalOwner returns false for a stale token immediately after a new claim", () => { + createRoot((dispose) => { + const t1 = claimPortalMount("sessions") + expect(isPortalOwner("sessions", t1)).toBe(true) + + // Simulate a second SessionHeader mounting — claims the same point + const t2 = claimPortalMount("sessions") + // Stale token loses, new token wins — the read is instant + expect(isPortalOwner("sessions", t1)).toBe(false) + expect(isPortalOwner("sessions", t2)).toBe(true) + dispose() + }) + }) + + test("all three session-scoped mount points transfer ownership atomically", () => { + createRoot((dispose) => { + const ids = ["sessions", "status", "side-panel"] as const + + // Old SessionHeader claims all three + const oldTokens = ids.map((id) => claimPortalMount(id)) + for (let i = 0; i < ids.length; i++) { + expect(isPortalOwner(ids[i], oldTokens[i])).toBe(true) + } + + // New SessionHeader claims all three — old loses everywhere + const newTokens = ids.map((id) => claimPortalMount(id)) + for (let i = 0; i < ids.length; i++) { + expect(isPortalOwner(ids[i], oldTokens[i])).toBe(false) + expect(isPortalOwner(ids[i], newTokens[i])).toBe(true) + } + dispose() + }) + }) +}) + +describe("#1577 session-header wiring — the registry is consumed", () => { + test("session-header imports and uses the portal registry for claim/release/ownership", async () => { + const { readFileSync } = await import("node:fs") + const { resolve } = await import("node:path") + const source = readFileSync(resolve(__dirname, "session/session-header.tsx"), "utf8") + + // Must import the registry + expect(source).toContain("claimPortalMount") + expect(source).toContain("isPortalOwner") + expect(source).toContain("releasePortalMount") + + // Must claim the three session-scoped mount points + expect(source).toContain('claimPortalMount("sessions")') + expect(source).toContain('claimPortalMount("status")') + expect(source).toContain('claimPortalMount("side-panel")') + + // Must gate portal rendering on ownership + expect(source).toContain('isPortalOwner("sessions"') + expect(source).toContain('isPortalOwner("status"') + expect(source).toContain('isPortalOwner("side-panel"') + + // Must release on cleanup + expect(source).toContain("releasePortalMount(") + expect(source).toContain("onCleanup(") + }) +}) + +describe("#1577 AC3 — no net-new /event subscriber", () => { + test("the registry module does not import event, fetch, or subscription machinery", async () => { + // Source-string assertion: the registry is pure signals + Map, + // no network or event-bus imports + const { readFileSync } = await import("node:fs") + const { resolve } = await import("node:path") + const source = readFileSync(resolve(__dirname, "titlebar-portal-registry.ts"), "utf8") + + expect(source).not.toContain("addEventListener") + expect(source).not.toContain("EventSource") + expect(source).not.toContain("/event") + expect(source).not.toContain("fetch(") + expect(source).not.toContain("createResource") + expect(source).not.toContain("useServer") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-portal-registry.ts b/packages/app-bundle/overlay/packages/app/src/components/titlebar-portal-registry.ts new file mode 100644 index 000000000..501288745 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-portal-registry.ts @@ -0,0 +1,64 @@ +// #1577: Singleton portal-owner registry — enforces at most one portal source +// per session-scoped titlebar mount point. During `startTransition`, two +// SessionHeader components can be mounted simultaneously; without this, both +// render portals into the same mount-point div, doubling controls. +// +// The registry maps each mount-point ID to a reactive signal holding the +// current owner's token. When a new owner claims a mount point, the signal +// updates, and the prior owner's `` reactively +// evaluates to false — ejecting its portal content without DOM manipulation. + +import { createSignal } from "solid-js" +import type { TitlebarControlId } from "./titlebar-layout" +import { mountPointId } from "./titlebar-layout" + +let _nextToken = 0 + +// Per-mount-point signal: holds the token of the current owner. +// Lazily created on first claim. +const _ownerSignals = new Map>>() + +function getOwnerSignal(key: string) { + let entry = _ownerSignals.get(key) + if (!entry) { + entry = createSignal(0) + _ownerSignals.set(key, entry) + } + return entry +} + +/** Claim exclusive ownership of a titlebar mount point. + * Returns a numeric token identifying this owner. Any prior owner's + * `isPortalOwner` check reactively becomes false. */ +export function claimPortalMount(id: TitlebarControlId): number { + const token = ++_nextToken + const [, setOwner] = getOwnerSignal(mountPointId(id)) + setOwner(token) + return token +} + +/** Reactive ownership check — returns true only when `token` is the + * current owner of the mount point. Reads the underlying signal, so + * it tracks in `createEffect`, `createMemo`, and ``. */ +export function isPortalOwner(id: TitlebarControlId, token: number): boolean { + const [owner] = getOwnerSignal(mountPointId(id)) + return owner() === token +} + +/** Release ownership. A no-op if `token` is not the current owner + * (e.g. a stale component unmounting after a newer one claimed). */ +export function releasePortalMount(id: TitlebarControlId, token: number): void { + const key = mountPointId(id) + const entry = _ownerSignals.get(key) + if (!entry) return + const [owner, setOwner] = entry + if (owner() === token) { + setOwner(0) // no owner + } +} + +/** Reset the registry — for tests only. */ +export function _resetPortalRegistry(): void { + _ownerSignals.clear() + _nextToken = 0 +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx index e012fc516..b256fd775 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-nav.tsx @@ -22,6 +22,8 @@ export function TabNavItem(props: { server: ServerConnection.Key session: () => Session | undefined fallbackTitle?: string + drivingMachineId?: () => string | undefined + drivenByMachine?: () => { machineId: string; machineName: string } | undefined onRename: (title: string) => Promise onClose: () => void onNavigate: () => void @@ -221,7 +223,7 @@ export function TabNavItem(props: { }} class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" > - + )} + + {(machineId) => ( + + + + )} + + + {(machine) => ( + + + + )} + { diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx index 5c14f9c73..0d4da88c7 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-tab-strip.tsx @@ -20,6 +20,8 @@ import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" import type { Session } from "@opencode-ai/sdk/v2" +import { useControlProjectionForConnection } from "@/components/session/session-fleet-control-projection" +import { drivingBannerFromProjection, drivenByBannerFromProjection } from "@/components/session/session-fleet-peers" function SessionTabSlot(props: { tab: SessionTab @@ -29,6 +31,8 @@ function SessionTabSlot(props: { forceTruncate: boolean session: () => Session | undefined fallbackTitle?: string + drivingMachineId?: () => string | undefined + drivenByMachine?: () => { machineId: string; machineName: string } | undefined onRename: (title: string) => Promise onNavigate: (element: HTMLDivElement) => void onClose: () => void @@ -59,6 +63,8 @@ function SessionTabSlot(props: { server={props.tab.server} session={props.session} fallbackTitle={props.fallbackTitle} + drivingMachineId={props.drivingMachineId} + drivenByMachine={props.drivenByMachine} onRename={props.onRename} onNavigate={() => props.onNavigate(ref)} onClose={props.onClose} @@ -77,6 +83,8 @@ function SessionTabEntry(props: { active: () => boolean forceTruncate: boolean serverCtx: () => ServerCtx | undefined + drivingMachineId?: () => string | undefined + drivenByMachine?: () => { machineId: string; machineName: string } | undefined onVisibleChange: (visible: boolean) => void onNavigate: (element: HTMLDivElement) => void onClose: () => void @@ -158,6 +166,8 @@ function SessionTabEntry(props: { active={props.active} forceTruncate={props.forceTruncate} session={session} + drivingMachineId={props.drivingMachineId} + drivenByMachine={props.drivenByMachine} fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : loadedSession.loading ? "\u2026" : undefined)} onRename={rename} onNavigate={props.onNavigate} @@ -221,6 +231,14 @@ export function TitlebarTabStrip(props: { const global = useGlobal() const language = useLanguage() const command = useCommand() + + // Fleet driving indicator: acquire the shared control projection (same + // ref-counted singleton the session header's composer scrim consumes — no + // duplicate polling). For each session tab we derive a drivingMachineId; + // the tab renders a monitor icon when non-null. + const primaryConn = createMemo(() => global.servers.list()[0]) + const controlProjection = useControlProjectionForConnection(primaryConn) + let scrollRef!: HTMLDivElement let listRef!: HTMLDivElement let resizeFrame: number | undefined @@ -346,6 +364,14 @@ export function TitlebarTabStrip(props: { }) if (tab.type === "session") { + const drivingMachineId = createMemo(() => { + const driving = drivingBannerFromProjection(controlProjection(), tab.sessionId) + return driving?.machineId + }) + const drivenByMachine = createMemo(() => { + const driven = drivenByBannerFromProjection(controlProjection(), tab.sessionId) + return driven ?? undefined + }) return ( props.currentTab() === tab} forceTruncate={props.forceTruncate} serverCtx={serverCtx} + drivingMachineId={drivingMachineId} + drivenByMachine={drivenByMachine} onVisibleChange={(visible) => setVisibility(id, visible)} onNavigate={(element) => { ref = element diff --git a/packages/app-bundle/overlay/packages/app/src/context/directory-sync.ts b/packages/app-bundle/overlay/packages/app/src/context/directory-sync.ts index e8e9b2978..a66eea214 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/directory-sync.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/directory-sync.ts @@ -119,6 +119,17 @@ export const createDirSyncContext = ( await serverSync.session.sync(sessionID, options) index(sessionID) }, + // #1647 S7: an INFO-ONLY forced refresh (server session.get → remember; NO + // message re-fetch), delegating to the server store's `resolve`. The + // directory store previously omitted this, so the composer's archived-flag + // convergence effect (session-composer-region-controller.ts) called a + // nonexistent `.resolve` and silently no-op'd (threw each interval tick, + // caught/ignored) — the owner-side prompt flip never fired. Mirrors `sync` + // above but never loads messages, so it is safe to poll while a stream is live. + async resolve(sessionID: string, options?: { force?: boolean }) { + await serverSync.session.resolve(sessionID, options) + index(sessionID) + }, // #1297: the render path hydrates from the mirror WITHOUT joining // an in-flight task — see createServerSession's hydrate. async hydrate(sessionID: string) { diff --git a/packages/app-bundle/overlay/packages/app/src/context/fleet-reseed-scheduler.test.ts b/packages/app-bundle/overlay/packages/app/src/context/fleet-reseed-scheduler.test.ts new file mode 100644 index 000000000..89f32432f --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/fleet-reseed-scheduler.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, test } from "bun:test" +import { + FLEET_RESEED_INTERVAL_MS, + FLEET_PIN_DEBOUNCE_MS, + FLEET_UNKNOWN_RETRY_MS, + FLEET_UNKNOWN_RETRY_MAX, + createFleetReseedScheduler, + viewedSessionIdFromLocation, + type FleetReseedSchedulerDeps, +} from "./fleet-reseed-scheduler" +import { createFleetDiagnostics, runFleetReseed, type FleetReseedOutcome } from "./fleet-session-status" + +// amicode#1766 checkpoint 5 — the fleet re-seed TRIGGERS (connected edge, gap +// frame, debounced new pin, `unknown` retries) and the 10 s SELF-HEAL scheduler +// (visibility-gated, single-flight with pending coalescing, no-pins skip, +// consecutive-failure diagnostics, teardown) — driven by an injected fake clock. + +// ── a deterministic fake clock for the injected timers ─────────────────────── +function fakeClock() { + let now = 0 + let seq = 0 + const timers = new Map void }>() + const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve() + } + return { + now: () => now, + timers: { + setTimeout: (fn: () => void, ms: number) => { + const id = ++seq + timers.set(id, { at: now + Math.max(0, ms), fn }) + return id + }, + clearTimeout: (id: unknown) => { + timers.delete(id as number) + }, + }, + pending: () => timers.size, + async advance(ms: number) { + const target = now + ms + await flush() + for (;;) { + let next: [number, { at: number; fn: () => void }] | undefined + for (const e of timers) if (e[1].at <= target && (!next || e[1].at < next[1].at)) next = e + if (!next) break + timers.delete(next[0]) + now = next[1].at + next[1].fn() + await flush() + } + now = target + await flush() + }, + flush, + } +} + +const applied = (requested: readonly string[], over: { statuses?: Record; unresolved?: Array<{ id: string; reason: string }>; sources?: Record } = {}): FleetReseedOutcome => + ({ + kind: "applied", + result: { + kind: "ok", + statuses: over.statuses ?? Object.fromEntries(requested.map((id) => [id, { type: "idle" }])), + unresolved: over.unresolved ?? [], + sources: over.sources ?? {}, + dropped: 0, + }, + }) as FleetReseedOutcome + +/** A harness: scripted reseed results, observable calls, controllable gates. */ +function harness(over: Partial & { pins?: string[]; respond?: (ids: readonly string[], n: number) => FleetReseedOutcome | Promise } = {}) { + const clock = fakeClock() + const state = { pins: over.pins ?? ["ses_a", "ses_b"], connected: true, visible: true, viewed: undefined as string | undefined } + const calls: string[][] = [] + const local: number[] = [] + const warned: string[] = [] + let inFlight = 0 + let maxInFlight = 0 + const deps: FleetReseedSchedulerDeps = { + pinnedIds: () => state.pins, + viewedId: () => state.viewed, + reseed: async (ids) => { + calls.push([...ids]) + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + try { + return await (over.respond ?? ((i) => applied(i)))(ids, calls.length) + } finally { + inFlight-- + } + }, + localReseed: async () => { + local.push(clock.now()) + }, + isConnected: () => state.connected, + isVisible: () => state.visible, + diagnostics: createFleetDiagnostics({ warn: (m) => warned.push(m), now: clock.now, windowMs: 1 }), + timers: clock.timers, + now: clock.now, + ...over, + } + const scheduler = createFleetReseedScheduler(deps) + return { clock, state, calls, local, warned, scheduler, maxInFlight: () => maxInFlight } +} + +describe("#1766 — fleet re-seed triggers", () => { + test("the connected edge and a gap frame each run a fleet re-seed of the pinned ids immediately", async () => { + const h = harness() + h.scheduler.trigger("connected") + await h.clock.flush() + expect(h.calls).toEqual([["ses_a", "ses_b"]]) + h.scheduler.trigger("gap") + await h.clock.flush() + expect(h.calls).toHaveLength(2) + }) + + test("the viewed session is sent FIRST", async () => { + const h = harness({ pins: ["ses_a", "ses_b", "ses_c"] }) + h.state.viewed = "ses_c" + h.scheduler.trigger("connected") + await h.clock.flush() + expect(h.calls[0]).toEqual(["ses_c", "ses_a", "ses_b"]) + }) + + test("a viewed id that is not pinned is never added", async () => { + const h = harness({ pins: ["ses_a"] }) + h.state.viewed = "ses_x" + h.scheduler.trigger("connected") + await h.clock.flush() + expect(h.calls[0]).toEqual(["ses_a"]) + }) + + test("after the viewed one, ids go least-recently-reseeded first (an id left uncovered moves to the front)", async () => { + const h = harness({ + pins: ["s1", "s2", "s3"], + // the first read covers only s1 (s2, s3 omitted — e.g. past a cap) + respond: (ids, n) => (n === 1 ? applied(ids, { statuses: { s1: { type: "idle" } } }) : applied(ids)), + }) + h.scheduler.trigger("connected") + await h.clock.flush() + h.scheduler.trigger("gap") + await h.clock.flush() + expect(h.calls[1]).toEqual(["s2", "s3", "s1"]) + }) + + test(`a NEWLY pinned session triggers a re-seed debounced by ${FLEET_PIN_DEBOUNCE_MS} ms (a burst of pins → one read)`, async () => { + const h = harness() + h.scheduler.start() + h.scheduler.notePinned("ses_a") + await h.clock.advance(FLEET_PIN_DEBOUNCE_MS - 1) + h.scheduler.notePinned("ses_b") // restarts the debounce + await h.clock.advance(FLEET_PIN_DEBOUNCE_MS - 1) + expect(h.calls).toHaveLength(0) + await h.clock.advance(1) + expect(h.calls).toHaveLength(1) + h.scheduler.stop() + }) + + test("a no-pins connected edge / gap frame performs today's LOCAL re-seed (no fleet read)", async () => { + const h = harness({ pins: [] }) + h.scheduler.trigger("connected") + h.scheduler.trigger("gap") + await h.clock.flush() + expect(h.calls).toEqual([]) + expect(h.local.length).toBeGreaterThanOrEqual(1) + }) +}) + +describe("#1766 — unknown retries (cold owner map)", () => { + test(`a response listing a pinned id as unresolved \`unknown\` retries after ${FLEET_UNKNOWN_RETRY_MS} ms, at most ${FLEET_UNKNOWN_RETRY_MAX} times per trigger`, async () => { + expect(FLEET_UNKNOWN_RETRY_MS).toBe(2000) + expect(FLEET_UNKNOWN_RETRY_MAX).toBe(3) + const h = harness({ respond: (ids) => applied(ids, { statuses: {}, unresolved: [{ id: "ses_a", reason: "unknown" }] }) }) + h.scheduler.trigger("connected") + await h.clock.flush() + expect(h.calls).toHaveLength(1) + await h.clock.advance(FLEET_UNKNOWN_RETRY_MS - 1) + expect(h.calls).toHaveLength(1) + await h.clock.advance(1) + expect(h.calls).toHaveLength(2) + await h.clock.advance(FLEET_UNKNOWN_RETRY_MS * 5) + expect(h.calls).toHaveLength(1 + FLEET_UNKNOWN_RETRY_MAX) // the trigger + 3 retries, then it stops + }) + + test("retries stop as soon as the owner map has caught up (no unknown left)", async () => { + const h = harness({ + respond: (ids, n) => (n === 1 ? applied(ids, { statuses: {}, unresolved: [{ id: "ses_a", reason: "unknown" }] }) : applied(ids)), + }) + h.scheduler.trigger("connected") + await h.clock.advance(FLEET_UNKNOWN_RETRY_MS * 10) + expect(h.calls).toHaveLength(2) + }) + + test("over-cap / no-directory unresolved ids do NOT trigger the short retry", async () => { + const h = harness({ + respond: (ids) => applied(ids, { statuses: {}, unresolved: [{ id: "ses_a", reason: "over-cap" }, { id: "ses_b", reason: "no-directory" }] }), + }) + h.scheduler.trigger("connected") + await h.clock.advance(FLEET_UNKNOWN_RETRY_MS * 3) + expect(h.calls).toHaveLength(1) + }) + + // PR #1768 review (CodeRabbit): the retry budget is per TRIGGER (edge / new + // pin) — a self-heal TICK must not re-arm it. A pinned id that stays + // `unknown` forever (a tab for a deleted session, or for a peer that stopped + // serving) would otherwise cost the tick's read + 3 retries every interval. + test("a permanently-unknown pinned id: self-heal ticks cost ONE read per interval (ticks do not re-arm the retry budget)", async () => { + const h = harness({ respond: (ids) => applied(ids, { statuses: {}, unresolved: [{ id: "ses_a", reason: "unknown" }] }) }) + h.scheduler.start() + h.scheduler.trigger("connected") + // the edge + its 3 retries drain well inside the first interval + await h.clock.advance(FLEET_UNKNOWN_RETRY_MS * (FLEET_UNKNOWN_RETRY_MAX + 1)) + expect(h.calls).toHaveLength(1 + FLEET_UNKNOWN_RETRY_MAX) + const afterEdge = h.calls.length + // three more intervals: exactly one read per tick, no retry bursts + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 3) + expect(h.calls.length - afterEdge).toBe(3) + h.scheduler.stop() + }) + + test("a NEW trigger (edge / pin) after ticks re-arms the retry budget", async () => { + const h = harness({ respond: (ids) => applied(ids, { statuses: {}, unresolved: [{ id: "ses_a", reason: "unknown" }] }) }) + h.scheduler.start() + h.scheduler.trigger("connected") + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 2) // edge + 3 retries, then 2 ticks + const before = h.calls.length + h.scheduler.trigger("gap") // a fresh trigger + await h.clock.advance(FLEET_UNKNOWN_RETRY_MS * (FLEET_UNKNOWN_RETRY_MAX + 1)) + expect(h.calls.length - before).toBe(1 + FLEET_UNKNOWN_RETRY_MAX) + h.scheduler.stop() + }) +}) + +describe("#1766 — self-heal scheduler", () => { + test(`repeats the fleet re-seed every ${FLEET_RESEED_INTERVAL_MS} ms while connected and visible`, async () => { + expect(FLEET_RESEED_INTERVAL_MS).toBe(10_000) + const h = harness() + h.scheduler.start() + await h.clock.advance(FLEET_RESEED_INTERVAL_MS - 1) + expect(h.calls).toHaveLength(0) + await h.clock.advance(1) + expect(h.calls).toHaveLength(1) + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 2) + expect(h.calls).toHaveLength(3) + h.scheduler.stop() + }) + + test("pauses while the document is hidden, resumes when visible", async () => { + const h = harness() + h.scheduler.start() + h.state.visible = false + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 3) + expect(h.calls).toHaveLength(0) + h.state.visible = true + await h.clock.advance(FLEET_RESEED_INTERVAL_MS) + expect(h.calls).toHaveLength(1) + h.scheduler.stop() + }) + + test("does not tick while the event stream is disconnected", async () => { + const h = harness() + h.scheduler.start() + h.state.connected = false + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 2) + expect(h.calls).toHaveLength(0) + h.scheduler.stop() + }) + + test("does not run when there are no pinned sessions (and does no local re-seed either)", async () => { + const h = harness({ pins: [] }) + h.scheduler.start() + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 3) + expect(h.calls).toEqual([]) + expect(h.local).toEqual([]) + h.scheduler.stop() + }) + + test("never runs two re-seeds at once: triggers during a flight coalesce into exactly ONE more run", async () => { + let release!: () => void + const gate = new Promise((r) => (release = r)) + const h = harness({ + respond: async (ids, n) => { + if (n === 1) await gate + return applied(ids) + }, + }) + h.scheduler.trigger("connected") + await h.clock.flush() + h.scheduler.trigger("gap") + h.scheduler.trigger("gap") + h.scheduler.trigger("connected") + await h.clock.flush() + expect(h.calls).toHaveLength(1) // still in flight + release() + await h.clock.flush() + expect(h.calls).toHaveLength(2) // exactly one coalesced follow-up + expect(h.maxInFlight()).toBe(1) + }) + + test("a HUNG fleet read is cut by the 5 s client timeout (late response discarded) and cannot block the next tick", async () => { + const clock = fakeClock() + const reconciled: Array> = [] + let lateResolve!: (v: { status: number; body: unknown }) => void + let fetches = 0 + const diagnostics = createFleetDiagnostics({ warn: () => {}, now: clock.now }) + const scheduler = createFleetReseedScheduler({ + pinnedIds: () => ["ses_a"], + reseed: (ids, signal) => + runFleetReseed(ids, { + fetchFleet: () => { + fetches++ + if (fetches === 1) return new Promise((r) => (lateResolve = r)) // hangs, ignores abort + return Promise.resolve({ status: 200, body: { ok: true, statuses: { ses_a: { type: "busy" } }, sources: {}, unresolved: [] } }) + }, + reconcile: (s) => reconciled.push(s), + localReseed: async () => {}, + diagnostics, + now: clock.now, + timers: clock.timers, + signal, + }), + localReseed: async () => {}, + isConnected: () => true, + isVisible: () => true, + diagnostics, + timers: clock.timers, + now: clock.now, + }) + scheduler.start() + await clock.advance(FLEET_RESEED_INTERVAL_MS) // tick 1: the hung read + expect(fetches).toBe(1) + await clock.advance(5000) // the client timeout frees the scheduler + lateResolve({ status: 200, body: { ok: true, statuses: { ses_a: { type: "idle" } }, sources: {}, unresolved: [] } }) + await clock.flush() + expect(reconciled).toEqual([]) // the late response is discarded, never applied + await clock.advance(FLEET_RESEED_INTERVAL_MS) // tick 2 runs (not blocked) + expect(fetches).toBe(2) + expect(reconciled).toEqual([{ ses_a: { type: "busy" } }]) + scheduler.stop() + }) + + test("after the read is known NOT SERVED (no fleet peers), self-heal and pin triggers stop — the app keeps today's behavior", async () => { + const h = harness({ respond: () => ({ kind: "fallback", cause: "not-served" }) }) + h.scheduler.start() + h.scheduler.trigger("connected") + await h.clock.flush() + expect(h.calls).toHaveLength(1) + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 3) + h.scheduler.notePinned("ses_new") + await h.clock.advance(FLEET_PIN_DEBOUNCE_MS * 2) + expect(h.calls).toHaveLength(1) // no periodic or pin-driven reads on a fleet-less machine + h.scheduler.trigger("connected") // the existing edges still re-probe (→ local fallback) + await h.clock.flush() + expect(h.calls).toHaveLength(2) + h.scheduler.stop() + }) + + test("emits a diagnostic after 3 consecutive re-seeds in which every requested id was omitted or unresolved", async () => { + let covered = false + const h = harness({ + respond: (ids) => + covered ? applied(ids) : applied(ids, { statuses: {}, unresolved: [{ id: ids[0], reason: "over-cap" }] }), + }) + h.scheduler.trigger("connected") + await h.clock.flush() + h.scheduler.trigger("gap") + await h.clock.flush() + expect(h.warned.filter((w) => w.includes("all-omitted"))).toHaveLength(0) + h.scheduler.trigger("gap") + await h.clock.flush() + expect(h.warned.filter((w) => w.includes("all-omitted"))).toHaveLength(1) + covered = true // one covered read resets the streak + h.scheduler.trigger("gap") + await h.clock.flush() + covered = false + for (let i = 0; i < 2; i++) { + h.scheduler.trigger("gap") + await h.clock.flush() + } + expect(h.warned.filter((w) => w.includes("all-omitted"))).toHaveLength(1) + }) + + test("a partial failure persisting 3 consecutive re-seeds for the same (owner, directory) is diagnosed", async () => { + const failing = { studio: { present: true, directories: { "/p": { ok: false, reason: "timeout", sessions: 1 } } } } + const h = harness({ respond: (ids) => applied(ids, { statuses: { ses_a: { type: "idle" } }, sources: failing }) }) + for (let i = 0; i < 2; i++) { + h.scheduler.trigger("gap") + await h.clock.flush() + } + expect(h.warned.filter((w) => w.includes("studio") && w.includes("/p"))).toHaveLength(0) + h.scheduler.trigger("gap") + await h.clock.flush() + const hit = h.warned.filter((w) => w.includes("studio") && w.includes("/p")) + expect(hit).toHaveLength(1) + expect(hit[0]).toContain("timeout") + }) + + test("stops on teardown: no ticks, no debounced or retry runs, and an in-flight result is not followed up", async () => { + let release!: () => void + const gate = new Promise((r) => (release = r)) + const h = harness({ + respond: async (ids, n) => { + if (n === 1) await gate + return applied(ids, { statuses: {}, unresolved: [{ id: "ses_a", reason: "unknown" }] }) + }, + }) + h.scheduler.start() + h.scheduler.trigger("connected") + await h.clock.flush() + h.scheduler.trigger("gap") // pending + h.scheduler.notePinned("ses_z") // debounce armed + h.scheduler.stop() + expect(h.clock.pending()).toBe(0) // every timer (tick, debounce) cleared AT teardown + release() + await h.clock.advance(FLEET_RESEED_INTERVAL_MS * 5) + expect(h.calls).toHaveLength(1) // nothing after teardown: no pending run, no retry, no tick, no debounce + expect(h.clock.pending()).toBe(0) // and none re-armed by the settling in-flight read (no retry) + h.scheduler.trigger("connected") // a stopped scheduler ignores triggers + await h.clock.flush() + expect(h.calls).toHaveLength(1) + }) +}) + +describe("#1766 — viewedSessionIdFromLocation", () => { + test.each([ + ["/server/aHR0cA/session/ses_abc", "ses_abc"], + ["/L1VzZXJzL2pq/session/ses_def?x=1", "ses_def"], + ["/#/server/aHR0cA/session/ses_hash", "ses_hash"], + ["/server/aHR0cA/session/ses_sub/files", "ses_sub"], + ["/new-session?draftId=1", undefined], + ["/", undefined], + ])("%s → %s", (href, id) => { + expect(viewedSessionIdFromLocation(href)).toBe(id) + }) +}) + +describe("#1766 — runFleetReseed teardown signal", () => { + test("an outer (teardown) abort cancels the read: nothing applied, no local fallback", async () => { + const ac = new AbortController() + const seen = { reconcile: 0, local: 0 } + const p = runFleetReseed(["a"], { + fetchFleet: () => new Promise(() => {}), + reconcile: () => seen.reconcile++, + localReseed: async () => { + seen.local++ + }, + diagnostics: createFleetDiagnostics({ warn: () => {} }), + signal: ac.signal, + }) + ac.abort() + expect(await p).toEqual({ kind: "cancelled" }) + expect(seen).toEqual({ reconcile: 0, local: 0 }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/fleet-reseed-scheduler.ts b/packages/app-bundle/overlay/packages/app/src/context/fleet-reseed-scheduler.ts new file mode 100644 index 000000000..b3483cd5d --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/fleet-reseed-scheduler.ts @@ -0,0 +1,249 @@ +// amicode#1766 — the fleet session-status RE-SEED SCHEDULER (pure; timers and +// every input injected, so it is fake-clock testable without Solid). +// +// Triggers (each sends the pinned ids, the viewed session first, to the fleet +// read and applies the answer through the recency-guarded reconcile): +// - the event stream's CONNECTED edge and a SYNC-GAP frame (the #1637 edges); +// - a NEWLY PINNED session, debounced (covers tabs restored after the +// connected edge already fired); +// - `unknown` RETRIES: an answer listing a pinned id as unresolved `unknown` +// (the service's owner map may lag a cold start) re-tries after 2 s, at most +// 3 times per trigger; +// - the SELF-HEAL tick: every 10 s while connected AND visible, with pinned +// sessions, on a machine where the read is served. +// Discipline: single-flight (a trigger during a flight sets a pending flag that +// runs exactly ONE more re-seed when the current one settles); each read is +// bounded by the client timeout inside runFleetReseed; teardown stops all of it. +// Diagnostics: 3 consecutive re-seeds in which EVERY requested id was omitted or +// unresolved, and a partial failure persisting 3 consecutive re-seeds for the +// same (owner, directory). +import type { FleetDiagnostics, FleetReseedOutcome, FleetTimers } from "./fleet-session-status" +import { defaultFleetTimers } from "./fleet-session-status" + +export const FLEET_RESEED_INTERVAL_MS = 10_000 +export const FLEET_PIN_DEBOUNCE_MS = 500 +export const FLEET_UNKNOWN_RETRY_MS = 2000 +export const FLEET_UNKNOWN_RETRY_MAX = 3 +/** Consecutive re-seeds before the all-omitted / partial-failure diagnostics. */ +export const FLEET_FAILURE_STREAK = 3 + +export type FleetReseedTrigger = "connected" | "gap" +type RunKind = "edge" | "tick" | "pin" | "retry" + +export type FleetReseedSchedulerDeps = { + /** The currently pinned session ids (every open tab's + the viewed one). */ + pinnedIds: () => readonly string[] + /** The viewed session id, if any — sent first. */ + viewedId?: () => string | undefined + /** One fleet re-seed (runFleetReseed); `signal` aborts on teardown. */ + reseed: (ids: readonly string[], signal: AbortSignal) => Promise + /** Today's local re-seed — the edges' behavior when nothing is pinned. */ + localReseed: () => Promise + isConnected: () => boolean + isVisible: () => boolean + diagnostics: FleetDiagnostics + timers?: FleetTimers + now?: () => number + intervalMs?: number + pinDebounceMs?: number + unknownRetryMs?: number + unknownRetryMax?: number +} + +export type FleetReseedScheduler = { + /** Arm the self-heal tick. */ + start(): void + /** Teardown: clear every timer, cancel the in-flight read, ignore triggers. */ + stop(): void + /** A connected edge or a sync-gap frame. */ + trigger(reason: FleetReseedTrigger): void + /** A session became newly pinned (debounced re-seed). */ + notePinned(sessionID: string): void +} + +/** The viewed session id from the app location (both the server-keyed + * `/server//session/` and the legacy `//session/` routes, + * path- or hash-routed). */ +export function viewedSessionIdFromLocation(href: string): string | undefined { + return href.match(/\/session\/([^/?#]+)/)?.[1] +} + +export function createFleetReseedScheduler(deps: FleetReseedSchedulerDeps): FleetReseedScheduler { + const timers = deps.timers ?? defaultFleetTimers + const intervalMs = deps.intervalMs ?? FLEET_RESEED_INTERVAL_MS + const pinDebounceMs = deps.pinDebounceMs ?? FLEET_PIN_DEBOUNCE_MS + const unknownRetryMs = deps.unknownRetryMs ?? FLEET_UNKNOWN_RETRY_MS + const unknownRetryMax = deps.unknownRetryMax ?? FLEET_UNKNOWN_RETRY_MAX + const teardown = new AbortController() + + let stopped = false + let inFlight = false + /** The coalesced follow-up (an edge outranks the others: it may need the + * no-pins local re-seed). */ + let pending: RunKind | undefined + /** Set once the read answered its expected not-served 404 (a machine without + * fleet peers): the periodic and pin-driven re-seeds stand down so the app + * keeps today's behavior; the edges still re-probe. */ + let notServed = false + let tickTimer: unknown + let debounceTimer: unknown + let retryTimer: unknown + let retriesLeft = 0 + let allOmittedStreak = 0 + const partialStreak = new Map() + /** Cap fairness: when each id last got a status (a monotonic sequence). */ + const lastReseeded = new Map() + let reseedSeq = 0 + + const orderedIds = (): string[] => { + const pins = [...new Set(deps.pinnedIds())] + const viewed = deps.viewedId?.() + const rest = pins.filter((id) => id !== viewed) + // least-recently-reseeded first (never-reseeded ids first); a stable sort + // keeps the pin order among ties + rest.sort((a, b) => (lastReseeded.get(a) ?? -1) - (lastReseeded.get(b) ?? -1)) + return viewed !== undefined && pins.includes(viewed) ? [viewed, ...rest] : rest + } + + const observe = (ids: readonly string[], outcome: FleetReseedOutcome, kind: RunKind): void => { + // per TRIGGER: a fresh trigger (an edge or a new pin) re-arms the retry + // budget and supersedes any retry still waiting. A self-heal TICK does not: + // it is already the periodic re-read, and re-arming on every tick would make + // a permanently-`unknown` pinned id (a deleted session, a peer that stopped + // serving) cost the tick's read + every retry, each interval (PR #1768 review). + if (kind === "edge" || kind === "pin") { + if (retryTimer !== undefined) timers.clearTimeout(retryTimer) + retryTimer = undefined + retriesLeft = unknownRetryMax + } + if (outcome.kind === "fallback") { + if (outcome.cause === "not-served") notServed = true + return + } + if (outcome.kind !== "applied") return + notServed = false + const { statuses, unresolved, sources } = outcome.result + // cap fairness bookkeeping + let covered = 0 + for (const id of ids) { + if (Object.prototype.hasOwnProperty.call(statuses, id)) { + covered++ + lastReseeded.set(id, ++reseedSeq) + } + } + // every requested id omitted or unresolved, 3 re-seeds running + allOmittedStreak = covered === 0 ? allOmittedStreak + 1 : 0 + if (allOmittedStreak >= FLEET_FAILURE_STREAK) { + deps.diagnostics.report( + "all-omitted", + `${allOmittedStreak} consecutive re-seeds covered none of the ${ids.length} pinned session(s)`, + ) + } + // a partial failure persisting for the same (owner, directory) + const failing = new Set() + for (const [machine, rec] of Object.entries(sources)) { + for (const [dir, d] of Object.entries(rec.directories)) { + if (d.ok) continue + const key = `${machine}\u0000${dir}` + failing.add(key) + const n = (partialStreak.get(key) ?? 0) + 1 + partialStreak.set(key, n) + if (n >= FLEET_FAILURE_STREAK) { + deps.diagnostics.report( + `partial-failure ${machine} ${dir}`, + `${machine} ${dir}: ${d.reason ?? "failed"}${d.detail ? ` (${d.detail})` : ""} for ${n} consecutive re-seeds`, + ) + } + } + } + for (const key of [...partialStreak.keys()]) if (!failing.has(key)) partialStreak.delete(key) + // a cold owner map: retry shortly while a pinned id is still `unknown` + const pinned = new Set(ids) + const unknown = unresolved.some((u) => u.reason === "unknown" && pinned.has(u.id)) + if (unknown && retriesLeft > 0) { + retriesLeft-- + retryTimer = timers.setTimeout(() => { + retryTimer = undefined + request("retry") + }, unknownRetryMs) + } + } + + const run = async (kind: RunKind): Promise => { + const ids = orderedIds() + if (ids.length === 0) { + // nothing displayed to re-seed: the edges keep today's local re-seed; + // the self-heal / pin / retry paths simply skip + if (kind === "edge") { + try { + await deps.localReseed() + } catch { + // non-fatal — the next edge retries + } + } + return + } + inFlight = true + let outcome: FleetReseedOutcome + try { + outcome = await deps.reseed(ids, teardown.signal) + } catch { + outcome = { kind: "fallback", cause: "network" } + } finally { + inFlight = false + } + if (stopped) return + observe(ids, outcome, kind) + if (pending !== undefined) { + const next = pending + pending = undefined + request(next) + } + } + + const request = (kind: RunKind): void => { + if (stopped) return + if (inFlight) { + pending = pending === "edge" || kind === "edge" ? "edge" : kind + return + } + void run(kind) + } + + const armTick = (): void => { + tickTimer = timers.setTimeout(() => { + tickTimer = undefined + if (stopped) return + armTick() // a fixed cadence; a hung read cannot block it (client timeout) + if (notServed || !deps.isConnected() || !deps.isVisible()) return + if (deps.pinnedIds().length === 0) return + request("tick") + }, intervalMs) + } + + return { + start() { + if (stopped || tickTimer !== undefined) return + armTick() + }, + stop() { + stopped = true + pending = undefined + for (const t of [tickTimer, debounceTimer, retryTimer]) if (t !== undefined) timers.clearTimeout(t) + tickTimer = debounceTimer = retryTimer = undefined + teardown.abort() + }, + trigger() { + request("edge") + }, + notePinned() { + if (stopped) return + if (debounceTimer !== undefined) timers.clearTimeout(debounceTimer) + debounceTimer = timers.setTimeout(() => { + debounceTimer = undefined + if (notServed || !deps.isConnected()) return + request("pin") + }, pinDebounceMs) + }, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.e2e-fixture.ts b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.e2e-fixture.ts new file mode 100644 index 000000000..6177aa2e0 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.e2e-fixture.ts @@ -0,0 +1,41 @@ +// amicode#1766 — the END-TO-END contract fixture binding the two halves of the +// fleet re-seed. Import-free on purpose: it is loaded by BOTH the service's +// vitest end-to-end test (which asserts the REAL GET /amicode/fleet/session-status +// handler, driven against a stubbed peer owner, answers EXACTLY +// FLEET_STATUS_E2E_RESPONSE) and the app's bun end-to-end test (which feeds this +// same response through the app's parse + reconcile into the store). If either +// side's shape moves, one of the two tests goes red. +// +// Session ids are the engine's raw ids end to end — the id the app pins is the +// owner-map key and the key in the owner's own status answer. + +export const E2E = { + localMachine: "e2e-laptop", + peerMachine: "e2e-studio", + localDirectory: "/Users/jj/harmoniqs/local-e2e", + peerDirectory: "/Users/jj/harmoniqs/amicode", + /** reported busy by the stubbed peer owner, in its own directory */ + peerBusySession: "ses_e2e_peer_busy", + /** in the same peer directory, absent from the owner's answer → idle */ + peerQuietSession: "ses_e2e_peer_quiet", + /** a local session, absent from the local engine's answer → idle */ + localSession: "ses_e2e_local", + /** pinned, but unknown to the service's owner map → omitted + unresolved */ + unknownSession: "ses_e2e_unknown", + /** the app's pinned ids, the viewed (peer busy) session first */ + pinnedIds: ["ses_e2e_peer_busy", "ses_e2e_peer_quiet", "ses_e2e_local", "ses_e2e_unknown"], +} as const + +export const FLEET_STATUS_E2E_RESPONSE = { + ok: true, + statuses: { + ses_e2e_peer_busy: { type: "busy" }, + ses_e2e_peer_quiet: { type: "idle" }, + ses_e2e_local: { type: "idle" }, + }, + sources: { + "e2e-studio": { present: true, directories: { "/Users/jj/harmoniqs/amicode": { ok: true, sessions: 2 } } }, + "e2e-laptop": { present: true, directories: { "/Users/jj/harmoniqs/local-e2e": { ok: true, sessions: 1 } } }, + }, + unresolved: [{ id: "ses_e2e_unknown", reason: "unknown" }], +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.e2e.test.ts b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.e2e.test.ts new file mode 100644 index 000000000..54bf1f1e3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.e2e.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import type { OpencodeClient, SessionStatus } from "@opencode-ai/sdk/v2/client" +import { createServerSession } from "./server-session" +import { E2E, FLEET_STATUS_E2E_RESPONSE } from "./fleet-session-status.e2e-fixture" +import { createFleetDiagnostics, runFleetReseed } from "./fleet-session-status" + +// amicode#1766 END TO END (app half). FLEET_STATUS_E2E_RESPONSE is the exact +// body the REAL GET /amicode/fleet/session-status handler returns when driven +// against a stubbed peer owner that reports a pinned session busy in its +// directory — the service half (packages/extension/test/ +// fleet_session_status_e2e.test.ts) asserts the live handler's answer equals it. +// Here that response goes through the app's real parse + runFleetReseed + the +// real ServerSession recency-guarded reconcile, into the store. + +function createSession() { + return createServerSession({ session: { get: async () => ({ data: undefined }) } } as unknown as OpencodeClient) +} + +describe("#1766 — end to end: the real route's response → the app's parse + reconcile → the store", () => { + test("the peer session the owner reported busy becomes busy in the store for that EXACT pinned id", async () => { + const session = createSession() + for (const id of E2E.pinnedIds) session.pin(id) + // after a reopen: the store had re-learned nothing — the peer session reads idle + expect(session.data.session_working(E2E.peerBusySession)).toBe(false) + // a stale busy left on a session that has since gone quiet, and a busy the + // app holds for a session the service cannot resolve + session.apply({ type: "session.status", properties: { sessionID: E2E.peerQuietSession, status: { type: "busy" } } }) + session.apply({ type: "session.status", properties: { sessionID: E2E.unknownSession, status: { type: "busy" } } }) + + let requested: readonly string[] = [] + const out = await runFleetReseed(session.pinnedIds(), { + fetchFleet: async (ids) => { + requested = ids + return { status: 200, body: structuredClone(FLEET_STATUS_E2E_RESPONSE) } + }, + reconcile: (statuses, opts) => session.reconcileStatuses(statuses as Record, opts), + localReseed: async () => { + throw new Error("the fleet read was served — no local fallback expected") + }, + diagnostics: createFleetDiagnostics({ warn: () => {} }), + now: () => Date.now() + 1000, // issued after the local mutations above + }) + + expect(out.kind).toBe("applied") + // the ids the app sent ARE the ids the service resolved and the owner answered + expect([...requested].sort()).toEqual([...E2E.pinnedIds].sort()) + // busy for that exact session id → the rail's working floor and the tab's active indicator light + expect(session.data.session_status[E2E.peerBusySession]).toEqual({ type: "busy" }) + expect(session.data.session_working(E2E.peerBusySession)).toBe(true) + // the stale busy settled to idle (idle by absence in its own queried directory) + expect(session.data.session_status[E2E.peerQuietSession]?.type).toBe("idle") + // honest absence: the omitted (unknown) session keeps its current status + expect(session.data.session_status[E2E.unknownSession]?.type).toBe("busy") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.test.ts b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.test.ts new file mode 100644 index 000000000..bdb722aeb --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test" +import { + FLEET_SESSION_STATUS_ROUTE, + FLEET_READ_TIMEOUT_MS, + createFleetDiagnostics, + fleetSessionStatusRoute, + parseFleetStatusResponse, + runFleetReseed, + type FleetReseedDeps, +} from "./fleet-session-status" + +// amicode#1766 checkpoint 4 — the app's fleet session-status read client: the +// documented response shape parse (per-entry drop of invalid entries; whole-body +// fallback only for a non-documented top level), the apply through the +// recency-guarded reconcile, and the fallback to today's LOCAL re-seed with +// rate-limited, per-cause diagnostics. + +const OK_BODY = { + ok: true, + statuses: { ses_a: { type: "busy" }, ses_b: { type: "idle" } }, + sources: { studio: { present: true, directories: { "/p": { ok: true, sessions: 2 } } } }, + unresolved: [{ id: "ses_c", reason: "unknown" }], +} + +describe("#1766 — fleet read route", () => { + test("sends the ids as ONE comma-separated `ids` parameter, order preserved, under the fleet namespace", () => { + expect(FLEET_SESSION_STATUS_ROUTE).toBe("/amicode/fleet/session-status") + const route = fleetSessionStatusRoute(["ses_viewed", "ses b", "ses,c"]) + const u = new URL(route, "http://x") + expect(u.pathname).toBe("/amicode/fleet/session-status") + expect(u.searchParams.get("ids")).toBe("ses_viewed,ses b,ses,c") + expect(u.searchParams.getAll("ids")).toHaveLength(1) + }) +}) + +describe("#1766 — parseFleetStatusResponse", () => { + test("a 200 with the documented shape parses to its statuses + unresolved", () => { + const r = parseFleetStatusResponse(200, OK_BODY) + expect(r.kind).toBe("ok") + if (r.kind !== "ok") return + expect(r.statuses).toEqual({ ses_a: { type: "busy" }, ses_b: { type: "idle" } }) + expect(r.unresolved).toEqual([{ id: "ses_c", reason: "unknown" }]) + expect(r.dropped).toBe(0) + }) + + test("an individual invalid status entry is DROPPED; the other entries are kept", () => { + const r = parseFleetStatusResponse(200, { + ...OK_BODY, + statuses: { + good: { type: "busy" }, + retry: { type: "retry", attempt: 1, message: "m", next: 2, action: { reason: "r" } }, + bad_type: { type: "running" }, + bad_retry: { type: "retry", attempt: -1, message: "m", next: 2 }, + not_obj: "busy", + nul: null, + }, + }) + expect(r.kind).toBe("ok") + if (r.kind !== "ok") return + expect(r.statuses).toEqual({ + good: { type: "busy" }, + retry: { type: "retry", attempt: 1, message: "m", next: 2, action: { reason: "r" } }, + }) + expect(r.dropped).toBe(4) + }) + + test("an invalid unresolved entry is dropped individually too", () => { + const r = parseFleetStatusResponse(200, { + ...OK_BODY, + unresolved: [{ id: "a", reason: "unknown" }, { id: "b", reason: "bogus" }, { reason: "unknown" }, "c", { id: "d", reason: "over-cap" }], + }) + expect(r.kind).toBe("ok") + if (r.kind !== "ok") return + expect(r.unresolved).toEqual([ + { id: "a", reason: "unknown" }, + { id: "d", reason: "over-cap" }, + ]) + }) + + test.each([ + ["an array", []], + ["null", null], + ["a string", "ok"], + ["ok:false", { ...OK_BODY, ok: false }], + ["missing statuses", { ok: true, sources: {}, unresolved: [] }], + ["statuses as an array", { ...OK_BODY, statuses: [] }], + ["unresolved not an array", { ...OK_BODY, unresolved: {} }], + ["sources not an object", { ...OK_BODY, sources: [] }], + ])("a 200 whose top level is not the documented shape (%s) is a diagnosed fallback", (_name, body) => { + expect(parseFleetStatusResponse(200, body)).toMatchObject({ kind: "fallback", cause: "bad-shape", silent: false }) + }) + + test("404 with reason no-fleet-peers is the EXPECTED not-served fallback — silent", () => { + expect(parseFleetStatusResponse(404, { ok: false, reason: "no-fleet-peers" })).toEqual({ + kind: "fallback", + cause: "not-served", + silent: true, + }) + }) + + test.each([ + [404, { ok: false, error: "no route: GET /amicode/fleet/session-status" }, "not-found"], + [404, undefined, "not-found"], + [401, { ok: false, error: "unauthorized" }, "refused"], + [403, {}, "refused"], + [500, {}, "http-error"], + [503, {}, "http-error"], + ])("HTTP %i (%j) is a DIAGNOSED %s fallback", (status, body, cause) => { + expect(parseFleetStatusResponse(status, body)).toMatchObject({ kind: "fallback", cause, silent: false }) + }) +}) + +describe("#1766 — createFleetDiagnostics (rate-limited per cause)", () => { + test("warns at most once per cause per window; distinct causes are independent; a new window warns again", () => { + let t = 1_000_000 + const warned: string[] = [] + const d = createFleetDiagnostics({ warn: (m) => warned.push(m), now: () => t, windowMs: 60_000 }) + d.report("timeout", "fleet read timed out") + d.report("timeout", "again") + d.report("refused", "HTTP 401") + t += 30_000 + d.report("timeout", "still within the window") + expect(warned).toHaveLength(2) + expect(warned[0]).toContain("timeout") + expect(warned[1]).toContain("refused") + t += 30_001 + d.report("timeout", "next window") + expect(warned).toHaveLength(3) + }) +}) + +/** Deps for one re-seed with a scripted fleet response. */ +function deps(over: Partial & { reply?: () => Promise<{ status: number; body: unknown }> }) { + const calls = { fleet: [] as string[][], reconcile: [] as Array<{ statuses: Record; fetchedAt: number }>, local: 0, warned: [] as string[] } + let t = 5_000 + const d: FleetReseedDeps = { + fetchFleet: async (ids) => { + calls.fleet.push([...ids]) + t += 10 // the response lands after the request was issued + return (over.reply ?? (async () => ({ status: 200, body: OK_BODY })))() + }, + reconcile: (statuses, opts) => calls.reconcile.push({ statuses, fetchedAt: opts.fetchedAt }), + localReseed: async () => { + calls.local++ + }, + diagnostics: createFleetDiagnostics({ warn: (m) => calls.warned.push(m), now: () => t }), + now: () => t, + ...over, + } + return { d, calls } +} + +describe("#1766 — runFleetReseed (checkpoint 4: apply + fallback)", () => { + test("a documented 200 is applied through the reconcile, stamped with the time the read was ISSUED", async () => { + const { d, calls } = deps({}) + const out = await runFleetReseed(["ses_a", "ses_b"], d) + expect(out.kind).toBe("applied") + expect(calls.fleet).toEqual([["ses_a", "ses_b"]]) + expect(calls.reconcile).toEqual([{ statuses: { ses_a: { type: "busy" }, ses_b: { type: "idle" } }, fetchedAt: 5_000 }]) + expect(calls.local).toBe(0) + expect(calls.warned).toEqual([]) + }) + + test("not served (404 no-fleet-peers) → today's local re-seed, silently", async () => { + const { d, calls } = deps({ reply: async () => ({ status: 404, body: { ok: false, reason: "no-fleet-peers" } }) }) + const out = await runFleetReseed(["ses_a"], d) + expect(out).toMatchObject({ kind: "fallback", cause: "not-served" }) + expect(calls.local).toBe(1) + expect(calls.reconcile).toEqual([]) + expect(calls.warned).toEqual([]) + }) + + test.each([ + ["refused", async () => ({ status: 401, body: { ok: false, error: "unauthorized" } })], + ["not-found", async () => ({ status: 404, body: { ok: false, error: "no route" } })], + ["bad-shape", async () => ({ status: 200, body: { hello: "world" } })], + [ + "network", + async (): Promise<{ status: number; body: unknown }> => { + throw new TypeError("Failed to fetch") + }, + ], + ])("an unavailable read (%s) → today's local re-seed + a diagnostic naming the cause", async (cause, reply) => { + const { d, calls } = deps({ reply }) + const out = await runFleetReseed(["ses_a"], d) + expect(out).toMatchObject({ kind: "fallback", cause }) + expect(calls.local).toBe(1) + expect(calls.reconcile).toEqual([]) + expect(calls.warned).toHaveLength(1) + expect(calls.warned[0]).toContain(cause) + }) + + test("diagnostics are rate-limited per cause across re-seeds", async () => { + const { d, calls } = deps({ reply: async () => ({ status: 500, body: {} }) }) + await runFleetReseed(["a"], d) + await runFleetReseed(["a"], d) + await runFleetReseed(["a"], d) + expect(calls.local).toBe(3) // every re-seed still falls back + expect(calls.warned).toHaveLength(1) // but warns once per window + }) + + test(`a fleet read past the ${FLEET_READ_TIMEOUT_MS} ms client timeout falls back (timeout), and its LATE response is discarded, never applied`, async () => { + expect(FLEET_READ_TIMEOUT_MS).toBe(5000) + let resolveLate!: (v: { status: number; body: unknown }) => void + const late = new Promise<{ status: number; body: unknown }>((r) => (resolveLate = r)) + let signalSeen: AbortSignal | undefined + const { d, calls } = deps({ timeoutMs: 20 }) + d.fetchFleet = async (ids, signal) => { + calls.fleet.push([...ids]) + signalSeen = signal + return late // ignores the abort on purpose + } + const out = await runFleetReseed(["ses_a"], d) + expect(out).toMatchObject({ kind: "fallback", cause: "timeout" }) + expect(signalSeen?.aborted).toBe(true) // the request was told to stop + expect(calls.local).toBe(1) + resolveLate({ status: 200, body: OK_BODY }) + await new Promise((r) => setTimeout(r, 10)) + expect(calls.reconcile).toEqual([]) // the late response was discarded + }) + + test("a throwing local fallback is contained (the re-seed never throws)", async () => { + const { d } = deps({ reply: async () => ({ status: 500, body: {} }) }) + d.localReseed = async () => { + throw new Error("engine down") + } + await expect(runFleetReseed(["a"], d)).resolves.toMatchObject({ kind: "fallback" }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.ts b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.ts new file mode 100644 index 000000000..987341fcd --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/fleet-session-status.ts @@ -0,0 +1,257 @@ +// amicode#1766 — the app's FLEET session-status re-seed client (pure; no imports, +// so it is unit-testable without Solid and loadable by the service's vitest +// end-to-end test). +// +// A peer session's busy/idle reaches this app only through the live event +// fan-in, which does not replay on reconnect; the old re-seed asked only the +// LOCAL engine, without a directory, and learned nothing. The fleet read +// (GET /amicode/fleet/session-status?ids=…) resolves each pinned session's owner +// and directory service-side and returns one tri-state map: every COVERED +// session has an explicit status (idle by absence), every uncovered one is +// OMITTED. Applied through the store's recency-guarded reconcile, an explicit +// idle settles a stale busy and an omission preserves the current value. +// +// When the read is unavailable (not served on a machine without fleet peers, +// refused, a network error, a timeout, or a body that is not the documented +// shape) the app performs TODAY's local re-seed, unchanged — silently for the +// expected not-served 404, with a rate-limited per-cause diagnostic otherwise. + +export const FLEET_SESSION_STATUS_ROUTE = "/amicode/fleet/session-status" +/** The client-side bound on one fleet read: above the service's 3 s deadline, + * below the 10 s self-heal cadence, so a hung read can never block later ones. */ +export const FLEET_READ_TIMEOUT_MS = 5000 +/** One diagnostic per cause per window. */ +export const FLEET_DIAGNOSTIC_WINDOW_MS = 60_000 + +/** The engine's SessionStatus union, as the fleet read passes it through. */ +export type FleetSessionStatus = + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number; action?: Record } + +export type FleetUnresolvedReason = "unknown" | "no-directory" | "over-cap" +export type FleetUnresolved = { id: string; reason: FleetUnresolvedReason } + +export type FleetDirectoryRecord = { ok: boolean; reason?: string; detail?: string; sessions?: number } +export type FleetSourceRecord = { + present?: boolean + reason?: string + directories: Record +} + +export type FleetFallbackCause = "not-served" | "not-found" | "refused" | "http-error" | "network" | "timeout" | "bad-shape" + +export type FleetParseResult = + | { + kind: "ok" + statuses: Record + unresolved: FleetUnresolved[] + sources: Record + /** How many individual invalid entries were dropped. */ + dropped: number + } + | { kind: "fallback"; cause: FleetFallbackCause; silent: boolean; detail?: string } + +/** The request route: the ids as ONE comma-separated `ids` query value, order + * significant (the viewed session first). */ +export function fleetSessionStatusRoute(ids: readonly string[]): string { + return `${FLEET_SESSION_STATUS_ROUTE}?ids=${encodeURIComponent(ids.join(","))}` +} + +const isPlainObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v) +const isNonNegativeInt = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 0 + +/** Validate ONE status entry against the engine's SessionStatus union (excess + * keys ignored, only documented fields kept). Undefined ⇒ invalid. */ +export function parseSessionStatusEntry(entry: unknown): FleetSessionStatus | undefined { + if (!isPlainObject(entry)) return undefined + if (entry.type === "idle") return { type: "idle" } + if (entry.type === "busy") return { type: "busy" } + if (entry.type === "retry") { + const { attempt, message, next, action } = entry + if (!isNonNegativeInt(attempt) || typeof message !== "string" || !isNonNegativeInt(next)) return undefined + if (action !== undefined && !isPlainObject(action)) return undefined + return { type: "retry", attempt, message, next, ...(action !== undefined ? { action } : {}) } + } + return undefined +} + +const UNRESOLVED_REASONS = new Set(["unknown", "no-directory", "over-cap"]) + +/** Parse a fleet read response. Anything other than a 200 with the documented + * top-level shape is a fallback (404 `no-fleet-peers` silently; anything else + * diagnosed). Inside a documented body, an individual invalid entry is DROPPED + * (the others kept) — never a whole-body rejection. */ +export function parseFleetStatusResponse(status: number, body: unknown): FleetParseResult { + if (status === 404) { + if (isPlainObject(body) && body.reason === "no-fleet-peers") return { kind: "fallback", cause: "not-served", silent: true } + return { kind: "fallback", cause: "not-found", silent: false, detail: "HTTP 404" } + } + if (status === 401 || status === 403) return { kind: "fallback", cause: "refused", silent: false, detail: `HTTP ${status}` } + if (status !== 200) return { kind: "fallback", cause: "http-error", silent: false, detail: `HTTP ${status}` } + if ( + !isPlainObject(body) || + body.ok !== true || + !isPlainObject(body.statuses) || + !Array.isArray(body.unresolved) || + (body.sources !== undefined && !isPlainObject(body.sources)) + ) { + return { kind: "fallback", cause: "bad-shape", silent: false, detail: "response is not the documented shape" } + } + let dropped = 0 + const statuses: Record = {} + for (const [id, entry] of Object.entries(body.statuses)) { + const parsed = parseSessionStatusEntry(entry) + if (parsed) statuses[id] = parsed + else dropped++ + } + const unresolved: FleetUnresolved[] = [] + for (const entry of body.unresolved) { + if (isPlainObject(entry) && typeof entry.id === "string" && typeof entry.reason === "string" && UNRESOLVED_REASONS.has(entry.reason)) { + unresolved.push({ id: entry.id, reason: entry.reason as FleetUnresolvedReason }) + } else dropped++ + } + const sources: Record = {} + for (const [machine, rec] of Object.entries(isPlainObject(body.sources) ? body.sources : {})) { + if (!isPlainObject(rec) || !isPlainObject(rec.directories)) continue + const directories: Record = {} + for (const [dir, d] of Object.entries(rec.directories)) { + if (isPlainObject(d) && typeof d.ok === "boolean") { + directories[dir] = { + ok: d.ok, + ...(typeof d.reason === "string" ? { reason: d.reason } : {}), + ...(typeof d.detail === "string" ? { detail: d.detail } : {}), + ...(typeof d.sessions === "number" ? { sessions: d.sessions } : {}), + } + } + } + sources[machine] = { + ...(typeof rec.present === "boolean" ? { present: rec.present } : {}), + ...(typeof rec.reason === "string" ? { reason: rec.reason } : {}), + directories, + } + } + return { kind: "ok", statuses, unresolved, sources, dropped } +} + +// ── diagnostics ───────────────────────────────────────────────────────────── + +export type FleetDiagnostics = { report(cause: string, message: string): void } + +/** A rate-limited console diagnostic: at most one warning per cause per window. */ +export function createFleetDiagnostics(opts: { + warn?: (message: string) => void + now?: () => number + windowMs?: number +} = {}): FleetDiagnostics { + const warn = opts.warn ?? ((m: string) => console.warn(m)) + const now = opts.now ?? Date.now + const windowMs = opts.windowMs ?? FLEET_DIAGNOSTIC_WINDOW_MS + const last = new Map() + return { + report(cause, message) { + const t = now() + const prev = last.get(cause) + if (prev !== undefined && t - prev < windowMs) return + last.set(cause, t) + warn(`[amicode] fleet session-status re-seed: ${cause} — ${message}`) + }, + } +} + +// ── one re-seed ───────────────────────────────────────────────────────────── + +export type FleetReseedDeps = { + /** Issue the fleet read for `ids` (one comma-separated `ids` parameter) with + * the service credential; resolve the HTTP status + parsed JSON body (body + * undefined when not JSON). Rejects on a network error. */ + fetchFleet: (ids: readonly string[], signal: AbortSignal) => Promise<{ status: number; body: unknown }> + /** The store's recency-guarded reconcile. */ + reconcile: (statuses: Record, opts: { fetchedAt: number }) => void + /** Today's local `/session/status` re-seed, unchanged. */ + localReseed: () => Promise + diagnostics: FleetDiagnostics + now?: () => number + /** Client timeout (default FLEET_READ_TIMEOUT_MS). */ + timeoutMs?: number + /** Injectable timers (fake clocks in tests). */ + timers?: FleetTimers + /** Teardown: an abort cancels the read — nothing applied, no fallback. */ + signal?: AbortSignal +} + +export type FleetTimers = { + setTimeout: (fn: () => void, ms: number) => unknown + clearTimeout: (id: unknown) => void +} + +export const defaultFleetTimers: FleetTimers = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (id) => clearTimeout(id as ReturnType), +} + +export type FleetReseedOutcome = + | { kind: "applied"; result: Extract } + | { kind: "fallback"; cause: FleetFallbackCause } + | { kind: "cancelled" } + +/** One fleet re-seed: issue the read (timestamped BEFORE the request so the + * store's recency guard can refuse a downgrade of a busy mutated after it), + * bound it by the client timeout (a late response is DISCARDED, never + * applied), and apply the documented map through the reconcile — or fall back + * to today's local re-seed. Never throws. */ +export async function runFleetReseed(ids: readonly string[], deps: FleetReseedDeps): Promise { + const now = deps.now ?? Date.now + const timers = deps.timers ?? defaultFleetTimers + const timeoutMs = deps.timeoutMs ?? FLEET_READ_TIMEOUT_MS + if (deps.signal?.aborted) return { kind: "cancelled" } + const fetchedAt = now() + const controller = new AbortController() + let timer: unknown + let onTeardown: (() => void) | undefined + const timedOut = new Promise<"timeout">((resolve) => { + timer = timers.setTimeout(() => { + controller.abort() + resolve("timeout") + }, timeoutMs) + }) + const cancelled = new Promise<"cancelled">((resolve) => { + onTeardown = () => { + controller.abort() + resolve("cancelled") + } + deps.signal?.addEventListener("abort", onTeardown, { once: true }) + }) + let parsed: FleetParseResult + try { + // Whatever settles first wins; a response arriving after the timeout (or + // teardown) lands on an already-settled race and is DISCARDED. + const raced = await Promise.race([deps.fetchFleet(ids, controller.signal), timedOut, cancelled]) + if (raced === "cancelled") return { kind: "cancelled" } + parsed = + raced === "timeout" + ? { kind: "fallback", cause: "timeout", silent: false, detail: `no answer within ${timeoutMs} ms` } + : parseFleetStatusResponse(raced.status, raced.body) + } catch (err) { + if (deps.signal?.aborted) return { kind: "cancelled" } + parsed = controller.signal.aborted + ? { kind: "fallback", cause: "timeout", silent: false, detail: "aborted at the client timeout" } + : { kind: "fallback", cause: "network", silent: false, detail: err instanceof Error ? err.message : String(err) } + } finally { + timers.clearTimeout(timer) + if (onTeardown) deps.signal?.removeEventListener("abort", onTeardown) + } + if (deps.signal?.aborted) return { kind: "cancelled" } + if (parsed.kind === "ok") { + deps.reconcile(parsed.statuses, { fetchedAt }) + return { kind: "applied", result: parsed } + } + if (!parsed.silent) deps.diagnostics.report(parsed.cause, parsed.detail ?? "fleet read unavailable; local re-seed") + try { + await deps.localReseed() + } catch { + // A failed local re-seed is non-fatal — the next trigger retries. + } + return { kind: "fallback", cause: parsed.cause } +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts index 4eaa78578..d1337f275 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts @@ -2,6 +2,7 @@ import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import { sessionWorkingFromStore } from "../session-working" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -229,9 +230,12 @@ export function createChildStoreManager(input: { session: [], sessionTotal: 0, session_status: {}, + session_turn_active: {}, session_working(id: string) { - const type = this.session_status[id]?.type - return (type ?? "idle") !== "idle" + // #1637 — honor the SAME turn-active floor as the server-session + // store: working when the turn flag is up (no-part turns / stray + // idle) OR the status is not idle. + return sessionWorkingFromStore(this.session_status[id], this.session_turn_active?.[id] ?? false) }, session_diff: {}, todo: {}, diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.test.ts index ebae7374b..41623d8f3 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.test.ts @@ -71,6 +71,7 @@ const baseState = (input: Partial = {}) => session: [], sessionTotal: 0, session_status: {}, + session_turn_active: {}, session_diff: {}, todo: {}, permission: {}, @@ -155,6 +156,43 @@ describe("applyDirectoryEvent", () => { expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended") }) + // #1637-followup: the engine publishes session.status (busy/idle) but NEVER + // session.execution.started (no publisher exists — it is a stale SDK type). + // The turn-active floor must therefore rise on the busy status frame that + // actually arrives, not only on the phantom execution bracket. + test("a session.status busy frame raises the turn-active floor", () => { + const [store, setStore] = createStore(baseState()) + + applyDirectoryEvent({ + event: { type: "session.status", properties: { sessionID: "ses_x", status: { type: "busy" } } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session_status.ses_x).toEqual({ type: "busy" }) + expect(store.session_turn_active?.ses_x).toBe(true) + }) + + test("a session.status idle frame lowers the turn-active floor", () => { + const [store, setStore] = createStore( + baseState({ session_status: { ses_y: { type: "busy" } }, session_turn_active: { ses_y: true } }), + ) + + applyDirectoryEvent({ + event: { type: "session.status", properties: { sessionID: "ses_y", status: { type: "idle" } } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session_turn_active?.ses_y).toBe(false) + }) + test("preserves a Home-specific retained session limit", () => { const [store, setStore] = createStore( baseState({ @@ -614,4 +652,112 @@ describe("applyDirectoryEvent", () => { expect(lspLoads).toBe(1) }) + // #1539: the directory store is local-only. The gate in server-sync.tsx checks + // `event.origin` and skips `applyDirectoryEvent` for remote-origin events. This + // test verifies the gate's contract: a remote-origin event WOULD mutate the + // store if the gate weren't there, proving the gate is necessary. + test("#1539 gate contract: session.created WOULD insert into the store (the gate prevents this for remote events)", () => { + const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 })) + const remoteSession = rootSession({ id: "ses_remote" }) + + // Without the gate, applyDirectoryEvent inserts the session: + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: remoteSession } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.session).toHaveLength(1) + expect(store.sessionTotal).toBe(1) + + // The gate in server-sync.tsx: `if (isRemote) return` — skips this call. + // A remote-origin event with `origin: "studio"` never reaches here. + // This test documents what the gate prevents. + }) + + test("#1539 gate contract: session.updated WOULD upsert into the store (the gate prevents this for remote events)", () => { + const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 })) + const remoteSession = rootSession({ id: "ses_remote" }) + + // Without the gate, session.updated inserts a previously-unknown session: + applyDirectoryEvent({ + event: { type: "session.updated", properties: { info: remoteSession } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.session).toHaveLength(1) + // The gate prevents this for events with `origin: "studio"`. + }) + + // ── #1637 — the child-store's second session_working honors the turn floor ── + // The global-sync child store's reducer previously wrote status ONLY from + // session.status frames — the execution bracket never reached it, so its + // session_working had no turn floor. Plumb the flag: execution.started sets it, + // the terminals / session.error / an idle status frame clear it. + test("#1637 the child-store reducer raises session_turn_active on execution.started", () => { + const [store, setStore] = createStore(baseState()) + applyDirectoryEvent({ + event: { type: "session.execution.started", properties: { sessionID: "ses_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.session_turn_active?.ses_1).toBe(true) + }) + + test("#1637 the child-store reducer clears session_turn_active on a terminal execution event", () => { + const [store, setStore] = createStore(baseState({ session_turn_active: { ses_1: true } })) + applyDirectoryEvent({ + event: { type: "session.execution.succeeded", properties: { sessionID: "ses_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.session_turn_active?.ses_1).toBe(false) + }) + + test("#1637 the child-store reducer clears session_turn_active on session.error", () => { + const [store, setStore] = createStore(baseState({ session_turn_active: { ses_1: true } })) + applyDirectoryEvent({ + event: { type: "session.error", properties: { sessionID: "ses_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.session_turn_active?.ses_1).toBe(false) + }) + + test("#1637 the child-store reducer honors status seq (drops a stale idle)", () => { + const [store, setStore] = createStore(baseState({ session_status: { ses_1: { type: "busy" } } })) + applyDirectoryEvent({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" }, seq: 5 } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + // A lower-seq idle is dropped — the busy stands. + applyDirectoryEvent({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" }, seq: 3 } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.session_status.ses_1?.type).toBe("busy") + }) }) + diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.ts index 8f203715a..0f0422aec 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/event-reducer.ts @@ -21,6 +21,13 @@ const SESSION_CONTENT_EVENTS = new Set([ "session.diff", "todo.updated", "session.status", + // #1637 — the turn-active floor's events are session content too (they feed + // session_working); gate them with the status frame in passive directories. + "session.execution.started", + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "session.error", "message.updated", "message.removed", "message.part.updated", @@ -32,6 +39,9 @@ const SESSION_CONTENT_EVENTS = new Set([ "question.replied", "question.rejected", ]) +// #1637 — sessionID → max status seq seen (#1636), for the child store's seq +// guard. Sessions are unique across directories, so a module map is safe. +const childStatusSeq = new Map() export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } @@ -264,10 +274,62 @@ export function applyDirectoryEvent(input: { break } case "session.status": { - const props = event.properties as { sessionID: string; status: SessionStatus } + const props = event.properties as { sessionID: string; status: SessionStatus; seq?: number } + // #1637 honor seq (#1636): drop a stale/out-of-order frame so a reordered + // idle cannot overwrite a live busy. Absent seq → always apply. + if (typeof props.seq === "number") { + const seen = childStatusSeq.get(props.sessionID) + if (seen !== undefined && props.seq <= seen) break + childStatusSeq.set(props.sessionID, props.seq) + } + // #1637 fallback clear: an authoritative idle frame ends the turn bracket. + // #1637-followup: and a busy frame RAISES the turn-active floor. The engine + // publishes session.status (busy/idle) but never session.execution.started + // (no publisher — it is a stale SDK type), so the floor must rise on the + // busy status frame that actually arrives, not only the phantom execution + // bracket below. This is the load-bearing floor for every turn shape. + if ((props.status?.type ?? "idle") === "idle") + input.setStore("session_turn_active", props.sessionID, false) + else if (props.status?.type === "busy") + input.setStore("session_turn_active", props.sessionID, true) input.setStore("session_status", props.sessionID, reconcile(props.status)) break } + case "session.execution.started": { + // #1637 turn-active floor: rises on the execution bracket IF the server emits + // one. NOTE (#1637-followup): this engine build does NOT publish + // session.execution.* — kept as a harmless forward-compat branch so a future + // engine that does emit it still raises the floor; the session.status busy + // case above is what actually fires today. + const props = event.properties as { sessionID?: string } | undefined + if (props?.sessionID) { + input.setStore("session_turn_active", props.sessionID, true) + input.setStore("session_status", props.sessionID, reconcile({ type: "busy" })) + } + break + } + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": { + // #1637 terminal clear. + const props = event.properties as { sessionID?: string } | undefined + if (props?.sessionID) { + input.setStore("session_turn_active", props.sessionID, false) + input.setStore("session_status", props.sessionID, reconcile({ type: "idle" })) + } + break + } + case "session.error": { + // #1637 fallback clear: a Session.Event.Error site / abort can skip the + // terminal execution event. + const props = event.properties as { sessionID?: string } | undefined + if (props?.sessionID) { + input.setStore("session_turn_active", props.sessionID, false) + if ((input.store.session_status[props.sessionID]?.type ?? "idle") !== "idle") + input.setStore("session_status", props.sessionID, reconcile({ type: "idle" })) + } + break + } case "message.updated": { const info = clean((event.properties as { info: Message }).info) const messages = input.store.message[info.sessionID] diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-cache.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-cache.ts index 87f0b864f..d7988fe67 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-cache.ts @@ -15,6 +15,7 @@ export const SESSION_CACHE_LIMIT = 128 type SessionCache = { session_status: Record + session_turn_active?: Record session_diff: Record todo: Record message: Record @@ -44,6 +45,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable { "context", "review", "pulseInspector", + "fleetManager", ]) }) @@ -23,10 +24,20 @@ describe("side panel tab order", () => { "review", "context", "pulseInspector", + "fleetManager", ]) }) test("does not move Home", () => { expect(reorderSidePanelTabs(DEFAULT_SIDE_PANEL_TAB_ORDER, "home", 4)).toEqual(DEFAULT_SIDE_PANEL_TAB_ORDER) }) + + // #1322: the Fleet Manager Work Column tab is a named side-panel surface, + // ordered as pulseInspector's sibling (after it, before Preview). + test("includes the fleetManager surface after pulseInspector", () => { + expect(DEFAULT_SIDE_PANEL_TAB_ORDER).toContain("fleetManager") + expect(DEFAULT_SIDE_PANEL_TAB_ORDER.indexOf("fleetManager")).toBe( + DEFAULT_SIDE_PANEL_TAB_ORDER.indexOf("pulseInspector") + 1, + ) + }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts b/packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts index 6f8b0a0be..fab93b8c5 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts @@ -1,6 +1,6 @@ import { SESSION_PREVIEW_TAB } from "./layout-tabs" -export const SIDE_PANEL_TAB_IDS = ["home", "review", "context", "pulseInspector", SESSION_PREVIEW_TAB] as const +export const SIDE_PANEL_TAB_IDS = ["home", "review", "context", "pulseInspector", "fleetManager", SESSION_PREVIEW_TAB] as const export type SidePanelTabID = (typeof SIDE_PANEL_TAB_IDS)[number] diff --git a/packages/app-bundle/overlay/packages/app/src/context/layout-tabs.test.ts b/packages/app-bundle/overlay/packages/app/src/context/layout-tabs.test.ts index 240b52fac..516b54baa 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/layout-tabs.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/layout-tabs.test.ts @@ -91,4 +91,17 @@ describe("closeSessionTab", () => { state([], undefined), ) }) + + // #1322: fleetManager rides the same generic close path as pulseInspector. + test("removes fleetManager and selects an adjacent tab", () => { + expect( + closeSessionTab(state(["context", "fleetManager", "file://a.ts"], "fleetManager"), "fleetManager"), + ).toEqual(state(["context", "file://a.ts"], "context")) + }) + + test("removes fleetManager when it is the only tab", () => { + expect(closeSessionTab(state(["fleetManager"], "fleetManager"), "fleetManager")).toEqual( + state([], undefined), + ) + }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts index f0d490145..c01d2018f 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts @@ -3,9 +3,13 @@ import { adaptServerEvent, applySseError, coalesceServerEvents, + composeArmedRequestHeaders, + createGlobalStreamResetCoordinator, enqueueServerEvent, + globalStreamResetRequired, resumeStreamAfterPageShow, } from "./server-sdk" +import { armRemoteCreate, attachOwnerHeaderIfArmed, disarmRemoteCreate } from "@/components/remote-create-arm" import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" @@ -245,3 +249,166 @@ describe("enqueueServerEvent", () => { expect(events).toHaveLength(2) }) }) + +describe("legacy attachment global-stream reset (#1468)", () => { + const local = { ok: true, mode: "local", identity: null } as const + const legacy = (machine_id: string) => ({ + ok: true as const, + mode: "legacy-single-pointer" as const, + identity: { machine_id, sshAlias: `${machine_id}.ssh`, transport: "ssh" }, + }) + + test("only a parsed successful control response and a valid legacy effective-identity transition require a reset", () => { + expect(globalStreamResetRequired({ control: { ok: true }, before: local, after: legacy("peer-a") })).toBe(true) + expect(globalStreamResetRequired({ control: { ok: true }, before: legacy("peer-a"), after: legacy("peer-b") })).toBe(true) + expect(globalStreamResetRequired({ control: { ok: true }, before: legacy("peer-a"), after: local })).toBe(true) + + expect(globalStreamResetRequired({ control: { ok: true }, before: legacy("peer-a"), after: legacy("peer-a") })).toBe(false) + expect(globalStreamResetRequired({ control: { ok: false }, before: legacy("peer-a"), after: legacy("peer-b") })).toBe(false) + expect(globalStreamResetRequired({ control: { ok: true }, before: { nope: true }, after: legacy("peer-b") })).toBe(false) + expect( + globalStreamResetRequired({ + control: { ok: true }, + before: legacy("peer-a"), + after: { ok: true, mode: "legacy-single-pointer", identity: { machine_id: "peer-b" } }, + }), + ).toBe(false) + expect( + globalStreamResetRequired({ + control: { ok: true }, + before: legacy("peer-a"), + after: { ok: true, mode: "multiplexed", identity: null }, + }), + ).toBe(false) + // Fleet v2's owner-aware data plane may change attachment pointers while + // its empty owner map still resolves every global path locally. It owns its + // own per-peer cursor set; the legacy global cursor is intentionally inert. + expect( + globalStreamResetRequired({ + control: { ok: true }, + before: { ok: true, mode: "multiplexed", identity: null }, + after: { ok: true, mode: "multiplexed", identity: null }, + }), + ).toBe(false) + }) + + test("a reset fences late frames, clears both cursors, aborts the old stream, and opens its replacement cursorless", async () => { + let aborts = 0 + let memoryCursor: string | undefined = "predecessor" + let persistedCursor: string | undefined = "predecessor" + let resolveOldStream: (() => void) | undefined + const oldStream = new Promise((resolve) => { + resolveOldStream = resolve + }) + const replacementRequests: URL[] = [] + let sessionStateUpdates = 0 + + const coordinator = createGlobalStreamResetCoordinator({ + abort: () => aborts++, + clearCursor: () => { + memoryCursor = undefined + persistedCursor = undefined + }, + waitForOldStream: () => oldStream, + reconnect: () => { + const request = new URL("http://127.0.0.1:43117/global/event") + if (memoryCursor) request.searchParams.set("lastEventID", memoryCursor) + replacementRequests.push(request) + }, + }) + const oldGeneration = coordinator.current() + const reset = coordinator.resetAfterControl({ control: { ok: true }, before: legacy("peer-a"), after: legacy("peer-b") }) + + await Promise.resolve() + expect(aborts).toBe(1) + expect(memoryCursor).toBeUndefined() + expect(persistedCursor).toBeUndefined() + // A frame that arrives after abort belongs to the fenced generation: it + // cannot resurrect the predecessor cursor or mutate session state. + if (coordinator.accepts(oldGeneration)) { + memoryCursor = "late-frame" + persistedCursor = "late-frame" + sessionStateUpdates++ + } + expect(memoryCursor).toBeUndefined() + expect(persistedCursor).toBeUndefined() + expect(sessionStateUpdates).toBe(0) + + resolveOldStream?.() + await reset + expect(replacementRequests).toHaveLength(1) + expect(replacementRequests[0]?.pathname).toBe("/global/event") + expect(replacementRequests[0]?.searchParams.has("lastEventID")).toBe(false) + }) + + test("failed, unreadable, and Fleet-v2 multiplexed controls leave the live stream and cursor untouched", async () => { + const calls = { abort: 0, clear: 0, reconnect: 0 } + const coordinator = createGlobalStreamResetCoordinator({ + abort: () => calls.abort++, + clearCursor: () => calls.clear++, + waitForOldStream: async () => {}, + reconnect: () => calls.reconnect++, + }) + expect(await coordinator.resetAfterControl({ control: { ok: false }, before: legacy("peer-a"), after: legacy("peer-b") })).toBe(false) + expect(await coordinator.resetAfterControl({ control: { ok: true }, before: undefined, after: legacy("peer-b") })).toBe(false) + expect( + await coordinator.resetAfterControl({ + control: { ok: true }, + before: { ok: true, mode: "multiplexed", identity: null }, + after: { ok: true, mode: "multiplexed", identity: null }, + }), + ).toBe(false) + expect(calls).toEqual({ abort: 0, clear: 0, reconnect: 0 }) + }) +}) + +describe("composeArmedRequestHeaders (remote-create Authorization preservation)", () => { + // The regression: the armed path-less create POST went out with NO + // Authorization (the SDK applies the Basic credential as the client's BASE + // headers, not per-request init; rebuilding init.headers shadowed it) → 401, + // while every other call kept the base credential. This proves the armed + // create now carries BOTH the owner header AND the base Authorization. + const BASE_AUTH = "Basic b3BlbmNvZGU6c2VjcmV0" // base64("opencode:secret") + + test("an armed create POST keeps the base Authorization AND gets the owner header", () => { + armRemoteCreate("jjs-mac-studio") + const out = composeArmedRequestHeaders({ + method: "POST", + url: "http://127.0.0.1:4095/session", + initHeaders: { "content-type": "application/json" }, + baseAuthorization: BASE_AUTH, + attach: attachOwnerHeaderIfArmed, + }) + expect(out.rewritten).toBe(true) + expect(out.headers["authorization"]).toBe(BASE_AUTH) + expect(out.headers["x-amicode-owner"]).toBe("jjs-mac-studio") + expect(out.headers["content-type"]).toBe("application/json") + disarmRemoteCreate() + }) + + test("an UNARMED call is not rewritten (base headers apply untouched)", () => { + disarmRemoteCreate() + const out = composeArmedRequestHeaders({ + method: "POST", + url: "http://127.0.0.1:4095/session", + initHeaders: undefined, + baseAuthorization: BASE_AUTH, + attach: attachOwnerHeaderIfArmed, + }) + expect(out.rewritten).toBe(false) + }) + + test("a non-create armed request is not rewritten (owner header is create-only, one-shot preserved)", () => { + armRemoteCreate("jjs-mac-studio") + const out = composeArmedRequestHeaders({ + method: "GET", + url: "http://127.0.0.1:4095/session/abc/message", + initHeaders: undefined, + baseAuthorization: BASE_AUTH, + attach: attachOwnerHeaderIfArmed, + }) + expect(out.rewritten).toBe(false) + disarmRemoteCreate() // consume the still-armed state + }) +}) + diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx index 985fbb36b..65559c790 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx @@ -3,6 +3,8 @@ import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" +import { dispatchSseFocusEvent } from "../pages/new-session/new-session-machine-mount" +import { isSyncGapType, parseSyncGapFrame, SYNC_GAP_TYPE } from "./sse-gap-frame" import { type Accessor, batch, createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js" import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" @@ -11,6 +13,9 @@ import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" +import { attachOwnerHeaderIfArmed } from "@/components/remote-create-arm" +import { parseCursorNamespaces, resolveEventOrigin } from "./sse-origin" +import { shouldReconnectIdleStream } from "./stream-liveness" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" @@ -18,7 +23,7 @@ const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -export type ServerEvent = Event & { current?: OpenCodeEvent } +export type ServerEvent = Event & { current?: OpenCodeEvent; origin?: string } type QueuedServerEvent = { directory: string; payload: ServerEvent } /** A minimal fetch call signature. Newer lib types make `typeof fetch` require * a `preconnect` member our plain wrappers don't implement (lib drift); @@ -167,6 +172,98 @@ export function resumeStreamAfterPageShow(_event: PageTransitionEvent, start: () start() } +type EffectiveStreamIdentity = { machine_id: string; sshAlias: string; transport: string } +type ParsedEffectiveStreamSignal = + | { mode: "local"; identity: null } + | { mode: "legacy-single-pointer"; identity: EffectiveStreamIdentity } + | { mode: "multiplexed"; identity: null } + +function effectiveStreamIdentity(value: unknown): EffectiveStreamIdentity | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + const input = value as Record + const machine_id = typeof input.machine_id === "string" ? input.machine_id.trim() : "" + const sshAlias = typeof input.sshAlias === "string" ? input.sshAlias.trim() : "" + const transport = typeof input.transport === "string" ? input.transport.trim() : "" + if (!machine_id || !sshAlias || !transport) return undefined + return { machine_id, sshAlias, transport } +} + +function parseEffectiveStreamSignal(value: unknown): ParsedEffectiveStreamSignal | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + const input = value as Record + if (input.ok !== true || typeof input.mode !== "string") return undefined + if (input.mode === "local" && input.identity === null) return { mode: "local", identity: null } + if (input.mode === "multiplexed" && input.identity === null) return { mode: "multiplexed", identity: null } + if (input.mode === "legacy-single-pointer") { + const identity = effectiveStreamIdentity(input.identity) + if (identity) return { mode: "legacy-single-pointer", identity } + } + return undefined +} + +function parsedSuccessfulControl(value: unknown): boolean { + return !!value && typeof value === "object" && !Array.isArray(value) && (value as { ok?: unknown }).ok === true +} + +function sameEffectiveStreamIdentity(a: EffectiveStreamIdentity | null, b: EffectiveStreamIdentity | null): boolean { + if (a === null || b === null) return a === b + return a.machine_id === b.machine_id && a.sshAlias === b.sshAlias && a.transport === b.transport +} + +/** Whether one attachment control may invalidate the legacy global stream. + * The local effective-stream endpoint is authoritative only for its legacy + * compatibility mode; Fleet v2 multiplexing owns the per-peer data plane and + * must not be reset from an attachment-pointer observation. */ +export function globalStreamResetRequired(input: { control: unknown; before: unknown; after: unknown }): boolean { + if (!parsedSuccessfulControl(input.control)) return false + const before = parseEffectiveStreamSignal(input.before) + const after = parseEffectiveStreamSignal(input.after) + if (!before || !after || before.mode === "multiplexed" || after.mode === "multiplexed") return false + const beforeIdentity = before.mode === "legacy-single-pointer" ? before.identity : null + const afterIdentity = after.mode === "legacy-single-pointer" ? after.identity : null + return !sameEffectiveStreamIdentity(beforeIdentity, afterIdentity) +} + +/** A serialized, generation-fenced reset for the compatibility global stream. + * Invalidating the generation precedes the old request's abort, so a late + * frame has no authority to restore the cursor or mutate session state. */ +export function createGlobalStreamResetCoordinator(input: { + abort: () => void + clearCursor: () => void + waitForOldStream: () => Promise + reconnect: () => void +}) { + let generation = 0 + let serial = Promise.resolve() + + const resetAfterControl = (change: { control: unknown; before: unknown; after: unknown }): Promise => { + if (!globalStreamResetRequired(change)) return Promise.resolve(false) + const requestedGeneration = ++generation + const task = serial.then(async () => { + // A second valid transition supersedes a reset still waiting to reconnect. + if (requestedGeneration !== generation) return false + input.abort() + input.clearCursor() + await input.waitForOldStream() + if (requestedGeneration !== generation) return false + input.reconnect() + return true + }) + serial = task.then( + () => undefined, + () => undefined, + ) + return task + } + + return { + current: () => generation, + accepts: (candidate: number) => candidate === generation, + invalidate: () => ++generation, + resetAfterControl, + } +} + /** What an SSE stream error must do, extracted so the ABORT — the part that * reads as redundant and is easy to delete — is covered by a test. * @@ -185,6 +282,35 @@ export function applySseError(input: { closed: boolean; disconnect: () => void; return true } +/** Compose the outgoing request headers for the owner-header PRODUCER seam. + * + * The bug this fixes (#1643 follow-up): createOpencodeClient applies the Basic + * credential as the CLIENT's base headers, NOT per-request `init.headers`. The + * fetch wrapper rebuilds `init.headers` when it arms the owner header, which + * SHADOWS the client base headers — so the armed create went out with no + * Authorization and 401'd, while every other (un-rewritten) call kept the base + * credential. Seeding the base Authorization here before arming keeps it on the + * rewritten request. Returns the same reference when nothing was armed (the + * wrapper leaves `init` untouched → base headers still apply). */ +export function composeArmedRequestHeaders(input: { + method: string + url: string + initHeaders: HeadersInit | undefined + baseAuthorization: string | undefined + attach: (method: string, url: string, headers: Record) => Record +}): { rewritten: boolean; headers: Record } { + const seeded: Record = {} + if (input.baseAuthorization) seeded["authorization"] = input.baseAuthorization + new Headers(input.initHeaders).forEach((v, k) => (seeded[k] = v)) + // Snapshot to detect whether `attach` changed anything (it returns the SAME + // reference when not armed). We compare against a copy because we mutated + // `seeded` above with the base auth. + const before = { ...seeded } + const after = input.attach(input.method, input.url, seeded) + const rewritten = after !== seeded || JSON.stringify(after) !== JSON.stringify(before) + return { rewritten, headers: after } +} + type ServerEventEmitter = ReturnType> type ServerSDKBase = { server: ServerConnection.Any @@ -202,6 +328,14 @@ type ServerSDKBase = { /** amicode webview: live stream visibility for the ConnectionBanner — * "connected" only while the SSE loop is actively subscribed. */ status: Accessor<"connected" | "disconnected"> + /** #1617: a monotonic counter bumped each time a `amicode.sync.gap` frame is + * intercepted (the fan-in overflow signal). Surfaces to the entity rail as + * the forced-resync signal so a WEDGE — which never crosses the + * disconnect→connect edge — still heals the rail. */ + resyncCount: Accessor + /** Reset only the legacy single-pointer global stream after a successful + * attachment control changes the local effective data-plane identity. */ + resetForAttachmentChange: (input: { control: unknown; before: unknown; after: unknown }) => Promise } createClient: ( opts: Omit[0], "server" | "fetch">, @@ -251,6 +385,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } } + // #1539: track the parsed composite cursor to detect event origin (local vs + // remote peer). Initialized from the persisted cursor so the first event + // after reconnect diffs correctly. + let previousParsedCursor = parseCursorNamespaces(lastEventID) const sseFetch: FetchLike = (input, init) => { const base = eventFetch ?? globalThis.fetch try { @@ -325,7 +463,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let attempt: AbortController | undefined let run: Promise | undefined let started = false - let generation = 0 // Amicode webview: connection visibility for the ConnectionBanner. The loop // below reconnects silently every RECONNECT_DELAY_MS, so without a signal a // dead server reads as an endless "thinking" wave. (The branch's 15s @@ -333,27 +470,71 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // governs liveness; two abort clocks would fight.) const [streamStatus, setStreamStatus] = createSignal<"connected" | "disconnected">("disconnected") + // #1617: bumped each time a `amicode.sync.gap` frame is intercepted below. The + // entity rail reads this as its forced-resync signal (a WEDGE keeps the socket + // nominally connected, so the disconnect→connect edge never fires and the rail + // would otherwise never self-heal a stranded view). + const [resyncCount, setResyncCount] = createSignal(0) + + // #1584 client-side liveness watchdog. The v1 event iterator neither throws + // nor completes when the socket goes silently half-open (sleep/wake, Wi-Fi + // blip, an engine replaced without FIN/RST), so `onSseError` never fires and + // the `for await` parks forever while `streamStatus` stays "connected". We + // track the last frame time and, on a 5s tick, force-reconnect a stream that + // BELIEVES it is connected but has seen nothing for 30s — abort only the + // per-attempt controller so the loop re-opens with the `lastEventID` cursor + // (lossless); the same generation reconnects. An intentional abort is treated + // as "closed" by the catch at ~L526, so the banner does NOT flip to + // disconnected. Mirrors the extension-host `sse_liveness.ts` (30s/5s). + const STREAM_STALE_MS = 30_000 + const WATCHDOG_TICK_MS = 5_000 + // A tab returning to visible / the network coming back are the signals that + // actually fire in a VS Code webview under retainContextWhenHidden, where + // pageshow/pagehide do NOT fire on a tab switch — re-arm with a tighter + // window so a wake reconnects promptly. + const WAKE_STALE_MS = 10_000 + let lastFrameAt = Date.now() + let watchdog: ReturnType | undefined + // Reset the frame clock and abort the parked attempt so the loop re-opens + // (same generation, cursor intact). Shared by the watchdog and both wake + // handlers; a no-op if the stream is already down (attempt undefined). + const forceReconnect = () => { + lastFrameAt = Date.now() + // #1637: a silent-socket / watchdog forced reconnect must bump the resync + // token so the entity rail self-heals a stranded view — a WEDGE keeps the + // socket nominally connected, so the disconnect→connect edge never fires and + // the rail would otherwise never re-fetch. Bump only when we actually abort a + // parked attempt (a real forced resync), not on the already-down no-op. + if (attempt) { + attempt.abort() + setResyncCount((n) => n + 1) + } + } + const start = () => { if (started) return run started = true - const active = ++generation + const active = streamGeneration.current() const previous = run const current = (async () => { if (previous) await previous // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit - while (!abort.signal.aborted && started && generation === active) { - attempt = new AbortController() + while (!abort.signal.aborted && started && streamGeneration.accepts(active)) { + const currentAttempt = new AbortController() + attempt = currentAttempt const onAbort = () => { - attempt?.abort() + currentAttempt.abort() } abort.signal.addEventListener("abort", onAbort) try { const kind = await protocol + if (!streamGeneration.accepts(active)) return const onSseError = (error: unknown) => { + if (!streamGeneration.accepts(active)) return const real = applySseError({ - closed: isStreamClosed(error, attempt?.signal), + closed: isStreamClosed(error, currentAttempt.signal), disconnect: () => setStreamStatus("disconnected"), - abort: () => attempt?.abort(), + abort: () => currentAttempt.abort(), }) if (!real) return if (streamErrorLogged) return @@ -366,26 +547,92 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } const events = kind === "v1" - ? (await eventSdk.global.event({ signal: attempt.signal, onSseError })).stream - : eventApi.event.subscribe({ signal: attempt.signal }) + ? (await eventSdk.global.event({ signal: currentAttempt.signal, onSseError })).stream + : eventApi.event.subscribe({ signal: currentAttempt.signal }) + if (!streamGeneration.accepts(active)) return setStreamStatus("connected") + lastFrameAt = Date.now() let yielded = Date.now() for await (const event of events) { + // A buffered frame can arrive after AbortController.abort(). The + // reset invalidates first, so it cannot restore a stale cursor or + // mutate the new effective stream's session state. + if (!streamGeneration.accepts(active)) return streamErrorLogged = false + lastFrameAt = Date.now() const legacy = "payload" in event if (legacy && event.payload.type === "sync") continue const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + // #1522 (ADR 0033 decision A): the SSE fan-in aggregator emits + // `amicode.fleet.focus` as the first frame on (re)connect. Intercept + // it here and dispatch to the existing picker focus latch via + // window.postMessage — a SECOND input to the same seed path (the + // chat_bridge push is the first). Skip the normal event queue: the + // focus frame is not a session event. + const focusType = legacy ? (payload as { type?: string }).type : payload.type + if (focusType === "amicode.fleet.focus") { + if (typeof window !== "undefined") { + // The SSE data payload carries the focus fields directly; for the + // adapted (non-legacy) path, the data sits in `properties`. + const focusData = legacy + ? (payload as Record) + : ((payload as { properties?: Record }).properties ?? {}) + dispatchSseFocusEvent( + { type: "amicode.fleet.focus", ...focusData } as { type: string; focusedMachineId?: string; [k: string]: unknown }, + window, + ) + } + continue + } + // #1617 (ADR 0033 Amendment 1): the fan-in aggregator emits an id-less + // `amicode.sync.gap` frame when a bounded per-namespace buffer + // overflow-dropped frames while the downstream was backpressured. A gap + // is a real loss the reconnect-storm debounce (#1289) must NOT swallow, + // so we drive a FORCED bootstrap refetch. Modeled on the focus-frame + // interception directly above: the gap frame is `payload`-less bare + // JSON, so it rides the non-legacy adapter path (`payload.type`), and + // its data sits in `properties`. Re-emit it as a synthetic GLOBAL event + // into the normal queue; server-sync's listener refetches the bootstrap + // unconditionally on this type (past the #1289 `server.connected` + // debounce, which keys on a different type). Skip the rest of the loop: + // the gap frame is not a session event and never advances the cursor. + if (isSyncGapType(focusType)) { + const gapData = legacy + ? (payload as Record) + : ((payload as { properties?: Record }).properties ?? {}) + const gap = parseSyncGapFrame({ type: SYNC_GAP_TYPE, ...gapData }) + // Bump the forced-resync signal so the entity rail self-heals even + // though the socket never crossed a disconnect→connect edge. + setResyncCount((n) => n + 1) + if (enqueueServerEvent(queue, { + directory: "global", + payload: { type: SYNC_GAP_TYPE, properties: { namespaces: gap?.namespaces ?? [] } } as unknown as ServerEvent, + })) + schedule() + continue + } trackEventID(legacy ? event.payload : (event as { id?: string })) - if (enqueueServerEvent(queue, { directory, payload })) schedule() + // #1539: resolve origin from the composite cursor diff. Fleet-of-one + // (bare scalar id) returns undefined immediately — zero overhead. + const sseId: string | undefined = legacy + ? (event.payload as { id?: string }).id + : (event as { id?: string }).id + const resolved = resolveEventOrigin(sseId, previousParsedCursor) + previousParsedCursor = resolved.cursor + const taggedPayload: ServerEvent = resolved.origin !== undefined + ? { ...payload, origin: resolved.origin } + : payload + if (enqueueServerEvent(queue, { directory, payload: taggedPayload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() await wait(0) } } catch (error) { - if (!isStreamClosed(error, attempt?.signal)) setStreamStatus("disconnected") - if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { + if (streamGeneration.accepts(active) && !isStreamClosed(error, currentAttempt.signal)) + setStreamStatus("disconnected") + if (streamGeneration.accepts(active) && !isStreamClosed(error, currentAttempt.signal) && !streamErrorLogged) { streamErrorLogged = true console.error("[global-sdk] event stream failed", { url: server.http.url, @@ -395,10 +642,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } finally { abort.signal.removeEventListener("abort", onAbort) - attempt = undefined + if (attempt === currentAttempt) attempt = undefined } - if (abort.signal.aborted || !started || generation !== active) return + if (abort.signal.aborted || !started || !streamGeneration.accepts(active)) return await wait(RECONNECT_DELAY_MS) } })().finally(() => { @@ -410,19 +657,81 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS return run } + const clearGlobalStreamCursor = () => { + lastEventID = undefined + previousParsedCursor = {} + try { + sessionStorage.removeItem(CURSOR_KEY) + } catch { + /* private mode etc. — memory was still cleared */ + } + queue.length = 0 + buffer.length = 0 + if (timer) clearTimeout(timer) + timer = undefined + setStreamStatus("disconnected") + } + + const streamGeneration = createGlobalStreamResetCoordinator({ + abort: () => attempt?.abort(), + clearCursor: clearGlobalStreamCursor, + waitForOldStream: async () => { + const previous = run + if (previous) await previous + }, + reconnect: () => { + if (abort.signal.aborted) return + // waitForOldStream settled the active loop. Clearing this latch is what + // makes the replacement call start(), and its fetch sees no cursor. + started = false + void start() + }, + }) + const stop = () => { started = false - generation++ + streamGeneration.invalidate() attempt?.abort() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) + // #1584 watchdog: on a 5s tick, force-reconnect a stream that believes it + // is connected but has been silent past the 30s staleness window. Guarded + // on the live stream (started, not torn down) so a stopped stream is left + // to the loop; forceReconnect resets the clock so a single tick fires once + // per staleness episode, not every tick. + watchdog = setInterval(() => { + if (!started || abort.signal.aborted) return + if (shouldReconnectIdleStream({ status: streamStatus(), now: Date.now(), lastFrameAt, staleMs: STREAM_STALE_MS })) + forceReconnect() + }, WATCHDOG_TICK_MS) + // #1584 wake re-arm: pageshow/pagehide do NOT fire on a tab switch under + // retainContextWhenHidden — visibilitychange and online are the signals + // that actually fire. A tab returning to visible while the stream is stale + // past the tighter wake window reconnects; the network coming back + // reconnects any "connected" stream (its socket may be silently dead). A + // healthy active stream (fresh lastFrameAt) is not stale, so a quick tab + // toggle causes no reconnect. + makeEventListener(document, "visibilitychange", () => { + if (!started || abort.signal.aborted) return + if ( + document.visibilityState === "visible" && + shouldReconnectIdleStream({ status: streamStatus(), now: Date.now(), lastFrameAt, staleMs: WAKE_STALE_MS }) + ) + forceReconnect() + }) + makeEventListener(window, "online", () => { + if (!started || abort.signal.aborted) return + if (streamStatus() === "connected") forceReconnect() + }) }) onCleanup(() => { stop() + if (watchdog) clearInterval(watchdog) + watchdog = undefined abort.abort() flush() }) @@ -434,11 +743,40 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // the old forever. A timed-out call rejects; the sync layer's retry // paths (and the frozen holds) carry the view until it lands. const platformFetch = platform.fetch ?? globalThis.fetch - const fetchWithTimeout: FetchLike = (input, init) => - platformFetch(input, { - ...init, - signal: init?.signal ?? AbortSignal.timeout(30_000), + const fetchWithTimeout: FetchLike = (input, init) => { + // #1643 (completes #1484 AC3): the owner-header PRODUCER seam (ADR 0031 + // §D1a leg 2). When the composer armed a remote create, attach + // x-amicode-owner to the path-less session-create POST so the multiplexer + // routes it to the owning peer. One-shot (disarms on attach); a local + // create never arms, so this is byte-unchanged for the common case. + let headerInit = init + try { + const method = init?.method ?? (input instanceof Request ? input.method : "GET") + const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const url = new URL(rawUrl, server.http.url).href + const baseAuthorization = + server.http.password !== undefined + ? `Basic ${btoa(`${server.http.username ?? "opencode"}:${server.http.password}`)}` + : undefined + const composed = composeArmedRequestHeaders({ + method, + url, + initHeaders: init?.headers, + baseAuthorization, + attach: attachOwnerHeaderIfArmed, + }) + // Only rewrite when the owner header was actually attached (an armed + // create). Unarmed calls leave `init` untouched so the client's base + // headers still apply byte-identically to before. + if (composed.rewritten) headerInit = { ...init, headers: composed.headers } + } catch { + /* fall through unmodified — never let header logic break a request */ + } + return platformFetch(input, { + ...headerInit, + signal: headerInit?.signal ?? AbortSignal.timeout(30_000), }) + } const sdk = createSdkForServer({ server: server.http, @@ -469,6 +807,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS listen: emitter.listen.bind(emitter), start, status: streamStatus, + resyncCount, + resetForAttachmentChange: streamGeneration.resetAfterControl, }, createClient(opts: Omit[0], "server" | "fetch">) { return createSdkForServer({ diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-session-child-active.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-session-child-active.test.ts new file mode 100644 index 000000000..e87e6f57a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/server-session-child-active.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test" +import type { OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" +import { createServerSession } from "./server-session" + +// #1649 — ancestor-of-active-child floor. A foreground subagent (Task tool) +// blocks its parent's turn inside the tool: the engine holds the parent runner +// busy but the parent emits no parts and no execution bracket, so neither the +// streamActiveParts nor the turnActive floor rises, and a stray/reconcile idle +// would blank the parent rail. session_working(parent) must stay true while any +// descendant is non-idle, and — critically — the parent's reactive consumers +// must RE-RUN when a child first spawns and when it goes idle (a plain-Map read +// would not be reactive; the fix pushes a store leaf, so it is). +// +// Dedicated file (not appended to server-session.test.ts): that suite has +// documented positive cross-test dependencies (#1239); a separate file is hermetic. + +const sessionInfo = (id: string, parentID?: string) => + ({ id, parentID, time: { created: 1, updated: 1 } }) as Session + +// A `task` tool part carrying the spawn metadata recordTaskSpawn reads +// (parentSessionId/sessionId live in part.state.metadata). +const taskPart = (input: { id: string; parentSessionID: string; childSessionID: string }) => + ({ + id: input.id, + sessionID: input.parentSessionID, + messageID: `msg_${input.parentSessionID}`, + type: "tool", + tool: "task", + callID: `call_${input.id}`, + state: { + status: "running", + input: {}, + time: { start: 1 }, + metadata: { parentSessionId: input.parentSessionID, sessionId: input.childSessionID }, + }, + }) as unknown as Part + +function createSession() { + return createServerSession({ session: { get: async () => ({ data: undefined }) } } as unknown as OpencodeClient) +} + +const spawnChild = (session: ReturnType, parentID: string, childID: string) => { + session.apply({ type: "session.created", properties: { info: sessionInfo(parentID) } }) + session.apply({ type: "session.created", properties: { info: sessionInfo(childID, parentID) } }) + // Parent turn goes busy, then the task tool part records the ancestry. + session.apply({ type: "session.status", properties: { sessionID: parentID, status: { type: "busy" } } }) + session.apply({ + type: "message.part.updated", + properties: { part: taskPart({ id: "task_1", parentSessionID: parentID, childSessionID: childID }) }, + }) +} + +describe("#1649 — ancestor-of-active-child floor", () => { + test("parent stays working across a stray reconcile idle while the child is busy", () => { + const session = createSession() + spawnChild(session, "P", "C") + session.apply({ type: "session.status", properties: { sessionID: "C", status: { type: "busy" } } }) + expect(session.data.session_working("P")).toBe(true) + + // A stray/reconcile idle downgrades the PARENT's own leaf — but the child is + // still busy, so the floor must hold. + session.apply({ type: "session.status", properties: { sessionID: "P", status: { type: "idle" } } }) + expect(session.data.session_status["P"]?.type).toBe("idle") + expect(session.data.session_working("P")).toBe(true) // held by the child floor + }) + + test("the parent floor lowers when the child goes idle", () => { + const session = createSession() + spawnChild(session, "P2", "C2") + session.apply({ type: "session.status", properties: { sessionID: "C2", status: { type: "busy" } } }) + // Parent's own leaf goes idle (turn's raw status settled) but child holds it. + session.apply({ type: "session.status", properties: { sessionID: "P2", status: { type: "idle" } } }) + expect(session.data.session_working("P2")).toBe(true) + // Child finishes → floor lowers → parent no longer working. + session.apply({ type: "session.status", properties: { sessionID: "C2", status: { type: "idle" } } }) + expect(session.data.session_working("P2")).toBe(false) + }) + + test("a child terminal via execution.succeeded also lowers the floor", () => { + const session = createSession() + spawnChild(session, "P3", "C3") + session.apply({ type: "session.status", properties: { sessionID: "C3", status: { type: "busy" } } }) + session.apply({ type: "session.status", properties: { sessionID: "P3", status: { type: "idle" } } }) + expect(session.data.session_working("P3")).toBe(true) + session.apply({ type: "session.execution.succeeded", properties: { sessionID: "C3" } }) + expect(session.data.session_working("P3")).toBe(false) + }) + + test("REACTIVITY MECHANISM: the child floor is a store leaf (session_child_active), written on spawn/edge", () => { + // The fix's reactivity rests on session_child_active being a STORE leaf + // (setData-written), not a plain-Map read — so a parent's projection memo + // re-runs on a child edge. Under bun's solid-js server build, createMemo + // does not track (it resolves to solid-js/dist/server.js — a no-op stub), so + // we assert the reactive SOURCE directly: the store leaf transitions on the + // child's status edges, which is what drives the parent consumer. (The end- + // to-end re-render is covered by the human live-reproduce; see the ledger.) + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("PR") } }) + // No child yet → no floor entry. + expect(session.data.session_child_active["PR"] ?? 0).toBe(0) + expect(session.data.session_working("PR")).toBe(false) + + session.apply({ type: "session.created", properties: { info: sessionInfo("CR", "PR") } }) + session.apply({ type: "session.status", properties: { sessionID: "PR", status: { type: "busy" } } }) + session.apply({ + type: "message.part.updated", + properties: { part: taskPart({ id: "task_r", parentSessionID: "PR", childSessionID: "CR" }) }, + }) + session.apply({ type: "session.status", properties: { sessionID: "CR", status: { type: "busy" } } }) + // The store leaf rose on the child busy — the reactive source is live. + expect(session.data.session_child_active["PR"]).toBe(1) + // Stray-idle the parent: only the child floor holds it working. + session.apply({ type: "session.status", properties: { sessionID: "PR", status: { type: "idle" } } }) + expect(session.data.session_working("PR")).toBe(true) + + // Child idle lowers the leaf back to 0. + session.apply({ type: "session.status", properties: { sessionID: "CR", status: { type: "idle" } } }) + expect(session.data.session_child_active["PR"] ?? 0).toBe(0) + expect(session.data.session_working("PR")).toBe(false) + }) + + test("idempotent: duplicate child busy frames do not drift the floor; one idle clears it", () => { + const session = createSession() + spawnChild(session, "P4", "C4") + // Several busy frames for the same child (reorder/duplicate). + session.apply({ type: "session.status", properties: { sessionID: "C4", status: { type: "busy" } } }) + session.apply({ type: "session.status", properties: { sessionID: "C4", status: { type: "busy" } } }) + session.apply({ type: "session.status", properties: { sessionID: "C4", status: { type: "busy" } } }) + session.apply({ type: "session.status", properties: { sessionID: "P4", status: { type: "idle" } } }) + expect(session.data.session_working("P4")).toBe(true) + // A single idle must fully clear it (contribution was 1, not 3). + session.apply({ type: "session.status", properties: { sessionID: "C4", status: { type: "idle" } } }) + expect(session.data.session_working("P4")).toBe(false) + }) + + test("evict clears the child index so a parent is not wedged live forever", () => { + const session = createSession() + spawnChild(session, "P5", "C5") + session.apply({ type: "session.status", properties: { sessionID: "C5", status: { type: "busy" } } }) + session.apply({ type: "session.status", properties: { sessionID: "P5", status: { type: "idle" } } }) + expect(session.data.session_working("P5")).toBe(true) + // Deleting the child tears down the ancestry + floor via evict. + session.apply({ type: "session.deleted", properties: { sessionID: "C5" } }) + expect(session.data.session_working("P5")).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-session-orphan-part.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-session-orphan-part.test.ts new file mode 100644 index 000000000..d173b1fef --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/server-session-orphan-part.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" +import type { OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" +import { createServerSession } from "./server-session" + +// #1646 (transcript self-heal) — outside a page load, a message.part.updated +// whose ordered parent message.updated was never seen (the mid-stream reconnect +// gap where the parent frame was lost and the lastEventID cursor could not +// replay it) used to be dropped silently, leaving the row permanently absent +// until a manual window reload. It now requests a forced resync to backfill the +// parent — debounced per session so a part burst yields at most one sync — and +// still refuses to resurrect a message it knows was removed. +// +// In its OWN file (not appended to server-session.test.ts) on purpose: that +// suite has documented positive cross-test dependencies (#1239), and a new +// describe block appended there shifts ordering and reddens an unrelated test. +// A dedicated file keeps these hermetic. + +const sessionInfo = (id: string, parentID?: string) => + ({ id, parentID, time: { created: 1, updated: 1 } }) as Session + +const textPart = (input: { id: string; sessionID: string; messageID: string }) => + ({ + id: input.id, + sessionID: input.sessionID, + messageID: input.messageID, + type: "text", + text: "hi", + }) as unknown as Part + +// A client that records session.messages calls so we can count forced resyncs +// (a forced sync runs loadMessages → client.session.messages). `next()` returns +// a promise that resolves the moment the NEXT messages call lands, so tests +// await the resync deterministically rather than racing a timer (bun runs these +// in one shared process alongside server-session.test.ts; a setTimeout settle is +// not reliable across that boundary). +function recordingClient() { + const calls: string[] = [] + let notify: (() => void) | undefined + const client = { + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ data: sessionInfo(sessionID) }), + messages: async ({ sessionID }: { sessionID: string }) => { + calls.push(sessionID) + notify?.() + return { data: [], cursor: { next: undefined } } + }, + }, + } as unknown as OpencodeClient + const nextCall = () => + new Promise((resolve) => { + notify = resolve + }) + return { client, calls, nextCall } +} + +const microtasks = async (n = 10) => { + for (let i = 0; i < n; i++) await Promise.resolve() +} + +describe("#1646 — orphan-part self-heal", () => { + test("a genuine orphan (missing parent, no active load) triggers a forced resync", async () => { + const { client, calls, nextCall } = recordingClient() + const session = createServerSession(client) + session.apply({ type: "session.created", properties: { info: sessionInfo("ses_o") } }) + const landed = nextCall() + // No message.updated for msg_o ever arrived — its parent frame was lost. + session.apply({ + type: "message.part.updated", + properties: { part: textPart({ id: "prt_1", sessionID: "ses_o", messageID: "msg_o" }) }, + }) + await landed + expect(calls).toContain("ses_o") + // The orphan part itself is NOT ordered into the list (no parent to anchor). + expect(session.data.message["ses_o"] ?? []).toHaveLength(0) + }) + + test("a burst of orphan parts triggers at most one resync (the in-flight guard holds)", async () => { + const { client, calls, nextCall } = recordingClient() + const session = createServerSession(client) + session.apply({ type: "session.created", properties: { info: sessionInfo("ses_b") } }) + const landed = nextCall() + // The whole burst is applied synchronously. The per-session in-flight guard + // is set synchronously (before the first sync's first await), so every later + // orphan in the burst sees it set and launches no second resync. The novel- + // messageID dedup independently collapses repeats of the same message. + for (let i = 0; i < 5; i++) { + session.apply({ + type: "message.part.updated", + properties: { part: textPart({ id: `prt_${i}`, sessionID: "ses_b", messageID: `msg_${i}` }) }, + }) + } + await landed + await microtasks() // give any (erroneously) launched second resync time to land + expect(calls.filter((s) => s === "ses_b")).toHaveLength(1) + }) + + test("a part for a KNOWN-REMOVED message never resurrects it and never resyncs", async () => { + const { client, calls } = recordingClient() + const session = createServerSession(client) + session.apply({ type: "session.created", properties: { info: sessionInfo("ses_r") } }) + session.apply({ type: "message.removed", properties: { sessionID: "ses_r", messageID: "msg_dead" } }) + session.apply({ + type: "message.part.updated", + properties: { part: textPart({ id: "prt_z", sessionID: "ses_r", messageID: "msg_dead" }) }, + }) + await microtasks() // a resync, if wrongly launched, would land within these + expect(calls).not.toContain("ses_r") + expect(session.data.message["ses_r"] ?? []).toHaveLength(0) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts index 1a77f14fb..95356ca9e 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { OpencodeClient, Part, Session, SessionStatus } from "@opencode-ai/sdk/v2/client" -import { createServerSession } from "./server-session" +import { createServerSession, runInflight } from "./server-session" const sessionInfo = (id: string, parentID?: string) => ({ @@ -154,3 +154,445 @@ describe("ServerSession live file-edit route", () => { expect(session.data.diff_version.worker).toBe(1) }) }) + +// #1646: the composer's archived read-only banner reads the PER-SESSION store +// (session.get(id).time.archived). When an archive arrives as a `session.updated` +// event (the ONLY way a NON-initiating machine — e.g. the owner Studio when the +// laptop archived its session — learns of it), that read MUST reflect archived. +// The handler remembers the info (archived set) then evict()s message caches; +// evict must NOT drop the info entry, or the composer never flips off the event. +describe("#1646 — archived session.updated keeps time.archived on the per-session info store", () => { + const archivedInfo = (id: string, archived: number) => + ({ id, time: { created: 1, updated: archived, archived } }) as Session + + test("get(id).time.archived is truthy after a session.updated archive event", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("ses_x") } }) + expect(session.get("ses_x")?.time?.archived).toBeFalsy() + + session.apply({ type: "session.updated", properties: { info: archivedInfo("ses_x", 4242) } }) + + // The composer's exact read — must see the archive, NOT undefined (evicted). + const info = session.get("ses_x") + expect(info).toBeDefined() + expect(info?.time?.archived).toBe(4242) + }) + + test("the message caches ARE dropped for the archived session (evict still runs)", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("ses_y") } }) + session.apply({ + type: "message.part.updated", + properties: { + part: toolPart({ + id: "e1", + sessionID: "ses_y", + tool: "apply_patch", + metadata: { filediff: { file: "f.ts", status: "modified" } }, + }), + }, + }) + expect(session.data.diff_version["ses_y"]).toBe(1) + + session.apply({ type: "session.updated", properties: { info: archivedInfo("ses_y", 99) } }) + + // Caches gone (evict ran) but the info entry survives with archived set. + expect(session.data.diff_version["ses_y"]).toBeUndefined() + expect(session.get("ses_y")?.time?.archived).toBe(99) + }) +}) + +// #1646 regression: a FORCED resolve must re-fetch, never ride a non-forced +// in-flight request. Archiving a LOCAL session left the row gone from the list +// but the session live (no read-only banner, menu still "Archive"): the +// archive's force-sync resolve() returned an in-flight NON-forced request that +// had been issued before the PATCH committed, so it re-remembered STALE +// (unarchived) info and clobbered the fresh archived state. +describe("#1646 — a forced resolve does not ride a stale in-flight request", () => { + test("force:true re-fetches fresh info even while a non-forced request is pending", async () => { + let call = 0 + let releaseStale: (v: { data: Session }) => void = () => {} + const stalePromise = new Promise<{ data: Session }>((r) => (releaseStale = r)) + const client = { + session: { + get: ({ sessionID }: { sessionID: string }) => { + call++ + // 1st call (non-forced): a slow request that resolves STALE (no archive). + if (call === 1) return stalePromise + // 2nd call (forced): resolves FRESH (archived), immediately. + return Promise.resolve({ + data: { id: sessionID, time: { created: 1, updated: 5, archived: 5 } } as Session, + }) + }, + }, + } as unknown as OpencodeClient + const session = createServerSession(client) + + // A non-forced resolve goes in flight (call 1, still pending). + const first = session.resolve("ses_z") + // A FORCED resolve must NOT coalesce onto the pending non-forced one — it + // issues its own fetch (call 2) and lands the fresh archived info. + await session.resolve("ses_z", { force: true }) + expect(session.get("ses_z")?.time?.archived).toBe(5) + + // Now let the stale request finish. Because it is a DISTINCT request, it may + // resolve, but the forced request already applied the fresh state; the store + // must still read archived (the stale one must not have been the last write + // the caller relied on). We assert the forced fetch happened at all. + releaseStale({ data: { id: "ses_z", time: { created: 1, updated: 1 } } as Session }) + await first.catch(() => {}) + expect(call).toBeGreaterThanOrEqual(2) // the forced resolve issued its own fetch + }) +}) + +// #1646 (follow-up): the OUTER coalescing layer f54987a9 missed. sync() wraps +// its whole body in runInflight(inflight, id, task); the archive read-only flip +// rides through it as sync(id, { force: true }). A forced run must NOT coalesce +// onto a pending non-forced task (the inflight twin of the resolve()-layer fix) +// — otherwise the forced task never runs while a normal sync/prefetch is in +// flight for the session, resolve(force) is never reached, and the composer +// stays live ("archived row gone from the list but the session still editable"). +describe("#1646 — runInflight: a forced run bypasses in-flight coalescing", () => { + test("non-forced runs coalesce onto a pending task; a forced run executes its own", async () => { + const map = new Map>() + let runs = 0 + let release: () => void = () => {} + const gate = new Promise((resolve) => (release = resolve)) + const task = () => { + runs++ + return gate + } + + // First non-forced run starts the task and stays pending. + const first = runInflight(map, "ses_a", task) + // A second NON-forced run coalesces onto it — the task must NOT run again. + const second = runInflight(map, "ses_a", task) + expect(second).toBe(first) + expect(runs).toBe(1) + + // A FORCED run must NOT coalesce — it executes its own task even though a + // non-forced one is still in flight for the same key. + const forced = runInflight(map, "ses_a", task, { force: true }) + expect(forced).not.toBe(first) + expect(runs).toBe(2) + + release() + await Promise.all([first, second, forced]) + }) + + test("a settled forced run cleans itself out of the inflight map", async () => { + const map = new Map>() + await runInflight(map, "ses_b", () => Promise.resolve(), { force: true }) + expect(map.has("ses_b")).toBe(false) + }) +}) + +// ── #1617 (follow-up) — rail must not go idle while the agent is still streaming ── +// The wire carries NO sequence/timestamp on session status (only {type}), and the +// client's session_status writer blindly overwrites from THREE unordered writers. +// A racing/stale `idle` (from a prior turn, or a reordered frame) could blank the +// rail while the agent is still producing output. The self-correcting floor: +// session_working() stays true while message-part deltas are actively streaming +// for the session, regardless of a stray idle status. +describe("#1617 — session_working floor: a racing idle does not blank a streaming rail", () => { + const textPart = (id: string, sessionID: string, text = "") => + ({ + id, + sessionID, + messageID: `msg_${sessionID}`, + type: "text", + text, + }) as Part + const assistantMessage = (sessionID: string) => + ({ + id: `msg_${sessionID}`, + sessionID, + role: "assistant", + time: { created: 1 }, + }) as unknown as Parameters["apply"]>[0] + + // Establish the parent message so a part.updated is not dropped as an orphan. + const openStreamingMessage = (session: ReturnType, sessionID: string) => { + session.apply({ type: "message.updated", properties: { info: assistantMessage(sessionID) } }) + session.apply({ type: "message.part.updated", properties: { part: textPart("p1", sessionID, "hel") } }) + session.apply({ + type: "message.part.delta", + properties: { sessionID, messageID: `msg_${sessionID}`, partID: "p1", field: "text", delta: "lo" }, + }) + } + + test("an idle status arriving WHILE parts stream keeps session_working true", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("s1") } }) + // The turn is running: busy + a text part that is actively receiving deltas. + session.apply({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }) + openStreamingMessage(session, "s1") + expect(session.data.session_working("s1")).toBe(true) + + // A RACING/STALE idle arrives (reordered, or from a prior turn). The rail must + // NOT blank while deltas are still streaming. + session.apply({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } }) + expect(session.data.session_working("s1")).toBe(true) // <-- RED before the fix + }) + + test("a genuine idle AFTER streaming stops clears session_working", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("s2") } }) + session.apply({ type: "session.status", properties: { sessionID: "s2", status: { type: "busy" } } }) + openStreamingMessage(session, "s2") + expect(session.data.session_working("s2")).toBe(true) + // Streaming settles: the finalizing part.updated clears the stream marker, then idle. + session.apply({ type: "message.part.updated", properties: { part: textPart("p1", "s2", "hello") } }) + session.apply({ type: "session.status", properties: { sessionID: "s2", status: { type: "idle" } } }) + expect(session.data.session_working("s2")).toBe(false) + }) + + test("no false floor: a session with no streaming and an idle status is not working", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("s3") } }) + session.apply({ type: "session.status", properties: { sessionID: "s3", status: { type: "idle" } } }) + expect(session.data.session_working("s3")).toBe(false) + }) +}) + +// ── #1637 — turn-active floor keyed on execution.started (covers no-part turns) ── +// The #1617 per-delta floor only rises on message.part.{delta}, but most turns +// stream via message.part.updated and some turns produce NO parts at all +// (refusal, immediate provider error, empty completion, abort-before-first-token). +// The turn-active flag is keyed on session.execution.started — the bracket the +// server emits for EVERY turn shape — and cleared on the terminal execution +// events OR any fallback (session.error / eviction / idle status frame / bounded +// timeout), so a swallowed terminal cannot wedge the rail "working" forever. +describe("#1637 — turn-active floor keyed on execution.started", () => { + test("a no-part turn keeps session_working true for its duration and clears once when it ends", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("np1") } }) + // execution.started brackets the turn — no parts are ever produced. + session.apply({ type: "session.execution.started", properties: { sessionID: "np1" } }) + // The server stamps a monotonic seq on the busy status frame (#1636). + session.apply({ type: "session.status", properties: { sessionID: "np1", status: { type: "busy" }, seq: 5 } }) + expect(session.data.session_working("np1")).toBe(true) + // A stray/reordered idle from a prior turn (lower seq) must NOT blank the rail: + // the seq guard drops it before it can clear the turn flag. + session.apply({ type: "session.status", properties: { sessionID: "np1", status: { type: "idle" }, seq: 2 } }) + expect(session.data.session_working("np1")).toBe(true) + // The turn ends (empty completion) → clears exactly once. + session.apply({ type: "session.execution.succeeded", properties: { sessionID: "np1" } }) + expect(session.data.session_working("np1")).toBe(false) + }) + + test("a turn streaming only tool/reasoning parts keeps session_working true across a stray idle", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("to1") } }) + session.apply({ type: "session.execution.started", properties: { sessionID: "to1" } }) + session.apply({ type: "session.status", properties: { sessionID: "to1", status: { type: "busy" }, seq: 8 } }) + // A completed tool part (no text delta stream) arrives; then a racing idle. + session.apply({ + type: "message.part.updated", + properties: { part: toolPart({ id: "t1", sessionID: "to1", tool: "read" }) }, + }) + // A stray idle (lower seq) — dropped by the seq guard, floor held by the turn flag. + session.apply({ type: "session.status", properties: { sessionID: "to1", status: { type: "idle" }, seq: 4 } }) + expect(session.data.session_working("to1")).toBe(true) + session.apply({ type: "session.execution.interrupted", properties: { sessionID: "to1" } }) + expect(session.data.session_working("to1")).toBe(false) + }) + + test("clear path: session.execution.failed clears the flag (no stuck-working)", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("cf") } }) + session.apply({ type: "session.execution.started", properties: { sessionID: "cf" } }) + expect(session.data.session_working("cf")).toBe(true) + session.apply({ type: "session.execution.failed", properties: { sessionID: "cf" } }) + expect(session.data.session_working("cf")).toBe(false) + }) + + test("clear path: session.error clears a flag left set by a swallowed terminal", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("ce") } }) + session.apply({ type: "session.execution.started", properties: { sessionID: "ce" } }) + expect(session.data.session_working("ce")).toBe(true) + // A Session.Event.Error site skips the terminal execution event; session.error is the fallback. + session.apply({ type: "session.error", properties: { sessionID: "ce", error: { name: "ProviderError" } } }) + expect(session.data.session_working("ce")).toBe(false) + }) + + test("clear path: eviction / session.deleted clears the flag", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("cd") } }) + session.apply({ type: "session.execution.started", properties: { sessionID: "cd" } }) + expect(session.data.session_working("cd")).toBe(true) + session.apply({ type: "session.deleted", properties: { sessionID: "cd" } }) + expect(session.data.session_working("cd")).toBe(false) + }) + + test("clear path: an idle session.status frame clears the flag once the turn is genuinely idle", () => { + // The idle-frame fallback clears the turn flag. (The mid-turn floor is held by + // the ACTIVE-part stream / a fresh execution bracket, not a lone idle frame; + // here there is no active stream, so an authoritative idle frame settles it.) + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("ci") } }) + session.apply({ type: "session.execution.started", properties: { sessionID: "ci" } }) + expect(session.data.session_working("ci")).toBe(true) + session.apply({ type: "session.status", properties: { sessionID: "ci", status: { type: "idle" } } }) + // The flag itself is cleared by the idle frame (fallback); with no active + // stream and idle status, the session is no longer working. + expect(session.data.session_working("ci")).toBe(false) + }) + + test("clear path: the bounded timeout clears a flag whose terminal never arrived", async () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("ct") } }) + // A tiny timeout so the test is fast; the flag must self-clear when it fires. + session.apply({ type: "session.execution.started", properties: { sessionID: "ct", turnFloorTimeoutMs: 20 } }) + expect(session.data.session_working("ct")).toBe(true) + await new Promise((r) => setTimeout(r, 60)) + expect(session.data.session_working("ct")).toBe(false) + }) +}) + +// ── #1637 — honor status seq (drop stale/out-of-order status) — depends on #1636 ── +// #1636 stamps a strictly-increasing-per-session `seq` on session.status event +// DATA (v1 event.properties.seq / v2 event.data.seq, adapted to properties.seq +// in this reducer). A client tracking the max seq per session must DISCARD any +// status frame carrying seq <= what it has already seen. +describe("#1637 — honor status seq: a status with seq <= last seen is discarded", () => { + test("a stale idle (seq below the last busy) does not overwrite the live busy", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("q1") } }) + session.apply({ type: "session.status", properties: { sessionID: "q1", status: { type: "busy" }, seq: 5 } }) + expect(session.data.session_status.q1?.type).toBe("busy") + // An out-of-order idle with a LOWER seq must be dropped. + session.apply({ type: "session.status", properties: { sessionID: "q1", status: { type: "idle" }, seq: 3 } }) + expect(session.data.session_status.q1?.type).toBe("busy") + // A newer seq is honored. + session.apply({ type: "session.status", properties: { sessionID: "q1", status: { type: "idle" }, seq: 6 } }) + expect(session.data.session_status.q1?.type).toBe("idle") + }) + + test("an equal seq is discarded (strictly-greater to win)", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("q2") } }) + session.apply({ type: "session.status", properties: { sessionID: "q2", status: { type: "busy" }, seq: 10 } }) + session.apply({ type: "session.status", properties: { sessionID: "q2", status: { type: "idle" }, seq: 10 } }) + expect(session.data.session_status.q2?.type).toBe("busy") + }) + + test("seq is optional: a status frame without seq is applied (pre-#1636 compat)", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("q3") } }) + session.apply({ type: "session.status", properties: { sessionID: "q3", status: { type: "busy" }, seq: 4 } }) + // A frame with NO seq is still applied (the flag+reconcile halves must land without seq). + session.apply({ type: "session.status", properties: { sessionID: "q3", status: { type: "idle" } } }) + expect(session.data.session_status.q3?.type).toBe("idle") + }) +}) + +// ── #1637 — reconcile session_status from /session/status on reconnect/gap ── +// On a reconnect edge and on a gap frame the client must re-fetch the FULL +// tri-state status map (/session/status — NOT /session/active, which is +// running-only and cannot carry idle) and correct BOTH a stale idle and a stale +// busy without a reload — recency-guarded so an out-of-order reconcile cannot +// downgrade a LIVE busy to idle. +describe("#1637 — reconcileStatuses from /session/status (tri-state)", () => { + test("corrects a stale idle: a fetched busy raises a session the client left idle", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("r1") } }) + session.apply({ type: "session.status", properties: { sessionID: "r1", status: { type: "idle" } } }) + // A reconcile fetched AFTER the local idle sees the session actually busy. + session.reconcileStatuses({ r1: { type: "busy" } }, { fetchedAt: Date.now() + 1000 }) + expect(session.data.session_status.r1?.type).toBe("busy") + }) + + test("corrects a stale busy: a fetched idle downgrades a session the client left busy (older local mutation)", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("r2") } }) + session.apply({ type: "session.status", properties: { sessionID: "r2", status: { type: "busy" } } }) + // The reconcile response is newer than the last local mutation → downgrade is honored. + session.reconcileStatuses({ r2: { type: "idle" } }, { fetchedAt: Date.now() + 1000 }) + expect(session.data.session_status.r2?.type).toBe("idle") + }) + + test("recency guard: an out-of-order (older) reconcile does NOT downgrade a live busy to idle", () => { + const session = createSession() + session.apply({ type: "session.created", properties: { info: sessionInfo("r3") } }) + // The reconcile was fetched in the past; the local busy is fresher. + const past = Date.now() - 10_000 + session.apply({ type: "session.status", properties: { sessionID: "r3", status: { type: "busy" } } }) + session.reconcileStatuses({ r3: { type: "idle" } }, { fetchedAt: past }) + expect(session.data.session_status.r3?.type).toBe("busy") // live busy preserved + }) +}) + +// ── #1766 — the fleet re-seed relies on these reconcile semantics ─────────── +// The fleet session-status read returns a tri-state map in which every COVERED +// session has an explicit status (idle by absence in its own successfully +// queried directory) and every uncovered session is OMITTED. Through the +// existing recency-guarded reconcile: an explicit idle downgrades a stale busy; +// an omitted key never changes the store (an unreachable owner never blanks a +// live rail); a busy the store mutated after the read was issued survives. +describe("#1766 — fleet re-seed through reconcileStatuses", () => { + test("an explicit idle from the fleet read settles a stale busy to idle", () => { + const session = createSession() + session.apply({ type: "session.status", properties: { sessionID: "f1", status: { type: "busy" } } }) + const fetchedAt = Date.now() + 1000 // the read was issued after the last local mutation + session.reconcileStatuses({ f1: { type: "idle" } }, { fetchedAt }) + expect(session.data.session_status.f1?.type).toBe("idle") + }) + + test("a session OMITTED from the fleet map keeps its current status (busy stays busy, idle stays idle)", () => { + const session = createSession() + session.apply({ type: "session.status", properties: { sessionID: "f2", status: { type: "busy" } } }) + session.apply({ type: "session.status", properties: { sessionID: "f3", status: { type: "idle" } } }) + session.reconcileStatuses({ other: { type: "idle" } }, { fetchedAt: Date.now() + 1000 }) + expect(session.data.session_status.f2?.type).toBe("busy") + expect(session.data.session_status.f3?.type).toBe("idle") + }) + + test("a peer session reported busy becomes busy in the store, so session_working rises", () => { + const session = createSession() + expect(session.data.session_working("ses_peer")).toBe(false) + session.reconcileStatuses({ ses_peer: { type: "busy" } }, { fetchedAt: Date.now() }) + expect(session.data.session_status.ses_peer?.type).toBe("busy") + expect(session.data.session_working("ses_peer")).toBe(true) + }) + + test("a busy the store mutated AFTER the read was issued is not downgraded by the read's idle", () => { + const session = createSession() + const issuedAt = Date.now() - 5_000 + session.apply({ type: "session.status", properties: { sessionID: "f4", status: { type: "busy" } } }) + session.reconcileStatuses({ f4: { type: "idle" } }, { fetchedAt: issuedAt }) + expect(session.data.session_status.f4?.type).toBe("busy") + }) +}) + +// ── #1766 — the pinned set the fleet re-seed names ─────────────────────────── +describe("#1766 — pinnedIds / onPinned", () => { + test("pinnedIds lists every currently pinned session once, however many pins it holds", () => { + const session = createSession() + session.pin("a") + session.pin("b") + session.pin("a") // a tab pin + the route pin + expect([...session.pinnedIds()].sort()).toEqual(["a", "b"]) + session.unpin("a") + expect([...session.pinnedIds()].sort()).toEqual(["a", "b"]) // still one pin on a + session.unpin("a") + session.unpin("b") + expect(session.pinnedIds()).toEqual([]) + }) + + test("onPinned fires only when a session becomes NEWLY pinned (0 → 1), and stops after unsubscribe", () => { + const session = createSession() + const seen: string[] = [] + const off = session.onPinned((id) => seen.push(id)) + session.pin("a") + session.pin("a") // already pinned → not new + session.pin("b") + session.unpin("a") + session.unpin("a") + session.pin("a") // pinned again after reaching zero → new again + off() + session.pin("c") + expect(seen).toEqual(["a", "b", "a"]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-session.ts b/packages/app-bundle/overlay/packages/app/src/context/server-session.ts index c95a37350..cca46f3f2 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-session.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/server-session.ts @@ -22,6 +22,7 @@ import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/s import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import { deleteMirror, loadMirror, mirrorSlice, saveMirror } from "./session-mirror" +import { cancelReconcileTimer } from "./session-status-reconcile" import type { ServerApi } from "@/utils/server" type MessageApi = ServerApi["message"] @@ -142,9 +143,23 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { } } -function runInflight(map: Map>, key: string, task: () => Promise) { +export function runInflight( + map: Map>, + key: string, + task: () => Promise, + options?: { force?: boolean }, +) { const pending = map.get(key) - if (pending) return pending + // #1646: a FORCED run must NOT coalesce onto a pending NON-forced task. The + // pending task may have been issued BEFORE a just-committed mutation (e.g. the + // archive PATCH) and resolves with STALE state — the inflight-layer twin of + // the resolve() coalescing f54987a9 fixed at the requests layer. sync()'s + // force-sync (the archive read-only flip) rides through here, so without this + // the forced task never runs while any non-forced sync/prefetch is in flight + // for the session, and the composer never flips. A forced run always executes + // its own task and becomes the new pending; the finally-guard keeps that + // overwrite safe (only the promise still registered deletes itself). + if (pending && !options?.force) return pending const promise = task().finally(() => { if (map.get(key) === promise) map.delete(key) }) @@ -222,8 +237,42 @@ export function createServerSession( session_message: {} as Record, part: {} as Record, part_text_accum_delta: {} as Record, + // #1649 — ancestor-of-active-child floor. Count of non-idle DESCENDANT + // sessions per ancestor id, pushed reactively (setData) as child statuses + // change — the same shape as diff_version's ancestor propagation. A + // foreground subagent blocks its parent's turn inside the task tool + // (background.wait): the engine holds the parent runner busy but the parent + // emits no parts and no execution bracket, so neither the streamActiveParts + // nor the turnActive floor rises, and a stray/reconcile idle would blank the + // parent rail. This floor keeps the parent working while any descendant is + // non-idle. It MUST be a store leaf (not a plain-Map read) so the parent's + // projection memo re-runs on a first child spawn and on every child edge. + session_child_active: {} as Record, session_working(id: string) { - return (this.session_status[id]?.type ?? "idle") !== "idle" + // #1637 — turn-active floor keyed on the server's execution bracket. The + // #1617 per-delta floor (streamActiveParts) only rises on + // `message.part.delta`, but most turns stream via `message.part.updated` + // and some turns produce NO parts at all (refusal, immediate provider + // error, empty completion, abort-before-first-token) — so that floor would + // never rise for them and a stale idle blanks the rail. The turn flag rises + // on `session.execution.started` (which brackets EVERY turn shape) and + // clears on the terminal execution events OR a fallback (session.error / + // eviction / a seq/recency-honored idle status frame / a bounded timeout) + // so a swallowed terminal cannot wedge it "working" forever. + if (turnActive.has(id)) return true + // #1617 — self-correcting floor against a racing/stale `idle`. Report working + // when the status says busy OR a text part for this session is mid-stream — a + // `message.part.delta` sets an accum entry (streamActiveParts records its + // session) and the finalizing `message.part.updated` clears it, so this + // floor is up exactly while tokens flow and cannot be blanked by a stray + // idle. When streaming genuinely stops, the entry clears and idle settles. + if ((this.session_status[id]?.type ?? "idle") !== "idle") return true + // #1649 — ancestor floor: a session with any non-idle descendant (a + // running foreground subagent) is working, even when its own status leaf + // has gone stray-idle (reconcile downgrade) and no part is streaming. This + // is a reactive store read, so the parent rail re-runs on the child edge. + if ((this.session_child_active[id] ?? 0) > 0) return true + return streamActiveParts.has(id) }, }) const requests = new Map>() @@ -234,9 +283,92 @@ export function createServerSession( const messageLoads = new Map() const pendingParts = new Map>>() const orphanParts = new Map>() + // #1646 (transcript self-heal) — per-session guard so an orphan-part BURST + // (many parts arriving ahead of their parent message.updated after a + // reconnect gap) triggers at most ONE forced resync per session while one is + // outstanding. Cleared when the resync settles. + const orphanResyncInflight = new Set() const removedMessages = new Map>() const deltaBases = new Map() const taskSpawnParent = new Map() + // #1649 — per-child last-known contribution to the ancestor floor (0 = idle, + // 1 = non-idle). The floor is applied as a DELTA against this remembered + // value, so a duplicated or missed status frame cannot drift the ancestor + // counts (idempotent): re-applying the same contribution is a no-op. + const childActiveContribution = new Map() + // #1617 — sessionID → set of part ids currently mid-stream (a `part.delta` has + // arrived and the finalizing `part.updated` has not). The session_working() + // floor reads this so a racing/stale `idle` cannot blank the rail while tokens + // are still flowing. Populated/cleared in lockstep with `part_text_accum_delta`. + const streamActiveParts = new Map>() + const markPartStreaming = (sessionID: string, partID: string) => { + const set = streamActiveParts.get(sessionID) ?? new Set() + set.add(partID) + streamActiveParts.set(sessionID, set) + } + const clearPartStreaming = (sessionID: string | undefined, partID: string) => { + // sessionID is known at delta time; at clear time we may only have the partID, + // so sweep when the session is unknown (small sets; correctness over micro-opt). + if (sessionID !== undefined) { + const set = streamActiveParts.get(sessionID) + set?.delete(partID) + if (set && set.size === 0) streamActiveParts.delete(sessionID) + return + } + for (const [sid, set] of streamActiveParts) { + if (set.delete(partID) && set.size === 0) streamActiveParts.delete(sid) + } + } + // #1637 — sessionID → the turn is bracketed by a `session.execution.started` + // whose terminal has not yet arrived. This is the turn-active floor: it rises + // on the execution bracket the server emits for EVERY turn shape (part-bearing + // or not) and clears on the terminal execution events OR a fallback, so a + // no-part turn (refusal / immediate error / empty completion / abort) is + // covered and a swallowed terminal cannot wedge the rail "working". + const turnActive = new Set() + const turnFloorTimers = new Map>() + // The bounded staleness timeout: if no terminal (and no other clear) arrives + // within this window, the flag self-clears so the rail can never be stuck + // "working" forever from a fully-swallowed terminal path. + const TURN_FLOOR_TIMEOUT_MS = 5 * 60_000 + const clearTurnFloorTimer = (sessionID: string) => { + const timer = turnFloorTimers.get(sessionID) + if (timer !== undefined) { + clearTimeout(timer) + turnFloorTimers.delete(sessionID) + } + } + const clearTurnActive = (sessionID: string) => { + turnActive.delete(sessionID) + clearTurnFloorTimer(sessionID) + } + const markTurnActive = (sessionID: string, timeoutMs = TURN_FLOOR_TIMEOUT_MS) => { + turnActive.add(sessionID) + clearTurnFloorTimer(sessionID) + const timer = setTimeout(() => { + turnFloorTimers.delete(sessionID) + turnActive.delete(sessionID) + // The bounded timeout is a hard safety net: settle the status to idle too, + // so a fully-swallowed terminal (which never set idle) cannot leave the + // status floor reading "busy" forever. + if ((data.session_status[sessionID]?.type ?? "idle") !== "idle") + setData("session_status", sessionID, { type: "idle" }) + }, timeoutMs) + if (typeof timer === "object" && "unref" in timer) timer.unref() + turnFloorTimers.set(sessionID, timer) + } + // #1637 — sessionID → the max status `seq` seen for it (#1636 stamps a + // strictly-increasing-per-session seq on session.status event DATA). A status + // frame carrying seq <= what we have seen is stale/out-of-order and MUST be + // discarded so a reordered idle cannot overwrite a live busy. Absent seq + // (pre-#1636 wire) → the frame is always applied. + const lastStatusSeq = new Map() + // #1637 — sessionID → the local wall-clock time of its last status mutation. + // The reconnect/gap reconcile from /session/status carries no seq, so its + // recency guard is temporal: a reconcile fetched BEFORE the last local status + // mutation is stale for that session and must not downgrade a live busy. + const lastStatusMutationAt = new Map() + const stampStatusMutation = (sessionID: string) => lastStatusMutationAt.set(sessionID, Date.now()) const completedFileDiffParts = new Map>() const deleteMessageParts = ( cache: { part: Record; part_text_accum_delta: Record }, @@ -251,6 +383,7 @@ export function createServerSession( const seen = new Set() const infoSeen = new Set() const pinned = new Map() + const pinListeners = new Set<(sessionID: string) => void>() const generations = new Map() const generation = (sessionID: string) => { const current = generations.get(sessionID) @@ -292,6 +425,10 @@ export function createServerSession( if (typeof parentSessionId !== "string" || typeof sessionId !== "string") return if (!parentSessionId || !sessionId || parentSessionId !== part.sessionID) return taskSpawnParent.set(sessionId, parentSessionId) + // #1649 — the child's status may have arrived BEFORE this mapping existed + // (SSE frames are not ordered across sessions). Seed the ancestor floor from + // the child's current status now that the ancestry link is known. + refreshChildActive(sessionId) } const incrementFileDiffVersion = (part: Part) => { @@ -312,6 +449,32 @@ export function createServerSession( } } + // #1649 — recompute a child session's contribution to the ancestor floor and + // propagate the DELTA up the taskSpawnParent chain into the reactive + // session_child_active leaf. Called on every child session.status edge and on + // spawn. `nextContribution` overrides the derived value (used on eviction to + // force a session's contribution to 0). Idempotent: the delta is computed + // against the remembered contribution, so a duplicate/reordered frame is a + // no-op and a missed frame self-corrects on the next one. + const refreshChildActive = (childSessionID: string, nextContribution?: number) => { + // Only a spawned child (present in the ancestry map) can lift an ancestor. + if (!taskSpawnParent.has(childSessionID)) return + const derived = (data.session_status[childSessionID]?.type ?? "idle") !== "idle" ? 1 : 0 + const next = nextContribution ?? derived + const prev = childActiveContribution.get(childSessionID) ?? 0 + if (next === prev) return + const delta = next - prev + if (next === 0) childActiveContribution.delete(childSessionID) + else childActiveContribution.set(childSessionID, next) + const seen = new Set([childSessionID]) + let ancestor = taskSpawnParent.get(childSessionID) + while (ancestor && !seen.has(ancestor)) { + seen.add(ancestor) + setData("session_child_active", ancestor, (count = 0) => Math.max(0, count + delta)) + ancestor = taskSpawnParent.get(ancestor) + } + } + const remember = (session: Session) => { setData("info", session.id, reconcile(session)) infoSeen.delete(session.id) @@ -359,8 +522,15 @@ export function createServerSession( const resolve = (sessionID: string, options?: { force?: boolean }) => { const cached = data.info[sessionID] if (cached && !options?.force) return Promise.resolve(cached) + // #1646: a FORCED resolve must NOT ride a non-forced in-flight request. That + // request may have been issued BEFORE a just-committed mutation (e.g. the + // archive PATCH) and will resolve with STALE info, re-remembering it and + // clobbering the fresh state — the exact regression where archiving a local + // session left the row gone from the list but the session live (no + // read-only banner, menu still "Archive"). A non-forced caller may still + // coalesce onto any pending request. const pending = requests.get(sessionID) - if (pending) return pending + if (pending && !options?.force) return pending const active = generation(sessionID) const request = sessionApi ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) @@ -539,6 +709,18 @@ export function createServerSession( generations.delete(sessionID) clearOptimistic(sessionID) completedFileDiffParts.delete(sessionID) + streamActiveParts.delete(sessionID) // #1617 streaming floor: session gone + clearTurnActive(sessionID) // #1637 turn-active floor: session gone + // #1649 — lower this session's contribution to its ancestors' floor + // BEFORE severing the ancestry links, then drop its own bookkeeping. A + // session that is itself an ancestor has its session_child_active leaf + // deleted in the produce block below. + refreshChildActive(sessionID, 0) + taskSpawnParent.delete(sessionID) + for (const [childSessionID, parentSessionID] of taskSpawnParent) { + if (parentSessionID === sessionID) taskSpawnParent.delete(childSessionID) + } + childActiveContribution.delete(sessionID) requests.delete(sessionID) inflight.delete(sessionID) inflightTodo.delete(sessionID) @@ -550,7 +732,10 @@ export function createServerSession( }) setData( produce((draft) => { - for (const sessionID of sessionIDs) delete draft.diff_version[sessionID] + for (const sessionID of sessionIDs) { + delete draft.diff_version[sessionID] + delete draft.session_child_active[sessionID] // #1649 + } dropSessionCaches(draft, sessionIDs) }), ) @@ -769,6 +954,20 @@ export function createServerSession( preserveUnfetched, compare: compareMessages, }) + // #1579: an empty reconcile result against a populated store is a + // transient wire anomaly (empty page from a just-created session, timing + // gap during a concurrent SSE stream), not a real history wipe. Applying + // it blanks the timeline for one reactive tick — the "responses + // disappear" flash — then the next SSE event or warm pass re-populates. + // Skip the replacement; the existing data is more truthful. + const currentCount = data.message[sessionID]?.length ?? 0 + if (messages.length === 0 && currentCount > 0) { + loadDebug(sessionID, "skip-empty-replace", { currentCount }) + // Still update meta so the session is marked as loaded, but don't + // touch the message/part stores. + setMeta("at", sessionID, Date.now()) + return + } batch(() => { if (source) setData("session_message", sessionID, reconcile(source)) const messageIDs = replaceMessages(sessionID, messages) @@ -1050,7 +1249,7 @@ export function createServerSession( ? Promise.resolve() : loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize), ]) - }) + }, { force: options?.force }) } const prefetch = async (sessionID: string, limit: number) => { @@ -1103,7 +1302,26 @@ export function createServerSession( const projectV2 = (reduction: V2SessionReduction) => { reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) - setData("session_message", reduction.sessionID, reconcile(reduction.messages)) + // #1579: the V2 reducer receives `data.session_message[id] ?? []` as its + // base. When session_message hasn't been loaded yet (the session was just + // opened and the wire fetch is still in flight), the base is `[]` and the + // reduction contains ONLY the messages the SSE event described — typically + // a single message. A wholesale reconcile here would replace a fully-loaded + // session_message with that one-message array, wiping the timeline for one + // reactive tick until the next event or the wire fetch lands. Guard: never + // shrink an already-populated session_message; merge the touched entries + // into the existing array instead. + const existing = data.session_message[reduction.sessionID] + if (existing && existing.length > reduction.messages.length) { + const incomingIDs = new Set(reduction.messages.map((m) => m.id)) + const merged = [ + ...existing.filter((m) => !incomingIDs.has(m.id)), + ...reduction.messages, + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + setData("session_message", reduction.sessionID, reconcile(merged)) + } else { + setData("session_message", reduction.sessionID, reconcile(reduction.messages)) + } if (reduction.touched.length === 0) return const touched = new Set(reduction.touched) @@ -1186,8 +1404,10 @@ export function createServerSession( event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || event.type === "session.execution.interrupted" - ) + ) { + cancelReconcileTimer(sessionID) setData("session_status", sessionID, { type: "idle" }) + } if (event.type === "session.retry.scheduled") setData("session_status", sessionID, { type: "retry", @@ -1239,10 +1459,10 @@ export function createServerSession( const properties = event.properties as { sessionID?: string; info?: Session } const sessionID = properties.info?.id ?? properties.sessionID if (!sessionID) return - taskSpawnParent.delete(sessionID) - for (const [childSessionID, parentSessionID] of taskSpawnParent) { - if (parentSessionID === sessionID) taskSpawnParent.delete(childSessionID) - } + // #1649 — taskSpawnParent teardown (self + children) now lives in evict(), + // which also lowers the ancestor floor BEFORE severing the links. Calling + // evict below performs it; the manual deletes here would sever the links + // first and leak the floor, so they are removed. completedFileDiffParts.delete(sessionID) infoSeen.delete(sessionID) setData( @@ -1262,8 +1482,66 @@ export function createServerSession( return } case "session.status": { - const props = event.properties as { sessionID: string; status: SessionStatus } + const props = event.properties as { sessionID: string; status: SessionStatus; seq?: number } + // #1637 honor seq (#1636): drop a stale/out-of-order frame — a status + // whose seq is <= the last seen for this session is discarded so a + // reordered idle cannot overwrite a live busy. Absent seq → always apply. + if (typeof props.seq === "number") { + const seen = lastStatusSeq.get(props.sessionID) + if (seen !== undefined && props.seq <= seen) return + lastStatusSeq.set(props.sessionID, props.seq) + } + // #1637 fallback clear: an authoritative idle frame ends the turn bracket. + // (The mid-turn floor is held by an active part stream / a fresh execution + // bracket — a lone idle frame does not race those; it settles a turn that + // has otherwise gone quiet.) + if ((props.status?.type ?? "idle") === "idle") clearTurnActive(props.sessionID) + stampStatusMutation(props.sessionID) setData("session_status", props.sessionID, reconcile(props.status)) + // #1649 — if this session is a spawned child, propagate its new + // idle/non-idle state to its ancestors' floor (no-op otherwise). + refreshChildActive(props.sessionID) + return + } + case "session.execution.started": { + // #1637 turn-active floor: rises on the execution bracket the server + // emits for EVERY turn shape (part-bearing or not). + const props = event.properties as { sessionID?: string; turnFloorTimeoutMs?: number } | undefined + const id = props?.sessionID ?? eventID + if (!id) return + markTurnActive(id, props?.turnFloorTimeoutMs) + stampStatusMutation(id) + setData("session_status", id, { type: "busy" }) + return + } + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": { + // #1637 terminal clear: the turn bracket closed. + const props = event.properties as { sessionID?: string } | undefined + const id = props?.sessionID ?? eventID + if (!id) return + cancelReconcileTimer(id) + clearTurnActive(id) + stampStatusMutation(id) + setData("session_status", id, { type: "idle" }) + // #1649 — a child turn's terminal lowers its ancestor floor. + refreshChildActive(id) + return + } + case "session.error": { + // #1637 fallback clear: a Session.Event.Error site (10 in prompt.ts) and + // /session/abort can skip the terminal execution event — session.error + // clears the turn flag AND settles a busy status to idle so a swallowed + // terminal cannot wedge the rail (neither the turn floor nor the status + // floor is left up). + const props = event.properties as { sessionID?: string } | undefined + const id = props?.sessionID ?? eventID + if (!id) return + clearTurnActive(id) + if ((data.session_status[id]?.type ?? "idle") !== "idle") setData("session_status", id, { type: "idle" }) + // #1649 — a child error settles it idle; lower its ancestor floor. + refreshChildActive(id) return } case "message.updated": { @@ -1342,14 +1620,38 @@ export function createServerSession( const messages = data.message[part.sessionID] const load = messageLoads.get(part.sessionID) const missing = !messages?.some((message) => message.id === part.messageID) - // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. + // A part for a message we KNOW was removed/cleared must never be + // accepted — that would resurrect a deleted message. This short-circuit + // holds whether or not a load is active, and never triggers a resync. if ( missing && - (!load || - load.clearedMessageParts.has(part.messageID) || + (load?.clearedMessageParts.has(part.messageID) || removedMessages.get(part.sessionID)?.has(part.messageID)) ) return + // #1646 (transcript self-heal) — outside a page load, a part whose + // ordered parent message.updated we never saw is a GENUINE orphan: the + // classic mid-stream reconnect gap where the parent frame was the one + // lost and the lastEventID cursor could not replay it. Silently dropping + // it (the old behavior) left the row permanently absent until a manual + // reload. Instead, request a forced resync to backfill the parent — + // debounced per session (novel-messageID + an in-flight guard) so a + // part burst yields at most one sync, and still dropping the part itself + // (a part without its parent cannot be ordered into the list). + if (missing && !load) { + const known = orphanParts.get(part.sessionID) + const novel = !known?.has(part.messageID) + const orphans = known ?? new Set() + orphans.add(part.messageID) + orphanParts.set(part.sessionID, orphans) + if (novel && !orphanResyncInflight.has(part.sessionID)) { + orphanResyncInflight.add(part.sessionID) + void sync(part.sessionID, { force: true }) + .catch(() => {}) + .finally(() => orphanResyncInflight.delete(part.sessionID)) + } + return + } if (missing) { const orphans = orphanParts.get(part.sessionID) ?? new Set() orphans.add(part.messageID) @@ -1375,6 +1677,7 @@ export function createServerSession( deltaBases.delete(part.id) trackPartChange(part.sessionID, part.messageID, part.id) confirmOptimisticPart(part.sessionID, part.messageID, part) + clearPartStreaming(part.sessionID, part.id) // #1617 streaming floor: part finalized setData( "part_text_accum_delta", produce((draft) => void delete draft[part.id]), @@ -1419,6 +1722,7 @@ export function createServerSession( } trackPartChange(props.sessionID, props.messageID, props.partID) clearOptimisticPart(props.sessionID, props.messageID, props.partID) + clearPartStreaming(props.sessionID, props.partID) // #1617 streaming floor: part removed setData( produce((draft) => { delete draft.part_text_accum_delta[props.partID] @@ -1445,6 +1749,7 @@ export function createServerSession( const result = Binary.search(parts, props.partID, (part) => part.id) if (!result.found) return trackPartChange(props.sessionID, props.messageID, props.partID) + markPartStreaming(props.sessionID, props.partID) // #1617 streaming floor const load = messageLoads.get(props.sessionID) if (load) { const parts = load.deltaParts.get(props.messageID) ?? new Set() @@ -1664,16 +1969,59 @@ export function createServerSession( evict([sessionID]) }, pin(sessionID: string) { - pinned.set(sessionID, (pinned.get(sessionID) ?? 0) + 1) + const count = pinned.get(sessionID) ?? 0 + pinned.set(sessionID, count + 1) touch(sessionID) + // #1766: a NEWLY pinned session (0 → 1) — e.g. a tab restored after the + // connected edge already fired — triggers the debounced fleet re-seed. + if (count === 0) for (const listener of [...pinListeners]) listener(sessionID) }, unpin(sessionID: string) { const count = pinned.get(sessionID) if (!count || count === 1) pinned.delete(sessionID) if (count && count > 1) pinned.set(sessionID, count - 1) }, + /** #1766: the currently pinned session ids (every open tab's session plus + * the viewed one) — the set the fleet status re-seed names. */ + pinnedIds(): string[] { + return [...pinned.keys()] + }, + /** #1766: observe sessions becoming newly pinned (0 → 1). Returns the + * unsubscribe. */ + onPinned(listener: (sessionID: string) => void): () => void { + pinListeners.add(listener) + return () => { + pinListeners.delete(listener) + } + }, apply, applyV2, + // #1637 — reconcile session_status from a /session/status (tri-state) fetch + // on a reconnect edge / gap frame. Corrects BOTH a stale idle and a stale + // busy the client was left holding during an outage. Recency-guarded: a + // downgrade of a live busy to idle is refused when the fetched snapshot is + // OLDER than the last local status mutation for that session (the response + // carries no seq, so the guard is temporal). A stale idle→busy upgrade is + // always safe (it can only raise a floor, never wrongly blank a live rail). + reconcileStatuses(statuses: Record, opts?: { fetchedAt?: number }) { + const fetchedAt = opts?.fetchedAt ?? Date.now() + for (const [sessionID, status] of Object.entries(statuses)) { + if (!status) continue + const current = data.session_status[sessionID] + const currentType = current?.type ?? "idle" + const nextType = status.type + if (currentType === nextType && current) continue + // Recency guard: refuse a downgrade (→ idle) when the local status was + // mutated AFTER this snapshot was fetched — a live busy must survive an + // out-of-order reconcile. Raising a floor (→ non-idle) is always applied. + if (nextType === "idle" && currentType !== "idle") { + const localAt = lastStatusMutationAt.get(sessionID) ?? 0 + if (localAt > fetchedAt) continue + } + stampStatusMutation(sessionID) + setData("session_status", sessionID, reconcile(status)) + } + }, } } diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx index bb49a767c..d0d43c2a5 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx @@ -59,6 +59,14 @@ import type { } from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" import { createServerSession, type ServerSession } from "./server-session" +import { amicodeGetResponse } from "@/utils/amicode-fetch" +import { + createFleetDiagnostics, + fleetSessionStatusRoute, + runFleetReseed, + type FleetReseedDeps, +} from "./fleet-session-status" +import { createFleetReseedScheduler, viewedSessionIdFromLocation } from "./fleet-reseed-scheduler" type GlobalStore = { ready: boolean @@ -301,6 +309,58 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined + // #1637 — reconcile session_status from /session/status (the FULL tri-state + // map — NOT /session/active, which is running-only and cannot carry the idle + // needed to downgrade a stale busy) on every reconnect edge / gap frame. The + // fetch is timestamped BEFORE the request so the store's recency guard can + // refuse a downgrade of a busy that was mutated locally after the snapshot. + // #1766: this is now the FALLBACK — today's local re-seed, unchanged. + const localReseed = async () => { + const fetchedAt = Date.now() + try { + const statuses = (await serverSDK.client.session.status()).data ?? {} + session.reconcileStatuses(statuses, { fetchedAt }) + } catch { + // A failed reconcile is non-fatal — the next edge retries. + } + } + // #1766 — the FLEET re-seed: name the pinned sessions (every open tab's, the + // viewed one first) to the service's fleet session-status read, which asks + // each session's OWNER for status in that session's exact DIRECTORY, and + // apply its tri-state map through the same recency-guarded reconcile. A peer + // session's busy re-learned here re-raises the rail's working floor after a + // reopen (the fan-in does not replay). Unavailable → localReseed(). + const fleetDiagnostics = createFleetDiagnostics() + const fleetReseedDeps: FleetReseedDeps = { + fetchFleet: (ids, signal) => amicodeGetResponse(serverSDK.server, fleetSessionStatusRoute(ids), { signal }), + // The read passes the engine's own SessionStatus entries through validated. + reconcile: (statuses, opts) => session.reconcileStatuses(statuses as Record, opts), + localReseed, + diagnostics: fleetDiagnostics, + } + // #1766 — the re-seed TRIGGERS + 10 s SELF-HEAL: the connected edge and the + // sync-gap frame (below), a newly pinned session (debounced), `unknown` + // retries for a cold owner map, and a visibility-gated periodic tick — all + // single-flight, each read bounded by the client timeout. Nothing pinned → + // the edges keep today's local re-seed. + const fleetReseed = createFleetReseedScheduler({ + pinnedIds: () => session.pinnedIds(), + viewedId: () => + typeof window === "undefined" + ? undefined + : viewedSessionIdFromLocation(`${window.location.pathname}${window.location.hash}`), + reseed: (ids, signal) => runFleetReseed(ids, { ...fleetReseedDeps, signal }), + localReseed, + isConnected: () => serverSDK.event.status() === "connected", + isVisible: () => typeof document === "undefined" || document.visibilityState !== "hidden", + diagnostics: fleetDiagnostics, + }) + const offPinned = session.onPinned((sessionID) => fleetReseed.notePinned(sessionID)) + fleetReseed.start() + onCleanup(() => { + offPinned() + fleetReseed.stop() + }) // #1264/#1289 (reconnect-storm debounce): the SSE preamble replays // `server.connected` to EVERY reconnecting member — over a flaky link a // reconnect flurry would re-bootstrap all active directories once per @@ -548,11 +608,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const event = e.details const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 + // #1539: remote-origin events must NOT mutate the local directory store. + // The origin field is set by the SSE loop when the event came from a remote + // peer's namespace in the fan-in composite cursor. `undefined` = local. + const isRemote = typeof event.origin === "string" if (event.current) session.applyV2(event.current) session.apply(event) if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { - homeSessions.apply(event) + // #1539: homeSessions feeds into the session list that mergePeerSessions + // deduplicates against; remote events contaminate it the same way. + if (!isRemote) homeSessions.apply(event) } homeSessions.refresh(event.type) if (eventType === "integration.connection.updated") void refreshProviders() @@ -560,6 +626,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { if (directory === "global") { if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch() + // #1637 — reconcile the tri-state status on every reconnect edge / gap + // frame so a status left stale during an outage self-corrects without a + // reload (a stale busy AND a stale idle), recency-guarded in the store. + // #1766: through the FLEET read (peer sessions included), local fallback. + if (eventType === "server.connected") fleetReseed.trigger("connected") + if (eventType === "amicode.sync.gap") fleetReseed.trigger("gap") applyGlobalEvent({ event, project: globalStore.project, @@ -573,7 +645,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { eventType === "config.updated" || eventType === "catalog.updated" || eventType === "agent.updated" || - eventType === "project.directories.updated" + eventType === "project.directories.updated" || + // #1617 (ADR 0033 Amendment 1): the fan-in aggregator dropped frames on + // buffer overflow while the downstream was backpressured and emitted a + // gap signal; the SDK loop re-emitted it as this synthetic global event. + // A gap is a real loss, so force a bootstrap refetch UNCONDITIONALLY — + // this branch runs before the #1289 `server.connected` debounce below and + // keys on a distinct type, so the forced refetch bypasses that debounce. + eventType === "amicode.sync.gap" ) bootstrap.refetch() if (eventType === "server.connected" || eventType === "global.disposed") { @@ -584,21 +663,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { lastConnectedQueueAt = Date.now() for (const directory of Object.keys(children.children)) { if (!children.active(directory)) continue - queue.push(directory) + // #1539: a remote server.connected/global.disposed must not trigger + // local directory re-bootstraps. + if (!isRemote) queue.push(directory) } } return } - if (event.current?.type === "session.moved") { - const info = session.get(event.current.data.sessionID) - if (info) indexSession(info) + // #1539: indexSession inserts synthetic session.created events into the + // directory store — skip for remote events. + if (!isRemote) { + if (event.current?.type === "session.moved") { + const info = session.get(event.current.data.sessionID) + if (info) indexSession(info) + } + if (event.current?.type === "session.forked") + void session + .resolve(event.current.data.sessionID, { force: true }) + .then(indexSession) + .catch(() => {}) } - if (event.current?.type === "session.forked") - void session - .resolve(event.current.data.sessionID, { force: true }) - .then(indexSession) - .catch(() => {}) const existing = children.children[key] if (!existing) return @@ -610,10 +695,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { eventType === "command.updated" || eventType === "config.updated" || eventType === "agent.updated" - ) - queue.push(key) + ) { + // #1539: queue.push triggers directory re-fetches that would pull remote + // sessions into the local store. + if (!isRemote) queue.push(key) + } if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) + // #1539: applyDirectoryEvent is the primary contamination point — remote + // events must not insert into or update the directory-scoped store.session. + if (isRemote) return const [store, setStore] = existing applyDirectoryEvent({ event, diff --git a/packages/app-bundle/overlay/packages/app/src/context/session-status-reconcile.test.ts b/packages/app-bundle/overlay/packages/app/src/context/session-status-reconcile.test.ts new file mode 100644 index 000000000..3a25e8783 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/session-status-reconcile.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test" +import { + startReconcileTimer, + cancelReconcileTimer, + cancelAllReconcileTimers, + hasReconcileTimer, +} from "./session-status-reconcile" + +// amicode#1567 — reconciliation safety valve for optimistic "busy" status. +// The timer fires when the SSE event resolving "busy" never arrives (fan-in +// gap, network loss). Pure timer management tests: injectable timeout keeps +// assertions sub-second without fake-timer machinery. + +const SHORT_MS = 30 + +describe("session-status-reconcile (#1567)", () => { + beforeEach(() => cancelAllReconcileTimers()) + afterEach(() => cancelAllReconcileTimers()) + + test("startReconcileTimer calls onReconcile after timeout", async () => { + const onReconcile = mock((_id: string) => {}) + startReconcileTimer("s1", onReconcile, SHORT_MS) + expect(onReconcile).not.toHaveBeenCalled() + expect(hasReconcileTimer("s1")).toBe(true) + await new Promise((r) => setTimeout(r, SHORT_MS + 20)) + expect(onReconcile).toHaveBeenCalledTimes(1) + expect(onReconcile.mock.calls[0]).toEqual(["s1"]) + expect(hasReconcileTimer("s1")).toBe(false) + }) + + test("cancelReconcileTimer prevents the callback from firing", async () => { + const onReconcile = mock(() => {}) + startReconcileTimer("s1", onReconcile, SHORT_MS) + expect(hasReconcileTimer("s1")).toBe(true) + cancelReconcileTimer("s1") + expect(hasReconcileTimer("s1")).toBe(false) + await new Promise((r) => setTimeout(r, SHORT_MS + 20)) + expect(onReconcile).not.toHaveBeenCalled() + }) + + test("starting a new timer for the same session cancels the previous one", async () => { + const first = mock(() => {}) + const second = mock(() => {}) + startReconcileTimer("s1", first, SHORT_MS) + startReconcileTimer("s1", second, SHORT_MS) + await new Promise((r) => setTimeout(r, SHORT_MS + 20)) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) + + test("cancelAllReconcileTimers clears everything", async () => { + const a = mock(() => {}) + const b = mock(() => {}) + startReconcileTimer("s1", a, SHORT_MS) + startReconcileTimer("s2", b, SHORT_MS) + expect(hasReconcileTimer("s1")).toBe(true) + expect(hasReconcileTimer("s2")).toBe(true) + cancelAllReconcileTimers() + expect(hasReconcileTimer("s1")).toBe(false) + expect(hasReconcileTimer("s2")).toBe(false) + await new Promise((r) => setTimeout(r, SHORT_MS + 20)) + expect(a).not.toHaveBeenCalled() + expect(b).not.toHaveBeenCalled() + }) + + test("cancelling a non-existent timer is a no-op", () => { + expect(() => cancelReconcileTimer("nonexistent")).not.toThrow() + expect(hasReconcileTimer("nonexistent")).toBe(false) + }) + + test("timers for different sessions are independent", async () => { + const a = mock(() => {}) + const b = mock(() => {}) + startReconcileTimer("s1", a, SHORT_MS) + startReconcileTimer("s2", b, SHORT_MS * 3) + await new Promise((r) => setTimeout(r, SHORT_MS + 20)) + expect(a).toHaveBeenCalledTimes(1) + expect(b).not.toHaveBeenCalled() + expect(hasReconcileTimer("s1")).toBe(false) + expect(hasReconcileTimer("s2")).toBe(true) + cancelAllReconcileTimers() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/session-status-reconcile.ts b/packages/app-bundle/overlay/packages/app/src/context/session-status-reconcile.ts new file mode 100644 index 000000000..c96d9ecf4 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/session-status-reconcile.ts @@ -0,0 +1,58 @@ +/** + * Session status reconciliation — a safety valve for remote prompts. + * When the optimistic "busy" status is set but the SSE resolution event + * never arrives, this module polls the session endpoint and reconciles. + */ + +export const RECONCILE_TIMEOUT_MS = 30_000 +const pendingTimers = new Map>() + +/** + * Start a reconciliation timer for a session. + * If the SSE event resolves the status within the timeout, call cancel(). + * Otherwise, the timer fires and calls the reconcile callback. + */ +export function startReconcileTimer( + sessionId: string, + onReconcile: (sessionId: string) => void, + timeoutMs = RECONCILE_TIMEOUT_MS, +): void { + // Cancel any existing timer for this session + cancelReconcileTimer(sessionId) + const timer = setTimeout(() => { + pendingTimers.delete(sessionId) + onReconcile(sessionId) + }, timeoutMs) + // Don't block Node's exit (for tests) + if (typeof timer === "object" && "unref" in timer) timer.unref() + pendingTimers.set(sessionId, timer) +} + +/** + * Cancel the reconciliation timer for a session. + * Call this when the SSE event resolves the status normally. + */ +export function cancelReconcileTimer(sessionId: string): void { + const existing = pendingTimers.get(sessionId) + if (existing !== undefined) { + clearTimeout(existing) + pendingTimers.delete(sessionId) + } +} + +/** + * Cancel all pending timers (cleanup on unmount/navigation). + */ +export function cancelAllReconcileTimers(): void { + for (const timer of pendingTimers.values()) { + clearTimeout(timer) + } + pendingTimers.clear() +} + +/** + * Check if a reconciliation timer is pending for a session. + */ +export function hasReconcileTimer(sessionId: string): boolean { + return pendingTimers.has(sessionId) +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/session-working.test.ts b/packages/app-bundle/overlay/packages/app/src/context/session-working.test.ts new file mode 100644 index 000000000..c33ffb866 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/session-working.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { sessionWorkingFromStore } from "./session-working" + +// ── #1637 — the store-readable half of the session_working floor ── +// Both session_working implementations (server-session store + global-sync child +// store) must honor the SAME turn-active floor. This is the shared predicate they +// read: a session is working when its turn flag is up OR its status is not idle. +// (server-session ORs in its in-memory streamActiveParts stream floor on top.) +describe("#1637 sessionWorkingFromStore — shared turn floor", () => { + test("a raised turn flag reads working even when status is idle (no-part turn / stray idle)", () => { + expect(sessionWorkingFromStore({ type: "idle" }, true)).toBe(true) + }) + + test("a busy status reads working when the turn flag is down", () => { + expect(sessionWorkingFromStore({ type: "busy" }, false)).toBe(true) + }) + + test("idle status and a cleared turn flag reads not-working", () => { + expect(sessionWorkingFromStore({ type: "idle" }, false)).toBe(false) + expect(sessionWorkingFromStore(undefined, false)).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/session-working.ts b/packages/app-bundle/overlay/packages/app/src/context/session-working.ts new file mode 100644 index 000000000..a46c53efa --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/session-working.ts @@ -0,0 +1,17 @@ +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" + +/** + * #1637 — the store-readable half of the `session_working` floor, shared by both + * implementations (the server-session store and the global-sync child store) so + * they honor the SAME turn-active floor. + * + * A session is working when its turn-active flag is up (it is inside a + * `session.execution.started` bracket whose terminal / fallback clear has not + * arrived — this covers no-part turns and survives a stray idle) OR its status + * is anything other than idle. The server-session store ORs its in-memory + * streamActiveParts stream floor on top of this. + */ +export function sessionWorkingFromStore(status: SessionStatus | undefined, turnActive: boolean): boolean { + if (turnActive) return true + return (status?.type ?? "idle") !== "idle" +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/sse-gap-frame.test.ts b/packages/app-bundle/overlay/packages/app/src/context/sse-gap-frame.test.ts new file mode 100644 index 000000000..b4ea7025c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/sse-gap-frame.test.ts @@ -0,0 +1,62 @@ +// sse-gap-frame.test.ts — #1617: the client's gap-frame interception. +import { describe, test, expect } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { isSyncGapType, parseSyncGapFrame, SYNC_GAP_TYPE } from "./sse-gap-frame" + +describe("#1617 — sync gap frame recognition", () => { + test("isSyncGapType matches the aggregator's wire type and nothing else", () => { + expect(isSyncGapType(SYNC_GAP_TYPE)).toBe(true) + expect(isSyncGapType("amicode.sync.gap")).toBe(true) + expect(isSyncGapType("amicode.fleet.focus")).toBe(false) + expect(isSyncGapType("session.updated")).toBe(false) + expect(isSyncGapType(undefined)).toBe(false) + expect(isSyncGapType(null)).toBe(false) + }) + + test("parseSyncGapFrame extracts the affected namespaces", () => { + expect(parseSyncGapFrame({ type: SYNC_GAP_TYPE, namespaces: ["studio", "mini"] })).toEqual({ + namespaces: ["studio", "mini"], + }) + }) + + test("parseSyncGapFrame tolerates a missing/malformed namespaces field (empty list, still a gap)", () => { + expect(parseSyncGapFrame({ type: SYNC_GAP_TYPE })).toEqual({ namespaces: [] }) + expect(parseSyncGapFrame({ type: SYNC_GAP_TYPE, namespaces: "studio" })).toEqual({ namespaces: [] }) + expect(parseSyncGapFrame({ type: SYNC_GAP_TYPE, namespaces: [1, "studio", null] })).toEqual({ + namespaces: ["studio"], + }) + }) + + test("parseSyncGapFrame returns undefined only for a non-object", () => { + expect(parseSyncGapFrame(undefined)).toBeUndefined() + expect(parseSyncGapFrame("nope")).toBeUndefined() + expect(parseSyncGapFrame(42)).toBeUndefined() + }) +}) + +// ── wiring integrity (the event-loop glue lives in a closure; assert the seam) ── +// The focus-frame interception this fix is modeled on is likewise verified at the +// source level — the loop body is not independently constructable. These pins are +// falsifiable: deleting the interception or the forced-bootstrap branch reds them. +describe("#1617 — gap interception is wired at both client seams", () => { + const sdkSource = readFileSync(join(import.meta.dir, "server-sdk.tsx"), "utf8") + const syncSource = readFileSync(join(import.meta.dir, "server-sync.tsx"), "utf8") + + test("server-sdk intercepts the gap frame and re-emits it as a synthetic global event", () => { + expect(sdkSource).toContain("isSyncGapType(focusType)") + // re-emitted onto the normal queue as a global event (not a session event) + expect(sdkSource).toMatch(/directory:\s*"global"[\s\S]*SYNC_GAP_TYPE/) + // and the loop skips the rest (never advances the cursor on a gap) + const idx = sdkSource.indexOf("isSyncGapType(focusType)") + expect(sdkSource.slice(idx, idx + 1000)).toContain("continue") + }) + + test("server-sync forces a bootstrap refetch on the gap type, in the debounce-free branch", () => { + // the gap type sits in the UNCONDITIONAL bootstrap.refetch() branch (with + // config.updated etc.), NOT the debounced server.connected branch + expect(syncSource).toMatch(/"amicode\.sync\.gap"[\s\S]*?\)\s*\n\s*bootstrap\.refetch\(\)/) + // the #1289 debounce still guards only server.connected, not the gap + expect(syncSource).toMatch(/server\.connected"\s*&&\s*Date\.now\(\)\s*-\s*lastConnectedQueueAt/) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/sse-gap-frame.ts b/packages/app-bundle/overlay/packages/app/src/context/sse-gap-frame.ts new file mode 100644 index 000000000..b06ae4d4a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/sse-gap-frame.ts @@ -0,0 +1,41 @@ +// sse-gap-frame.ts — #1617 (ADR 0033 Amendment 1): the client half of the fan-in +// backpressure fix. The origin aggregator's overflow defense-in-depth emits an +// id-less `amicode.sync.gap` SSE frame when a bounded per-namespace buffer +// overflow-dropped frames while the downstream was backpressured. The client +// intercepts that frame — exactly where it intercepts `amicode.fleet.focus`, the +// modeled precedent — and drives a FORCED bootstrap refetch that bypasses the +// #1289 reconnect-storm debounce, because a gap is a real loss the debounce must +// not swallow. +// +// This module is PURE (a predicate + a parser) so it is unit-testable in place, +// like `dispatchSseFocusEvent` and `shouldRefetchOnReconnect`. The server-sdk +// event loop calls `isSyncGapFrame` on the same `payload.type` it already reads +// for the focus frame, and on a match runs the forced-resync callback instead of +// the normal event queue. + +/** The wire type of the synthetic gap frame the aggregator emits. */ +export const SYNC_GAP_TYPE = "amicode.sync.gap" as const + +/** The parsed gap frame: the namespaces whose buffers overflow-dropped. */ +export interface SyncGapFrame { + namespaces: string[] +} + +/** True iff `type` names the synthetic gap frame. The server-sdk loop reads the + * frame's `type` off the same adapter/legacy split it uses for the focus frame; + * this is the single point of truth for the match. */ +export function isSyncGapType(type: unknown): boolean { + return type === SYNC_GAP_TYPE +} + +/** Parse the gap frame's data (the `data:` JSON, or the adapter's `properties`). + * Returns the affected namespaces (possibly empty). A gap frame with a missing + * or malformed `namespaces` field still parses to an empty list — the forced + * refetch is unconditional once a gap frame is seen; the namespace list is + * diagnostic, not a gate. Returns undefined only when `data` is not an object. */ +export function parseSyncGapFrame(data: unknown): SyncGapFrame | undefined { + if (!data || typeof data !== "object") return undefined + const ns = (data as { namespaces?: unknown }).namespaces + if (Array.isArray(ns)) return { namespaces: ns.filter((n): n is string => typeof n === "string") } + return { namespaces: [] } +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/sse-origin.test.ts b/packages/app-bundle/overlay/packages/app/src/context/sse-origin.test.ts new file mode 100644 index 000000000..1930076b8 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/sse-origin.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { parseCursorNamespaces, resolveEventOrigin } from "./sse-origin" + +// #1539: SSE fan-in origin detection — the pure logic that determines whether +// an SSE event originated from the local engine or a remote peer, by diffing +// the composite cursor `id:` field the fan-in stamps on each frame. + +describe("parseCursorNamespaces", () => { + test("parses a composite cursor into a namespace→value map", () => { + expect(parseCursorNamespaces("local=5;studio=42")).toEqual({ local: "5", studio: "42" }) + }) + + test("a bare scalar (fleet-of-one, no `=`) returns an empty map", () => { + expect(parseCursorNamespaces("42")).toEqual({}) + }) + + test("undefined / empty string returns an empty map", () => { + expect(parseCursorNamespaces(undefined)).toEqual({}) + expect(parseCursorNamespaces("")).toEqual({}) + }) + + test("handles a single-namespace composite cursor", () => { + expect(parseCursorNamespaces("local=5")).toEqual({ local: "5" }) + }) + + test("skips malformed entries (empty key, no `=`)", () => { + expect(parseCursorNamespaces("local=5;;=bad;studio=42")).toEqual({ local: "5", studio: "42" }) + }) +}) + +describe("resolveEventOrigin", () => { + test("fleet-of-one (bare scalar id) is always local — zero overhead", () => { + const prev = {} + const { origin, cursor } = resolveEventOrigin("42", prev) + expect(origin).toBeUndefined() + // Cursor is returned unchanged (no allocation for fleet-of-one). + expect(cursor).toBe(prev) + }) + + test("undefined id is local", () => { + expect(resolveEventOrigin(undefined, {}).origin).toBeUndefined() + }) + + test("composite cursor with local-only change → local origin", () => { + const prev = { local: "4", studio: "42" } + const { origin, cursor } = resolveEventOrigin("local=5;studio=42", prev) + expect(origin).toBeUndefined() + expect(cursor).toEqual({ local: "5", studio: "42" }) + }) + + test("composite cursor with remote change → remote machine id", () => { + const prev = { local: "5", studio: "41" } + const { origin, cursor } = resolveEventOrigin("local=5;studio=42", prev) + expect(origin).toBe("studio") + expect(cursor).toEqual({ local: "5", studio: "42" }) + }) + + test("first event in composite mode (empty previous) detects local origin when local changes", () => { + // When the stream starts fresh, previous is {}. The first event's cursor + // will differ on ALL namespaces. The first differing namespace is the origin. + // "local" comes first in insertion order → detected as local. + const { origin } = resolveEventOrigin("local=1;studio=0", {}) + expect(origin).toBeUndefined() // local detected first + }) + + test("composite cursor with no change from previous → local (fallback)", () => { + const prev = { local: "5", studio: "42" } + const { origin } = resolveEventOrigin("local=5;studio=42", prev) + expect(origin).toBeUndefined() + }) + + test("multi-peer: correctly identifies which peer changed", () => { + const prev = { local: "5", studio: "42", mbp: "10" } + const { origin } = resolveEventOrigin("local=5;studio=42;mbp=11", prev) + expect(origin).toBe("mbp") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/sse-origin.ts b/packages/app-bundle/overlay/packages/app/src/context/sse-origin.ts new file mode 100644 index 000000000..ba404d519 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/sse-origin.ts @@ -0,0 +1,64 @@ +// SSE FAN-IN ORIGIN DETECTION (#1539) — pure logic for determining whether an +// SSE event originated from the local engine or a remote peer, by diffing the +// composite cursor `id:` field the fan-in stamps on each frame. +// +// The fan-in aggregator (sse_fanin_aggregator.ts) writes a composite cursor as +// the `id:` on every frame in multi-peer mode: `local=5;studio=42`. Each event +// advances exactly one namespace's cursor value. By diffing the previous cursor +// against the current one, we identify which namespace originated the event. +// +// In fleet-of-one mode the `id:` is a bare scalar (no `=`): the function +// returns immediately — zero allocation, byte-identical to today. + +/** Parse a composite cursor string into a namespace→value map. + * + * - `"local=5;studio=42"` → `{local:"5", studio:"42"}` + * - `"42"` (bare scalar, fleet-of-one) → `{}` + * - `""` / undefined → `{}` + * + * App-layer lightweight equivalent of sse_composite_cursor.ts's + * `parseCompositeCursor` — just enough to detect event origin. */ +export function parseCursorNamespaces(id: string | undefined): Record { + if (!id || !id.includes("=")) return {} + const result: Record = {} + for (const entry of id.split(";")) { + if (entry === "") continue + const eq = entry.indexOf("=") + if (eq <= 0) continue + result[entry.slice(0, eq)] = entry.slice(eq + 1) + } + return result +} + +/** Resolve which SSE namespace originated the event by diffing the event's + * composite cursor `id` against the previous cursor state. + * + * Returns `undefined` for a local event (or fleet-of-one — bare scalar id, + * zero overhead), or the remote machine id string. + * + * The composite cursor encodes ALL namespaces' positions in every event's + * `id:` field (e.g. `local=5;studio=42`). The namespace whose value changed + * from the previous cursor is the originator. */ +export function resolveEventOrigin( + currentId: string | undefined, + previousCursor: Record, +): { origin: string | undefined; cursor: Record } { + // Fleet-of-one: bare scalar (no `=`) — always local, zero overhead. + if (!currentId || !currentId.includes("=")) + return { origin: undefined, cursor: previousCursor } + + const current = parseCursorNamespaces(currentId) + let changed: string | undefined + for (const ns of Object.keys(current)) { + if (current[ns] !== previousCursor[ns]) { + changed = ns + break + } + } + + // "local" namespace or no detected change = local origin + return { + origin: changed && changed !== "local" ? changed : undefined, + cursor: current, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/stream-liveness.test.ts b/packages/app-bundle/overlay/packages/app/src/context/stream-liveness.test.ts new file mode 100644 index 000000000..b2364a6f5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/stream-liveness.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { shouldReconnectIdleStream } from "./stream-liveness" + +// #1584 — the client-side SSE liveness watchdog's DECISION, extracted as a +// zero-dependency pure fn so it runs in-place (`bun test --conditions=solid`) +// without the materialized tree: no solid-js, no @opencode-ai/* imports (that +// is exactly what breaks stream-gap.test.ts in-place). The watchdog and both +// wake re-arm handlers in server-sdk.tsx call THIS fn; here we pin the verdict. +// +// Verdict: reconnect iff the stream believes itself "connected" AND no frame +// has arrived for strictly longer than staleMs. A "disconnected" stream is +// already reconnecting via the loop, so the watchdog leaves it alone; a stream +// exactly at the threshold is not yet stale (strict `>`). + +describe("shouldReconnectIdleStream (#1584) — the liveness watchdog's decision", () => { + test("connected + idle beyond the staleness threshold → reconnect", () => { + expect( + shouldReconnectIdleStream({ status: "connected", now: 100_000, lastFrameAt: 60_000, staleMs: 30_000 }), + ).toBe(true) + }) + + test("connected + a recent frame → no reconnect", () => { + expect( + shouldReconnectIdleStream({ status: "connected", now: 100_000, lastFrameAt: 95_000, staleMs: 30_000 }), + ).toBe(false) + }) + + test("disconnected → never reconnect, however idle (the loop already owns it)", () => { + expect( + shouldReconnectIdleStream({ status: "disconnected", now: 100_000, lastFrameAt: 0, staleMs: 30_000 }), + ).toBe(false) + }) + + test("exactly at the threshold → no reconnect (strict greater-than)", () => { + expect( + shouldReconnectIdleStream({ status: "connected", now: 90_000, lastFrameAt: 60_000, staleMs: 30_000 }), + ).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/stream-liveness.ts b/packages/app-bundle/overlay/packages/app/src/context/stream-liveness.ts new file mode 100644 index 000000000..9fbd435ed --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/stream-liveness.ts @@ -0,0 +1,31 @@ +// #1584 — the client-side SSE liveness watchdog's decision, extracted as a +// ZERO-DEPENDENCY pure fn so its test runs in-place (`bun test --conditions=solid`) +// outside the materialized tree. It MUST NOT import solid-js or @opencode-ai/* +// (that dependency is exactly what makes stream-gap.test.ts fail in-place). +// +// Mirrors the `applySseError` pattern (server-sdk.tsx): the part that reads as +// redundant — "why reconnect a stream that says it is connected?" — is the part +// a silently half-open socket makes essential, so it is covered by a test. +// +// The overlay app's v1 event iterator "neither throws nor completes" when the +// socket goes half-open (sleep/wake, Wi-Fi blip, engine replaced without +// FIN/RST): `onSseError` never fires, the `for await` parks forever, and +// `streamStatus` stays "connected" while the transcript freezes. This watchdog +// catches that: a stream that BELIEVES itself connected but has seen no frame +// for strictly longer than `staleMs` is force-reconnected (abort the parked +// attempt so the loop re-opens with the `lastEventID` cursor — lossless). + +/** Reconnect a stale-but-"connected" stream — the watchdog's decision. + * + * Returns `true` iff the stream believes itself `"connected"` AND no frame has + * arrived for strictly longer than `staleMs`. A `"disconnected"` stream is + * already being reconnected by the loop, so the watchdog leaves it alone; a + * stream exactly at the threshold is not yet stale (strict `>`). */ +export function shouldReconnectIdleStream(input: { + status: "connected" | "disconnected" + now: number + lastFrameAt: number + staleMs: number +}): boolean { + return input.status === "connected" && input.now - input.lastFrameAt > input.staleMs +} diff --git a/packages/app-bundle/overlay/packages/app/src/design-polish.css b/packages/app-bundle/overlay/packages/app/src/design-polish.css index 476bdcc95..ac0b4e015 100644 --- a/packages/app-bundle/overlay/packages/app/src/design-polish.css +++ b/packages/app-bundle/overlay/packages/app/src/design-polish.css @@ -691,6 +691,9 @@ span[data-component="tag"][data-variant="accent"] { } } +/* engine banner (#1608) — REMOVED. The floating narration pill was cut per + user preference; the engine toggle's own dot/lock is the whole story now. */ + /* ── Onboarding walkthrough spotlight (session-tour.tsx) ─────────────────── A ring traced on the chrome element the current "Tour · X" stop names. The ring is the whole marker — there is no label, because the stop's own text diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts index 053d29c0f..ecb0e5d0b 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts @@ -326,6 +326,9 @@ export const dict = { "prompt.toast.connectionDropped.title": "Connection dropped", "prompt.toast.connectionDropped.description": "The connection to the server dropped, so your message wasn't sent. It's still in the composer — try again once reconnected.", + "prompt.toast.engineOff.title": "Engine is off", + "prompt.toast.engineOff.description": + "The Amicode engine is switched off, so your message wasn't sent. It's still in the composer — toggle the engine on to resume.", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} of {{total}} enabled", diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home-archive.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/home-archive.test.ts new file mode 100644 index 000000000..2be4ede89 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/home-archive.test.ts @@ -0,0 +1,48 @@ +/** + * home-archive.test.ts + * + * The Home flyout's session-list archive action (home.archiveSession) is a + * LOCAL archive. Two things it must do: + * + * 1. PERF (#archive-lag cleanup): remove the archived row by SPLICING it out + * of its directory store, NOT by re-fetching + reconciling every project + * directory's session list (the old reload re-`remember`ed 100+ sessions on + * each archive — wasted work). roots() (pages/layout/helpers.ts) filters + * archived sessions out of the list, so the splice is the whole removal. + * 2. #1646: flip the viewed composer to its read-only banner in place via the + * INFO-ONLY `resolve(force)` (not `sync(force)`): the archived flag is + * metadata, and a forced `sync` also re-pulled the whole message page + + * parents (seconds of wasted wire on a warmed session). + * + * Source-assertion pattern (mirrors message-timeline-archive.test.ts): the + * wiring is asserted against the .tsx source; the per-session store BEHAVIOR is + * covered by server-session.test.ts (the runInflight force-bypass + the resolve + * force re-fetch). + */ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const source = readFileSync(resolve(__dirname, "home.tsx"), "utf8") + +// Isolate the archiveSession handler body (the next function is +// deleteArchivedSession) so the assertion is scoped to it. +function archiveSessionBody(src: string): string { + const start = src.indexOf("async function archiveSession(session: Session)") + expect(start).toBeGreaterThan(-1) + const next = src.indexOf("async function deleteArchivedSession", start) + expect(next).toBeGreaterThan(start) + return src.slice(start, next) +} + +describe("Home flyout archive — splice the row, flip the composer info-only (#archive-lag / #1646)", () => { + test("archiveSession splices the row from the store (no full list reload) + flips the composer info-only", () => { + const body = archiveSessionBody(source) + // the archived row is removed by a targeted splice, not a whole-list reload + expect(body).toContain("draft.session.splice(") + // …and the composer's archived banner flips via the INFO-ONLY resolve + expect(body).toContain("session.resolve(session.id, { force: true })") + // must NOT use the heavy sync(force), which re-pulls the full message history + expect(body).not.toContain("session.sync(session.id, { force: true })") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home.tsx b/packages/app-bundle/overlay/packages/app/src/pages/home.tsx index 71b62fa38..dc232b4a4 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/home.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/home.tsx @@ -16,7 +16,7 @@ import { untrack, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import { createStore, reconcile } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { useQuery } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" @@ -65,6 +65,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" +import { archivedSessionsWithRemote, isRemotePeerSession, deriveSessionBadge, readSessionControl, writeAffordanceEnabled, failClosedChip, remoteDeleteAction, type DropdownSession } from "@/components/session/session-fleet-peers" import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard" import { AMICODE_MANAGE_VAULTS_PROMPT } from "@opencode-ai/ui/amicode-vaults-tab" @@ -356,15 +357,20 @@ function HomeDesign() { const result = await ctx.sdk.client.experimental.session.list( { archived: true, limit: ARCHIVED_PAGE_SIZE, ...(cursor ? { cursor: Number(cursor) } : {}) }, ) - const sessions: Session[] = (result.data ?? []) as Session[] + const local: Session[] = (result.data ?? []) as Session[] if (reset) { - setArchivedSessions(sessions) + // #1647 (S3): fold in peer-owned archived sessions from the fleet + // projection (owner-tagged). Bounded recent window, merged only on the + // first page. Standalone / fetch failure → no remote rows → unchanged. + const proj = await amicodeGet(focusedServer(), "/amicode/fleet/sessions?archived=true").catch(() => undefined) + setArchivedSessions(archivedSessionsWithRemote(local as DropdownSession[], proj) as Session[]) } else { - setArchivedSessions((prev) => [...prev, ...sessions]) + const have = new Set(archivedSessions().map((s) => s.id)) + setArchivedSessions((prev) => [...prev, ...local.filter((s) => !have.has(s.id))]) } - // Cursor-based pagination: if we got a full page, there may be more - const lastSession = sessions[sessions.length - 1] - if (sessions.length >= ARCHIVED_PAGE_SIZE && lastSession) { + // Cursor-based pagination tracks the LOCAL engine only (remote is a window) + const lastSession = local[local.length - 1] + if (local.length >= ARCHIVED_PAGE_SIZE && lastSession) { setArchivedCursor(String(lastSession.time.updated ?? lastSession.time.created)) setArchivedHasMore(true) } else { @@ -378,9 +384,30 @@ function HomeDesign() { } } + // #1647 (S6): keep the Archived tab live while it is open. A local OR remote + // (fan-in-relayed) archive/unarchive should surface without a manual refresh. + // Remote archive events are owned by a peer and don't reliably flow through + // the local directory stores, so instead of fragile cross-store reactivity we + // refresh on the SAME 5s cadence the fleet projection / owner-map already use + // — bounded real-time (≤5s), robust, and cleaned up on tab-close / unmount. + createEffect(() => { + if (flyoutTab() !== "archived") return + const timer = setInterval(() => void loadArchivedSessions(true), 5000) + onCleanup(() => clearInterval(timer)) + }) + async function unarchiveSession(session: Session) { const ctx = focusedServerCtx() if (!ctx) return + // #1647 (S4): a remote archived row unarchives on its owner; gate on control. + const ds = session as DropdownSession + if (isRemotePeerSession(ds) && !remoteDeleteAction(ds).allowed) { + showToast({ + title: "Control not enabled", + description: `Enable control of ${ds.amicode_owner?.owner_name ?? "the owner machine"} to unarchive its sessions from here.`, + }) + return + } try { // Unarchive via the v1 session.update endpoint (same as archive, but nulling the timestamp) await (ctx.sdk.client.session.update as Function)({ @@ -415,12 +442,33 @@ function HomeDesign() { }) // Add to archived list setArchivedSessions((prev) => [session, ...prev]) - // Reload active sessions so it disappears from the open list - await Promise.all( - projectDirectories().map((directory) => - focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }), - ), + // PERF (#archive-lag cleanup): remove the archived row from its directory + // store DIRECTLY instead of re-fetching + reconciling EVERY project + // directory's session list on each archive (wasted work). roots() + // (pages/layout/helpers.ts) filters archived sessions out of the list, so + // this splice is the entire removal; the next natural loadSessions + // reconciles from the server. Matches the Rail (layout.tsx) and chat-menu + // (message-timeline.tsx) archive paths, which already splice. (The + // measured dropdown lag was a different cause — the cached fleet + // projection re-filling the row; see session-header.tsx's tombstones.) + const [, setChildStore] = focusedSync().child(session.directory, { bootstrap: false }) + setChildStore( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === session.id) + if (index !== -1) draft.session.splice(index, 1) + }), ) + // #1646: flip the composer of the viewed session to its read-only banner + // in place. The removal above touches the LIST store; the composer's + // `archived` memo reads the PER-SESSION store (focusedSync().session.get(id)), + // which only a forced per-session refresh lands. INFO-ONLY `resolve` + // (session.get → remember), NOT `sync`: the archived flag is metadata, so a + // forced `sync` additionally re-pulled the viewed session's full message + // history + parents (seconds of wasted wire). `resolve` lands the flag + // without touching messages, matching the composer controller's convergence. + void focusedSync() + .session.resolve(session.id, { force: true }) + .catch(() => {}) } catch (cause) { showToast({ title: language.t("common.requestFailed"), @@ -433,6 +481,15 @@ function HomeDesign() { async function deleteArchivedSession(session: Session) { const ctx = focusedServerCtx() if (!ctx) return + // #1647 (S4): remote delete routes to the owner; gate on control. + const ds = session as DropdownSession + if (isRemotePeerSession(ds) && !remoteDeleteAction(ds).allowed) { + showToast({ + title: "Control not enabled", + description: `Enable control of ${ds.amicode_owner?.owner_name ?? "the owner machine"} to delete its sessions from here.`, + }) + return + } try { await (ctx.sdk.client.session.delete as Function)({ sessionID: session.id, @@ -2180,6 +2237,14 @@ function ArchivedSessionRow(props: { onDelete: (session: Session) => void }) { const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) + // #1647 (S4): a remote archived row (owner-tagged from the fleet projection) + // shows the owner badge and control-gates unarchive/delete — fail-closed to a + // lock chip when control is not held. + const badge = createMemo(() => deriveSessionBadge(props.session as DropdownSession)) + const isRemote = createMemo(() => isRemotePeerSession(props.session as DropdownSession)) + const control = createMemo(() => readSessionControl(props.session as DropdownSession)) + const canWrite = createMemo(() => writeAffordanceEnabled(control())) + const chip = createMemo(() => failClosedChip(control())) const [armed, setArmed] = createSignal(false) let resetTimer: ReturnType | undefined @@ -2218,10 +2283,38 @@ function ArchivedSessionRow(props: { {title()} + + + + {badge()} + +
+ + {(c) => ( + + + {c.label} + + )} + + } + > +
) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout-archive.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/layout-archive.test.ts new file mode 100644 index 000000000..15bc84739 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout-archive.test.ts @@ -0,0 +1,50 @@ +/** + * layout-archive.test.ts + * + * The Rail session-list archive action (sidebar-items → layout.archiveSession) + * is a LOCAL archive. #1646: archiving the session you are VIEWING must flip it + * to the read-only banner IN PLACE (like the chat menu), NOT navigate away — the + * old bounce meant the read-only mode was never seen. In-place requires a forced + * per-session refresh (the composer's `archived` memo source) and no navigation + * out of the archived session. It uses the INFO-ONLY `resolve(force)` (not + * `sync(force)`): the archived flag is metadata, and a forced `sync` also + * re-pulled the whole message page + parents (seconds of wasted wire). + * + * Source-assertion pattern (mirrors message-timeline-archive.test.ts): the + * wiring is asserted against the .tsx source; the per-session store BEHAVIOR is + * covered by server-session.test.ts (the runInflight force-bypass + the resolve + * force re-fetch). + */ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const source = readFileSync(resolve(__dirname, "layout.tsx"), "utf8") + +// Isolate the archiveSession handler body so assertions are scoped to it and +// not satisfied (or falsified) by an unrelated occurrence elsewhere in the file +// — layout.tsx has many navigate() calls outside this handler. +function archiveSessionBody(src: string): string { + const start = src.indexOf("async function archiveSession(session: Session)") + expect(start).toBeGreaterThan(-1) + const next = src.indexOf('command.register("layout"', start) + expect(next).toBeGreaterThan(start) + return src.slice(start, next) +} + +describe("Rail list archive — flip in place, never navigate (#1646)", () => { + test("archiveSession refreshes the per-session store (info-only) — the composer source", () => { + const body = archiveSessionBody(source) + expect(body).toContain("session.resolve(session.id, { force: true })") + // must NOT use the heavy sync(force), which re-pulls the full message history + expect(body).not.toContain("session.sync(session.id, { force: true })") + }) + + test("archiveSession does NOT navigate away from the archived session", () => { + const body = archiveSessionBody(source) + // The old behavior bounced you to the next session, so the read-only mode + // was never seen. The trailing paren distinguishes the call from the prose + // comment ("navigating away"). + expect(body).not.toContain("navigate(") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx index 7fc2541a8..189738e19 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { TabsInfoPopup } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { SolverSwitchBanner } from "@/components/solver-switch-banner" +import { installEngineStateListener } from "@/components/engine-state-signal" import { VaultPanel } from "@/components/vault-panel" import { usePlatform } from "@/context/platform" import { setV2Toast, ToastRegion } from "@/utils/toast" @@ -16,6 +17,13 @@ export default function NewLayout(props: ParentProps) { useNotificationToasts() useWorktreeAutoRename() createEffect(() => setV2Toast(true)) + // #1608: this layout is always mounted, so it owns the ONE global + // engine-state listener install (the engine toggle + composer refusal read + // the resulting signal). Idempotent — a second install from the status + // popover is a no-op — but this call guarantees pushes are received even + // while the popover is closed. The narration banner was removed; the toggle's + // own dot/lock is the whole story now. + installEngineStateListener() const update: TitlebarUpdate = { version: () => { diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx index 3c0075164..ac06da2b7 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx @@ -721,10 +721,7 @@ export default function LegacyLayout(props: ParentProps) { async function archiveSession(session: Session) { if ((await serverSDK().protocol) !== "v1") return - const [store, setStore] = serverSync().child(session.directory) - const sessions = store.session ?? [] - const index = sessions.findIndex((s) => s.id === session.id) - const nextSession = sessions[index + 1] ?? sessions[index - 1] + const [, setStore] = serverSync().child(session.directory) await serverSDK().client.session.update({ sessionID: session.id, @@ -737,13 +734,18 @@ export default function LegacyLayout(props: ParentProps) { if (match.found) draft.session.splice(match.index, 1) }), ) - if (session.id === params.id) { - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - } else { - navigate(`/${params.dir}/session`) - } - } + // #1646: archiving the session you are VIEWING flips it to the read-only + // banner IN PLACE (matching the chat menu) instead of navigating away — the + // old bounce meant the read-only mode was never seen. The splice above + // touches only the LIST store; the composer's `archived` memo reads the + // PER-SESSION store (serverSync().session.get(id).time.archived), which only + // a forced per-session refresh lands. INFO-ONLY `resolve` (session.get → + // remember), NOT `sync`: the archived flag is metadata, so a forced `sync` + // additionally re-pulled the viewed session's full message history + parents + // (seconds of wasted wire). `resolve` lands the flag without touching messages. + void serverSync() + .session.resolve(session.id, { force: true }) + .catch(() => {}) } command.register("layout", () => { diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-mount.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-mount.test.ts new file mode 100644 index 000000000..c57b7063c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-mount.test.ts @@ -0,0 +1,276 @@ +/** + * new-session-machine-mount.test.ts — #1453 (W4b) + * + * Mounts the (previously-dead) new-session machine picker in the live + * new-session flow. This suite proves the DELIVERED behavior of the mount's + * data seam — NOT the injected literal the #1442 picker unit test already + * covers: + * + * - the machine list is built from the fleet-sessions projection + * (fleetSessionsFromResponse output), grouped by amicode_owner; + * - the focused machine arrives over the W3 (#1451) chat_bridge `fleet-focus` + * down-message, is latched by the overlay-side receiver, re-read on mount, + * and feeds the picker's default; + * - `machineId: undefined` on the envelope is home/unfocus, NOT a malformed + * message. + * + * Message-driven behavior is tested with an injected fake window (the same + * pattern as clipboard-bridge.test.ts) — bun's test env has no DOM window. + */ +import { describe, expect, test } from "bun:test" +import { createMachinePickerState, type MachineOption } from "../../components/new-session-machine-picker" +import type { FleetSessionEntry } from "../session/timeline/session-header-provenance" +import { + createFleetFocusReceiver, + machineOptionsFromFleetSessions, + parseFleetFocusMessage, + dispatchSseFocusEvent, + type WindowLike, +} from "./new-session-machine-mount" + +type Listener = (event: MessageEvent) => void + +/** A stand-in for the framed window that records message listeners and lets a + * test drive a host `fleet-focus` down-message (the chat_bridge push W3 emits). */ +function fakeWindow() { + const listeners = new Set() + const win: WindowLike & { postMessage(data: unknown, origin: string): void } = { + addEventListener: (_type: string, fn: Listener) => listeners.add(fn), + removeEventListener: (_type: string, fn: Listener) => listeners.delete(fn), + // #1522: dispatchSseFocusEvent uses postMessage to re-emit focus data. + // In the real browser postMessage triggers message event listeners; this + // fake does the same synchronously for test determinism. + postMessage: (data: unknown, _origin: string) => + listeners.forEach((fn) => fn({ data } as MessageEvent)), + } + return { + win, + /** Drive a host down-message the way ChatPanel.postMessage → the webview does. */ + drive: (data: unknown) => listeners.forEach((fn) => fn({ data } as MessageEvent)), + listenerCount: () => listeners.size, + } +} + +const machines: MachineOption[] = [ + { machineId: "A", name: "MacBook Pro", isLocal: true }, + { machineId: "B", name: "Mac Studio", isLocal: false }, + { machineId: "C", name: "Build Server", isLocal: false }, +] + +describe("new-session machine picker mount — focus delivered over the bridge (#1453 AC1)", () => { + test("driving the chat_bridge {kind:'fleet-focus', machineId:'B'} defaults the picker to B", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + + // W3 (#1451) posts this exact envelope over chat_bridge on focus change. + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "B" }) + + // The mount re-reads current focus on mount and feeds it as the default. + const state = createMachinePickerState({ machines, focusedMachineId: receiver.current() }) + expect(state.selectedMachineId).toBe("B") + + receiver.dispose() + }) + + test("a focus push that arrived BEFORE the picker is built is re-read on mount (best-effort latch)", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + + // The push fires first — no picker exists yet (best-effort delivery, not queued). + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "C" }) + + // The picker mounts LATER and re-reads current() rather than waiting for a fresh push. + const state = createMachinePickerState({ machines, focusedMachineId: receiver.current() }) + expect(state.selectedMachineId).toBe("C") + + receiver.dispose() + }) + + test("a home/unfocus push (machineId undefined) defaults the picker to the local machine", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: undefined }) + + const state = createMachinePickerState({ machines, focusedMachineId: receiver.current() }) + expect(state.selectedMachineId).toBe("A") // local default, not a bogus focus + expect(receiver.current()).toBeUndefined() + + receiver.dispose() + }) +}) + +describe("parseFleetFocusMessage — the fleet-focus envelope (#1451 seam)", () => { + test("a machineId string is a focus on that machine", () => { + expect(parseFleetFocusMessage({ source: "amicode", kind: "fleet-focus", machineId: "B" })).toEqual({ + machineId: "B", + }) + }) + + test("machineId undefined is home/unfocus (a valid message, NOT malformed)", () => { + expect(parseFleetFocusMessage({ source: "amicode", kind: "fleet-focus", machineId: undefined })).toEqual({ + machineId: undefined, + }) + }) + + test("a missing machineId key is also home/unfocus", () => { + expect(parseFleetFocusMessage({ source: "amicode", kind: "fleet-focus" })).toEqual({ machineId: undefined }) + }) + + test("a non-amicode source is not a fleet-focus message", () => { + expect(parseFleetFocusMessage({ source: "vscode", kind: "fleet-focus", machineId: "B" })).toBeUndefined() + }) + + test("a different amicode kind is not a fleet-focus message", () => { + expect(parseFleetFocusMessage({ source: "amicode", kind: "preview-file", machineId: "B" })).toBeUndefined() + }) + + test("a non-string, non-undefined machineId is malformed", () => { + expect(parseFleetFocusMessage({ source: "amicode", kind: "fleet-focus", machineId: 42 })).toBeUndefined() + }) + + test("non-object input is rejected", () => { + expect(parseFleetFocusMessage(null)).toBeUndefined() + expect(parseFleetFocusMessage("fleet-focus")).toBeUndefined() + expect(parseFleetFocusMessage(undefined)).toBeUndefined() + }) +}) + +describe("machineOptionsFromFleetSessions — build the list from the projection", () => { + test("groups sessions by amicode_owner into one option per machine", () => { + const entries: FleetSessionEntry[] = [ + { id: "s1", amicode_owner: { owner_machine_id: "A", owner_name: "MacBook Pro", is_local: true } }, + { id: "s2", amicode_owner: { owner_machine_id: "B", owner_name: "Mac Studio", is_local: false } }, + { id: "s3", amicode_owner: { owner_machine_id: "B", owner_name: "Mac Studio", is_local: false } }, + ] + expect(machineOptionsFromFleetSessions(entries)).toEqual([ + { machineId: "A", name: "MacBook Pro", isLocal: true }, + { machineId: "B", name: "Mac Studio", isLocal: false }, + ]) + }) + + test("preserves is_local and dedupes on first occurrence", () => { + const entries: FleetSessionEntry[] = [ + { id: "s1", amicode_owner: { owner_machine_id: "B", owner_name: "Mac Studio", is_local: false } }, + { id: "s2", amicode_owner: { owner_machine_id: "A", owner_name: "MacBook Pro", is_local: true } }, + ] + const opts = machineOptionsFromFleetSessions(entries) + expect(opts.map((o) => o.machineId)).toEqual(["B", "A"]) + expect(opts.find((o) => o.machineId === "A")?.isLocal).toBe(true) + }) + + test("skips owner-less (pre-fleet / local-path) sessions — no bogus machine", () => { + const entries: FleetSessionEntry[] = [ + { id: "legacy" }, + { id: "s1", amicode_owner: { owner_machine_id: "B", owner_name: "Mac Studio", is_local: false } }, + ] + expect(machineOptionsFromFleetSessions(entries)).toEqual([ + { machineId: "B", name: "Mac Studio", isLocal: false }, + ]) + }) + + test("an empty projection yields no machines (honest fleet-of-one degrade)", () => { + expect(machineOptionsFromFleetSessions([])).toEqual([]) + }) +}) + +describe("createFleetFocusReceiver — latch + subscribe (best-effort, re-read on mount)", () => { + test("current() latches the LATEST focus push", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + expect(receiver.current()).toBeUndefined() + + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "B" }) + expect(receiver.current()).toBe("B") + + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "C" }) + expect(receiver.current()).toBe("C") + + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: undefined }) + expect(receiver.current()).toBeUndefined() + + receiver.dispose() + }) + + test("subscribe fires on each effective focus push and unsubscribe stops it", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + const seen: (string | undefined)[] = [] + const off = receiver.subscribe((m) => seen.push(m)) + + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "B" }) + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: undefined }) + off() + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "C" }) + + expect(seen).toEqual(["B", undefined]) + receiver.dispose() + }) + + test("ignores non-fleet-focus messages (does not clobber the latch)", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "B" }) + bridge.drive({ source: "amicode", kind: "preview-file", path: "/x" }) + bridge.drive({ source: "vscode", kind: "fleet-focus", machineId: "C" }) + expect(receiver.current()).toBe("B") + receiver.dispose() + }) + + test("dispose removes the window listener", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + expect(bridge.listenerCount()).toBe(1) + receiver.dispose() + expect(bridge.listenerCount()).toBe(0) + }) +}) + +// ── #1522 AC5 — SSE-sourced focus event feeds the existing latch ──────────── +describe("#1522 AC5 — SSE focus frame seeds the picker through the existing latch", () => { + test("dispatchSseFocusEvent re-emits focused-peer data as a chat_bridge envelope the latch catches", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + + // Simulate the SSE amicode.fleet.focus event data (the JSON payload the + // aggregator writes into the `data:` line of the SSE frame). + dispatchSseFocusEvent( + { type: "amicode.fleet.focus", focusedMachineId: "studio", isHome: false, absent: false }, + bridge.win as unknown as Window, + ) + + expect(receiver.current()).toBe("studio") + receiver.dispose() + }) + + test("dispatchSseFocusEvent with home focus (no machineId) → latch returns undefined (home)", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + + dispatchSseFocusEvent( + { type: "amicode.fleet.focus", isHome: true, absent: false }, + bridge.win as unknown as Window, + ) + + expect(receiver.current()).toBeUndefined() + receiver.dispose() + }) + + test("dispatchSseFocusEvent with non-focus event is a no-op (latch not clobbered)", () => { + const bridge = fakeWindow() + const receiver = createFleetFocusReceiver(bridge.win) + + // Pre-seed with a real focus + bridge.drive({ source: "amicode", kind: "fleet-focus", machineId: "studio" }) + expect(receiver.current()).toBe("studio") + + // A non-focus SSE event should be ignored + dispatchSseFocusEvent( + { type: "session.updated", id: "s1" } as any, + bridge.win as unknown as Window, + ) + + expect(receiver.current()).toBe("studio") // unchanged + receiver.dispose() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-mount.ts b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-mount.ts new file mode 100644 index 000000000..6aa7e9598 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-mount.ts @@ -0,0 +1,163 @@ +/** + * new-session-machine-mount.ts — #1453 (W4b) + * + * The data/seam layer that MOUNTS the new-session machine picker + * (`new-session-machine-picker.ts`, #1442) into the live new-session flow. + * Two responsibilities, both pure and unit-testable: + * + * 1. Build the picker's machine list from the fleet-sessions projection + * (`fleetSessionsFromResponse` output, W2 #1447 / W4a #1452), grouped by + * the `amicode_owner` overlay — one option per owning machine. + * + * 2. Consume the W3 (#1451) host→overlay focus push: the host posts ONE + * `{source:"amicode", kind:"fleet-focus", machineId}` down-message over the + * chat_bridge (ChatPanel.postMessage) on every effective focus change. The + * receiver here LATCHES the latest push so the picker can RE-READ current + * focus on mount (`current()`) rather than assume it caught the last push — + * the push is best-effort, NOT queued/replayed (a push that fired before + * the overlay had any listener is genuinely lost; the picker then defaults + * to home/local, the safe default). W3 exposes no host read-back endpoint, + * so the latch IS the read-back: its `message` listener installs when the + * receiver is created (the singleton installs at the new-session view's + * first render) and captures every subsequent push. + * + * The focus value drives the picker DEFAULT only — it is DISTINCT from the + * sidebar focus selector (a different affordance, W3-owned). Selecting a + * machine here chooses where a NEW session runs; it never re-focuses the + * sidebar. + */ +import type { MachineOption } from "../../components/new-session-machine-picker" +import type { FleetSessionEntry } from "../session/timeline/session-header-provenance" + +// ── machine list from the projection ───────────────────────────────────────── + +/** Build the picker's machine list from the fleet-sessions projection: one + * option per owning machine, grouped by `amicode_owner.owner_machine_id` + * (first occurrence wins). Owner-less (pre-fleet / local-path) sessions are + * skipped — no bogus machine. An empty projection yields no machines (the + * honest fleet-of-one degrade). */ +export function machineOptionsFromFleetSessions(entries: readonly FleetSessionEntry[]): MachineOption[] { + const seen = new Set() + const options: MachineOption[] = [] + for (const entry of entries) { + const owner = entry.amicode_owner + if (!owner) continue + if (seen.has(owner.owner_machine_id)) continue + seen.add(owner.owner_machine_id) + options.push({ machineId: owner.owner_machine_id, name: owner.owner_name, isLocal: owner.is_local }) + } + return options +} + +// ── the fleet-focus down-message (#1451 seam) ──────────────────────────────── + +/** The parsed focus: the focused machine id, or undefined for home (local). */ +export interface FleetFocus { + machineId: string | undefined +} + +/** Parse the W3 (#1451) `fleet-focus` down-message envelope. Matches + * `source==="amicode"` AND `kind==="fleet-focus"` EXACTLY. `machineId` may be + * a string (focus that machine) or undefined/absent (home / unfocus — a VALID + * message, not malformed). Any other `machineId` type, or a non-matching + * source/kind, is not a fleet-focus message → undefined. */ +export function parseFleetFocusMessage(raw: unknown): FleetFocus | undefined { + if (!raw || typeof raw !== "object") return undefined + const d = raw as { source?: unknown; kind?: unknown; machineId?: unknown } + if (d.source !== "amicode" || d.kind !== "fleet-focus") return undefined + if (d.machineId === undefined) return { machineId: undefined } + if (typeof d.machineId === "string") return { machineId: d.machineId } + return undefined +} + +// ── the overlay-side focus receiver (latch + re-read on mount) ─────────────── + +/** The minimal window surface the receiver needs — injected so the latch is + * unit-testable with a fake window (bun's test env has no DOM window). */ +export interface WindowLike { + addEventListener(type: "message", listener: (event: MessageEvent) => void): void + removeEventListener(type: "message", listener: (event: MessageEvent) => void): void +} + +/** The overlay-side receiver for the W3 focus push. Latches the latest focus + * so the picker can re-read it on mount, and notifies subscribers on each + * effective push. */ +export interface FleetFocusReceiver { + /** The latest focused machine id (undefined = home/local, or no push yet). + * This is the RE-READ-ON-MOUNT value the picker seeds its default from. */ + current(): string | undefined + /** Subscribe to focus changes; returns an unsubscribe. */ + subscribe(callback: (machineId: string | undefined) => void): () => void + /** Remove the window listener and drop subscribers. */ + dispose(): void +} + +/** Create a focus receiver bound to `win`. Installs a `message` listener that + * latches every `fleet-focus` push; non-fleet-focus messages are ignored and + * never clobber the latch. */ +export function createFleetFocusReceiver(win: WindowLike): FleetFocusReceiver { + let latest: string | undefined + const subscribers = new Set<(machineId: string | undefined) => void>() + + const onMessage = (event: MessageEvent) => { + const focus = parseFleetFocusMessage((event as { data?: unknown }).data) + if (!focus) return + latest = focus.machineId + for (const callback of subscribers) callback(latest) + } + + win.addEventListener("message", onMessage) + + return { + current: () => latest, + subscribe(callback) { + subscribers.add(callback) + return () => subscribers.delete(callback) + }, + dispose() { + win.removeEventListener("message", onMessage) + subscribers.clear() + }, + } +} + +// ── the process-wide singleton (installed at first render) ─────────────────── + +let singleton: FleetFocusReceiver | undefined + +/** The process-wide focus receiver, bound to the real `window`. Lazily created + * on first call (the new-session view's render), so its listener is installed + * as early as the picker can be shown — maximizing the recoverable window for + * the best-effort push. Returns undefined outside a DOM context (SSR / tests), + * where callers fall back to home/local. */ +export function getFleetFocusReceiver(): FleetFocusReceiver | undefined { + if (typeof window === "undefined") return undefined + if (!singleton) singleton = createFleetFocusReceiver(window) + return singleton +} + +// ── #1522 (ADR 0033 decision A): SSE-sourced focus event dispatch ──────────── +// +// The SSE fan-in aggregator emits `amicode.fleet.focus` as the FIRST frame on +// every (re)connect. This function re-emits the SSE data as a window.postMessage +// with the SAME `{source:"amicode", kind:"fleet-focus", machineId}` envelope the +// chat_bridge uses — so the EXISTING latch catches it with zero changes. +// This is the SECOND input to the same seed path (the chat_bridge push is the +// first), not a new focus store. + +/** Dispatch an SSE-sourced `amicode.fleet.focus` event to the existing focus + * latch via `window.postMessage`. If the event is NOT an `amicode.fleet.focus` + * type, this is a no-op (never clobbers the latch with non-focus data). */ +export function dispatchSseFocusEvent( + data: { type?: string; focusedMachineId?: string; isHome?: boolean; [key: string]: unknown }, + win: Window, +): void { + if (data.type !== "amicode.fleet.focus") return + // Re-emit with the same envelope parseFleetFocusMessage expects: + // { source: "amicode", kind: "fleet-focus", machineId } + // `focusedMachineId` in the SSE data maps to `machineId` on the envelope. + win.postMessage( + { source: "amicode", kind: "fleet-focus", machineId: data.focusedMachineId }, + "*", + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-picker-mount.tsx b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-picker-mount.tsx new file mode 100644 index 000000000..8f4805d7d --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-picker-mount.tsx @@ -0,0 +1,96 @@ +/** + * new-session-machine-picker-mount.tsx — #1453 (W4b) + * + * The LIVE mount of the new-session machine picker (`new-session-machine-picker.ts`, + * #1442) — this component is the non-test importer of `createMachinePickerState` + * that retires that module's dead-module status. + * + * It wires the picker's two data sources: + * - the machine list: the fleet-sessions projection (GET /amicode/fleet/sessions, + * W2 #1447 / W4a #1452) grouped by owner (machineOptionsFromFleetSessions); + * - the DEFAULT selection: the focused machine delivered over the W3 (#1451) + * chat_bridge `fleet-focus` down-message. The overlay-side receiver latches + * the latest push; this component RE-READS it on mount (current()) and + * subscribes for live updates — the push is best-effort, not replayed. + * + * The picker chooses where a NEW session runs (createPayload().machine_id). That + * is DISTINCT from the sidebar focus selector (W3-owned): focus only seeds the + * default here; selecting a machine never re-focuses the sidebar. On a fleet + * with no owner-tagged sessions the projection is empty and the picker renders + * nothing (honest fleet-of-one degrade). + */ +import { For, Show, createEffect, createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js" +import { useServer } from "@/context/server" +import { amicodeGet } from "@/utils/amicode-fetch" +import { fleetSessionsFromResponse } from "@/pages/session/timeline/session-header-provenance" +import { createMachinePickerState } from "@/components/new-session-machine-picker" +import { getFleetFocusReceiver, machineOptionsFromFleetSessions } from "./new-session-machine-mount" +import { publishMachineSelection } from "./new-session-machine-selection" + +export function NewSessionMachinePicker() { + const server = useServer() + + // The fleet-sessions projection → the machine list. Keyed on server.current + // so a server switch re-fetches; a failed/absent projection degrades to []. + const [fleetSessionsRaw] = createResource( + () => server.current, + () => amicodeGet(server.current, "/amicode/fleet/sessions").catch(() => undefined), + ) + const machines = createMemo(() => machineOptionsFromFleetSessions(fleetSessionsFromResponse(fleetSessionsRaw.latest))) + + // Re-read current focus on mount (the latch is the read-back — W3 exposes no + // host endpoint), then track live pushes. undefined = home/local. + const receiver = getFleetFocusReceiver() + const [focused, setFocused] = createSignal(receiver?.current()) + onMount(() => { + setFocused(() => receiver?.current()) + const off = receiver?.subscribe((machineId) => setFocused(() => machineId)) + onCleanup(() => off?.()) + }) + + // An explicit user pick overrides the focus default; both flow through the + // #1442 picker state, whose createPayload carries the chosen machine_id. + const [override, setOverride] = createSignal(undefined) + const picker = createMemo(() => + createMachinePickerState({ machines: machines(), focusedMachineId: override() ?? focused() }), + ) + + // #1643 (completes #1484 AC3): publish the picker's effective selection to the + // submit path. A REMOTE selection is what the create's pre-flight gate reads + // to arm the x-amicode-owner header; a local one publishes undefined (local, + // header-less create). Without this the selection was purely cosmetic — the + // AC3-shipped-incomplete finding. + createEffect(() => { + const sel = picker().selectedMachineId + const chosen = picker().machines.find((m) => m.machineId === sel) + // A local pick (or the default local) publishes undefined → local create. + publishMachineSelection(chosen && !chosen.isLocal ? sel : undefined) + }) + onCleanup(() => publishMachineSelection(undefined)) + + return ( + 0}> + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-picker.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-picker.test.ts new file mode 100644 index 000000000..5a433a341 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-picker.test.ts @@ -0,0 +1,82 @@ +/** + * new-session-machine-picker.test.ts — #1442 AC4 + * + * Tests the pure-logic module for the new-session machine picker cascade: + * Machine ▸ Project ▸ Worktree — Machine first, default = focused machine + * (from fleet_focus.ts). The create call carries machine_id. + */ +import { describe, expect, test } from "bun:test" +import { + type MachinePickerState, + createMachinePickerState, + type MachineOption, +} from "../../components/new-session-machine-picker" + +describe("new-session machine picker (#1442 AC4)", () => { + const machines: MachineOption[] = [ + { machineId: "local-001", name: "MacBook Pro", isLocal: true }, + { machineId: "studio-001", name: "Mac Studio", isLocal: false }, + { machineId: "server-001", name: "Build Server", isLocal: false }, + ] + + test("machine selector is first in the cascade (before project/worktree)", () => { + const state = createMachinePickerState({ + machines, + focusedMachineId: undefined, + }) + // The cascade order is: machine → project → worktree + expect(state.cascadeOrder).toEqual(["machine", "project", "worktree"]) + }) + + test("default selected machine is the focused machine from fleet_focus", () => { + const state = createMachinePickerState({ + machines, + focusedMachineId: "studio-001", + }) + expect(state.selectedMachineId).toBe("studio-001") + }) + + test("when no machine is focused, default is the local machine", () => { + const state = createMachinePickerState({ + machines, + focusedMachineId: undefined, + }) + expect(state.selectedMachineId).toBe("local-001") + }) + + test("selecting a machine updates the state", () => { + const state = createMachinePickerState({ + machines, + focusedMachineId: undefined, + }) + state.selectMachine("server-001") + expect(state.selectedMachineId).toBe("server-001") + }) + + test("the create payload carries the selected machine_id", () => { + const state = createMachinePickerState({ + machines, + focusedMachineId: "studio-001", + }) + const payload = state.createPayload() + expect(payload.machine_id).toBe("studio-001") + }) + + test("after selecting a different machine, the create payload reflects the change", () => { + const state = createMachinePickerState({ + machines, + focusedMachineId: "studio-001", + }) + state.selectMachine("server-001") + const payload = state.createPayload() + expect(payload.machine_id).toBe("server-001") + }) + + test("machines list is available for rendering the selector", () => { + const state = createMachinePickerState({ + machines, + focusedMachineId: undefined, + }) + expect(state.machines).toEqual(machines) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-selection.ts b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-selection.ts new file mode 100644 index 000000000..6c1575b32 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-machine-selection.ts @@ -0,0 +1,41 @@ +/** + * new-session-machine-selection.ts — #1643 (completes #1484 AC3) + * + * The bridge that carries the composer machine picker's CURRENT selection to + * the submit path. The picker (new-session-machine-picker-mount.tsx) renders + * its selection in local component state; submit.ts lives in a different tree + * and had NO way to read it (the AC3-shipped-incomplete gap). This module is + * that shared, module-level latch: the picker publishes on every change; submit + * reads the latest at create time. + * + * Deliberately tiny and framework-free (mirrors the fleet-focus receiver + * pattern): a single latched value, a getter, and a subscribe for the picker's + * onCleanup. undefined = no explicit selection (→ local create). + */ + +let _selected: string | undefined +const _subscribers = new Set<(machineId: string | undefined) => void>() + +/** Publish the picker's current selection (called by the picker on change). */ +export function publishMachineSelection(machineId: string | undefined): void { + const next = machineId && machineId.trim() !== "" ? machineId : undefined + _selected = next + for (const fn of _subscribers) fn(next) +} + +/** Read the latest published selection (called by submit at create time). */ +export function currentMachineSelection(): string | undefined { + return _selected +} + +/** Subscribe to selection changes; returns an unsubscribe. */ +export function subscribeMachineSelection(fn: (machineId: string | undefined) => void): () => void { + _subscribers.add(fn) + return () => _subscribers.delete(fn) +} + +/** Test/reset seam. */ +export function resetMachineSelection(): void { + _selected = undefined + _subscribers.clear() +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts index 72bbdd23d..82b1d18cd 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts @@ -16,3 +16,29 @@ describe("project selector in new-session view (#663, #673)", () => { expect(source).not.toMatch(/when=\{props\.project\.selected\(\)\}/) }) }) + +// ── #1453 (W4b): the machine picker is MOUNTED — live-importer chain ────────── +// AC2 is a structural guard: createMachinePickerState (#1442) must have a live +// (non-test) importer to retire its dead-module status. The chain is: +// new-session-view.tsx ──imports──▶ new-session-machine-picker-mount.tsx +// new-session-machine-picker-mount.tsx ──imports──▶ createMachinePickerState +// new-session-view.tsx itself is imported by the live new-session page, so the +// mount (and thus the picker) is in the live module graph, not an orphan. +describe("machine picker is mounted in the new-session view (#1453 AC2)", () => { + const mountSource = readFileSync(join(import.meta.dir, "new-session-machine-picker-mount.tsx"), "utf8") + + test("new-session-view imports and renders the machine picker mount", () => { + expect(source).toMatch(/from "\.\/new-session-machine-picker-mount"/) + expect(source).toMatch(//) + }) + + test("the mount is the live importer of createMachinePickerState (kills dead-module status)", () => { + expect(mountSource).toMatch(/createMachinePickerState/) + expect(mountSource).toMatch(/from "@\/components\/new-session-machine-picker"/) + }) + + test("the mount builds its list from the fleet-sessions projection, not a hand-rolled roster", () => { + expect(mountSource).toMatch(/machineOptionsFromFleetSessions/) + expect(mountSource).toMatch(/fleetSessionsFromResponse/) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx index b0f99e07f..0eba52a24 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx @@ -24,6 +24,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { SessionBugDock } from "@/pages/session/composer/session-bug-dock" import { bugReportEnabled } from "@/utils/amicode-bug-report" import { Persist, persisted } from "@/utils/persist" +import { NewSessionMachinePicker } from "./new-session-machine-picker-mount" import type { NewSessionDraftController } from "./new-session-draft-controller" import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" @@ -97,6 +98,13 @@ export function NewSessionView(props: {
+ {/* #1453 (W4b): the new-session machine picker — chooses which + machine a NEW session runs on. Its default follows the focused + machine delivered over the W3 (#1451) chat_bridge fleet-focus + down-message; self-hides on a fleet with no remote sessions. */} +
+ +
diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx index f4b2c138c..2b21d25b4 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx @@ -37,7 +37,7 @@ import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { NewSessionView, SessionHeader } from "@/components/session" +import { NewSessionView, SessionHeader, SessionComposerControlScrim } from "@/components/session" import { ContextWarningBanner } from "@/components/session/context-warning-banner" import { ErrorPage } from "@/pages/error" import { CommentsProvider, useComments } from "@/context/comments" @@ -759,7 +759,15 @@ export default function Page() { const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) - const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID }) + const timeline = createTimelineModel({ + sessionID: () => params.id, + revertMessageID, + // #1646 (transcript self-heal) — the same global SSE liveness signals + // message-timeline.tsx feeds the entity rail, so the transcript heals on a + // reconnect edge / fan-in-wedge resync while staying on this session. + streamConnected: () => serverSDK().event.status() === "connected", + forceResync: () => serverSDK().event.resyncCount(), + }) const historyLoading = timeline.history.loading const historyMore = timeline.history.more const lastUserMessage = timeline.lastUserMessage @@ -1315,7 +1323,7 @@ export default function Page() { let scroller: HTMLDivElement | undefined let content: HTMLDivElement | undefined let revealMessage = (_id: string) => {} - let scrollToEnd = () => {} + let scrollToEnd = (_opts?: { smooth?: boolean }) => {} let scrollMark = 0 let messageMark = 0 @@ -1905,7 +1913,11 @@ export default function Page() { (id, previous) => { if (!id || !previous || id === previous) return if (location.hash || store.messageId || ui.pendingMessage) return - autoScroll.resume() + // amicode: clear the flag only — resume() would scrollToBottom on + // the OLD session's element (still mounted) and markAuto its position, + // leaving a stale 1500ms mark that could swallow a real user scroll + // on the NEW session if the positions happen to be within 2px. + autoScroll.clearUserScrolled() }, ), ) @@ -1944,7 +1956,11 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.resume() + // amicode: decouple "re-engage auto-follow" from "scroll now" — clear the + // flag without triggering createAutoScroll's own instant scrollToBottom, + // then let the virtualizer's scrollToEnd() handle positioning cleanly + // (no dual-scroll fight, no animation chasing unmeasured items). + autoScroll.clearUserScrolled() scrollToEnd() clearMessageHash() @@ -2388,7 +2404,7 @@ export default function Page() { dockHeight = next - if (stick) scrollToEnd() + if (stick) scrollToEnd({ smooth: true }) if (el) scheduleScrollState(el) fill() @@ -2411,7 +2427,7 @@ export default function Page() { autoScroll: { pause: autoScroll.pause, forceScrollToBottom: () => { - autoScroll.resume() + autoScroll.clearUserScrolled() scrollToEnd() }, }, @@ -2641,7 +2657,10 @@ export default function Page() { if ((await sdk().protocol) !== "v1") return try { await (sdk().client.session.update as Function)({ sessionID: id, directory: sdk().directory, time: { archived: null } }) - void sync().session.sync(id, { force: true }) + // #1646: INFO-ONLY resolve to flip the archived banner off — the + // messages are already loaded (you were viewing the archived + // session), so a forced sync's message re-fetch is pure waste. + void sync().session.resolve(id, { force: true }) } catch (err: unknown) { showToast({ title: language.t("common.requestFailed"), @@ -2660,6 +2679,7 @@ export default function Page() { }} + } /> ) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-composer-region-controller.ts index 18af3ff62..74a34a646 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -110,8 +110,43 @@ export function createSessionComposerRegionController(input: { }) const archived = createMemo(() => { const id = input.sessionID() - return id ? !!sync().session.get(id)?.time?.archived : false + if (!id) return false + // #1646: the archive arrives as a session.updated event that ADDS a + // `time.archived` key that was ABSENT at first render. A memo that only + // touched the (then-undefined) leaf never subscribed to it in the Solid + // store, so it did not recompute until an unrelated dependency changed + // ("only flips when I click around"). Touch a path that ALWAYS exists and + // changes on every remember() — `time.updated`, which the archive bumps — + // to guarantee a subscription that fires when the event lands. Proven by a + // reactivity regression test in server-session.test.ts (#1646). + const info = sync().session.get(id) + void info?.time?.updated + return !!info?.time?.archived }) + + // #1647 follow-up: flip to the archived read-only banner promptly even when + // THIS machine did not perform the archive — the OWNER of a session that a + // peer archived (or any archive done elsewhere, e.g. the CLI). That transition + // arrives as a `session.updated` SSE event, but on the non-acting machine that + // delivery is jittery: the #1646 force-sync only covers the ACTING machine and + // reconnect/resync edges, so the owner's flip "took some time" (it waited for + // the next edge or the laggy event). While the viewed session is NOT yet + // archived, do a bounded INFO-ONLY forced resolve (client.session.get → + // remember; it does NOT re-fetch messages and is #1646-safe against a stale + // in-flight) so the archived flag converges within the interval without + // disrupting an active stream. Self-terminating: once `archived()` flips true + // the effect re-runs, hits the guard, and stops (no more polling). + createEffect(() => { + const id = input.sessionID() + if (!id) return + if (archived()) return + const timer = setInterval(() => { + void sync().session.resolve(id, { force: true }).catch(() => {}) + }, 5000) + onCleanup(() => clearInterval(timer)) + }) + + const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing()) const progress = useSpring( () => (open() ? 1 : 0), diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.test.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.test.tsx new file mode 100644 index 000000000..a7e0af9e3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +// #1544 (slice 4): the Fleet Manager grant-management panel wiring. The pure +// decision logic (shapeGrantRows / grantAffordances / shapePendingRequests) is +// unit-tested in fleet-manager.test.ts; here we assert the tab BINDS it — reads +// the sanitized grants off GET /amicode/fleet/grants (never a token), renders +// per-state affordances, and carries the #1545 pending-requests stub. +const source = readFileSync(resolve(__dirname, "fleet-manager-tab.tsx"), "utf8") + +describe("#1544 fleet-manager-tab grant panel wiring", () => { + test("adds a Grants section to the tab", () => { + expect(source).toContain('') + expect(source).toContain('section() === "grants"') + }) + + test("reads the SANITIZED grants off the never-proxied GRANTS_ROUTE", () => { + expect(source).toContain("amicodeGet(server.current, GRANTS_ROUTE)") + expect(source).toContain("shapeGrantRows(") + // never renders a token field + expect(source).not.toContain(".token") + }) + + test("renders per-state grant affordances (enable/disable/revoke)", () => { + expect(source).toContain("data-grant-action={affordance}") + expect(source).toContain("g.affordances") + expect(source).toContain("data-grant-state={g.state}") + }) + + test("carries the #1545 pending-requests stub (honest empty)", () => { + expect(source).toContain("shapePendingRequests(") + expect(source).toContain("PENDING_REQUESTS_BACKEND_ISSUE") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.tsx new file mode 100644 index 000000000..83eaa5390 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.tsx @@ -0,0 +1,508 @@ +// FleetManagerContent — the Fleet Manager Work Column tab (#1322, ADR 0026). +// The tab CONSOLIDATES fleet management: Devices (roster + capability chips + +// the local row's inline capabilities edit), This machine (Server mode + +// window-mode + transport selector + Go Standalone / Repair), Hub service +// (server only), Versions (absorbing the retired Fleet & Versions panel), and +// the Enroll action. It INVOKES the existing registered commands — it never +// reimplements their logic (Key Decision) — and follows the design system: +// tokens only, selection-by-border, no raw color/radius literals. +// +// All decision logic lives in the pure, unit-tested ./fleet-manager module; +// this file is the (untested-by-design) SolidJS wiring around it. +import { For, Show, createMemo, createResource, createSignal, onCleanup } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" +import { postAmicode } from "@/utils/amicode-bridge" +import { + shapeFleetDeviceRows, + buildCapabilitiesReport, + fleetManagerCommand, + transportPrefill, + TRANSPORT_OPTIONS, + enrollAction, + CREATE_FLEET_COMMAND, + fleetTransportMessage, + shapeVersionRows, + attachControlFor, + performAttachControlWithEffectiveStream, + shapeGrantRows, + shapePendingRequests, + GRANTS_ROUTE, + PENDING_REQUESTS_BACKEND_ISSUE, + type RosterRowLike, + type DoctorSurfaceLike, + type SanitizedGrantLike, +} from "@/pages/session/fleet-manager" + +type FleetManagerSection = "devices" | "machine" | "hub" | "versions" | "grants" + +/** Tolerant roster-response reader → the lawful rows array, else []. Mirrors the + * host's own tolerant load: a malformed / error response never throws here. */ +function rowsFromRosterResponse(raw: unknown): RosterRowLike[] { + if (!raw || typeof raw !== "object") return [] + const rows = (raw as { rows?: unknown }).rows + if (!Array.isArray(rows)) return [] + return rows as RosterRowLike[] +} + +function rosterReachable(raw: unknown): boolean { + return !!raw && typeof raw === "object" && (raw as { ok?: unknown }).ok === true +} + +export function FleetManagerContent() { + const server = useServer() + const serverSDK = useServerSDK() + const [section, setSection] = createSignal("devices") + + // The local machine id (single-writer: only this row's capabilities are + // editable). The extension supplies it via the open-fleet-manager message; + // absent it, rows render read-only (honest — we never fabricate "which + // machine is us"). + const [localMachineId, setLocalMachineId] = createSignal(null) + // This machine's window-mode axis (remote-ssh | local) — its OWN field, kept + // separate from link-health posture (ADR 0025 #4). Host-provided via the open + // message; "unknown" until then (honest, never fabricated / overloaded). + const [windowMode, setWindowMode] = createSignal(null) + // The doctor report the extension ships with the Versions route (open-fleet- + // manager). Null until the route provides it — the section degrades honestly. + const [versionReport, setVersionReport] = createSignal<{ surfaces: DoctorSurfaceLike[] } | null>(null) + const onBridge = (e: MessageEvent) => { + const d = e.data as { + source?: string + kind?: string + localMachineId?: string + windowMode?: string + section?: FleetManagerSection + report?: { surfaces: DoctorSurfaceLike[] } + } + if (d?.source !== "amicode" || d.kind !== "open-fleet-manager") return + if (typeof d.localMachineId === "string") setLocalMachineId(d.localMachineId) + if (typeof d.windowMode === "string") setWindowMode(d.windowMode) + if (d.report) setVersionReport(d.report) + if (d.section) setSection(d.section) + } + if (typeof window !== "undefined") { + window.addEventListener("message", onBridge) + onCleanup(() => window.removeEventListener("message", onBridge)) + } + + const [rosterRaw, { refetch }] = createResource( + () => server.current, + () => amicodeGet(server.current, "/amicode/roster").catch(() => undefined), + ) + const reachable = createMemo(() => rosterReachable(rosterRaw())) + const rows = createMemo(() => (reachable() ? rowsFromRosterResponse(rosterRaw()) : [])) + const devices = createMemo(() => shapeFleetDeviceRows({ rows: rows(), localMachineId: localMachineId() })) + const localRow = createMemo(() => rows().find((r) => r.machine_id === localMachineId()) ?? null) + // Hub service is a server-only concern (Server mode === "server"). + const isServer = createMemo(() => localRow()?.server_mode === "server") + + // ── #1344 (ADR 0027 §3, Slice 4): the current attachment + the Attach control. + // The attached peer is read from the pointer's OWN local honesty surface + // (GET /amicode/fleet/attachment — never proxied). A switch re-keys this + + // the roster (a full reload of the scoped surfaces, so no previous studio's + // cached data leaks) WITHOUT ever changing server.current: the webview stays + // SINGLE-ORIGIN. The ADR explicitly rejected reusing useServer's add/setActive + // (the multi-server switcher) for the data plane — a switch is a backend + // pointer flip, so this drives the verb via amicodePost(server.current, …), + // which re-resolves the origin per call. + const [attachmentRaw, { refetch: refetchAttachment }] = createResource( + () => server.current, + () => amicodeGet(server.current, "/amicode/fleet/attachment").catch(() => undefined), + ) + const attachedMachineId = createMemo(() => { + const raw = attachmentRaw() + if (!raw || typeof raw !== "object") return null + const a = raw as { attached?: boolean; pointer?: { machine_id?: string } | null } + return a.attached && a.pointer?.machine_id ? a.pointer.machine_id : null + }) + const runAttachControl = (machineId: string) => { + void performAttachControlWithEffectiveStream({ + control: attachControlFor(machineId, attachedMachineId()), + // server.current's SAME origin — single-origin, never a server switch. + post: (route, body) => amicodePost(server.current, route, body), + // This is this service's local honesty endpoint, not a proxied data-plane + // read. Its legacy identity is valid only after the transport lifecycle + // bound it; malformed/unreadable state intentionally suppresses reset. + readEffectiveStream: () => amicodeGet(server.current, "/amicode/fleet/effective-stream"), + resetGlobalStream: (input) => serverSDK().event.resetForAttachmentChange(input), + }) + .then(() => { + // the switch's reload: refetch the scoped surfaces (no stale-cache leak). + refetchAttachment() + refetch() + }) + .catch(() => {}) + } + + // ── #1544 (slice 4): the lifecycle grant-management panel. Reads the + // SANITIZED grants (sanitizeGrantForDisplay — NEVER the token) off + // GET /amicode/fleet/grants, and offers per-state enable/disable/revoke + // affordances. The pending-requests view is stubbed until #1545 wires the + // request→approve backend. Control affordances live HERE + on the session + // surface — NEVER on the read-only sidebar (ADR 0034 D7). + const [grantsRaw] = createResource( + () => server.current, + () => amicodeGet(server.current, GRANTS_ROUTE).catch(() => undefined), + ) + const grantRows = createMemo(() => { + const raw = grantsRaw() + const grants = raw && typeof raw === "object" ? (raw as { grants?: unknown }).grants : undefined + return shapeGrantRows(Array.isArray(grants) ? (grants as SanitizedGrantLike[]) : []) + }) + const pendingRequests = createMemo(() => shapePendingRequests(grantsRaw())) + const runGrantAction = (targetMachineId: string, affordance: string) => { + // The grant-lifecycle mutations (disable/revoke → revocation, re-admit) are + // dispatched to the extension's lifecycle commands (#1541's issuance/revoke + // seam). Present-and-dispatching; the command handler is the backend seam. + postAmicode(`amicode.fleet.grant.${affordance}:${targetMachineId}`) + } + + // ── Devices: the local row's inline capabilities edit → POST /amicode/roster ─ + const toggleCapability = (tag: string) => { + const row = localRow() + if (!row) return + const has = row.capabilities.includes(tag) + const next = has ? row.capabilities.filter((c) => c !== tag) : [...row.capabilities, tag] + void amicodePost(server.current, "/amicode/roster", buildCapabilitiesReport(row, next)) + .then(() => refetch()) + .catch(() => {}) + } + + // ── This machine: transport selector (prefill roaming → tailscale) ────────── + const transportValue = createMemo(() => { + const row = localRow() + return transportPrefill({ + capabilities: row?.capabilities ?? [], + transport: row?.transport, + }) + }) + const writeTransport = (value: string) => { + // value-bearing envelope — the extension writes amicode.fleetTransport. + try { + window.parent?.postMessage(fleetTransportMessage(value), "*") + } catch { + /* no parent frame (public build) — nothing to write */ + } + } + + // ── Enroll: launch /create-a-fleet, or the honest not-yet-available state ──── + // On this branch #1320 / /create-a-fleet is not built — the absent case. + const enroll = createMemo(() => enrollAction({ hasCreateFleetSkill: false })) + const launchEnroll = () => { + const action = enroll() + if (action.available && action.launch) postAmicode(CREATE_FLEET_COMMAND) + } + + const eyebrow = "text-[10px] uppercase tracking-wide text-text-weak font-[600]" + + const SectionTab = (p: { id: FleetManagerSection; label: string; available?: boolean }) => ( + + + + ) + + return ( +
+ {/* Section segmented control — selection-by-border (design system). */} +
+ + + + + +
+ +
+ {/* ── Devices ─────────────────────────────────────────────────────── */} + + Fleet host unreachable.
} + > + 0} + fallback={
No devices reporting yet.
} + > +
+ + {(device) => ( +
+
+ + {device.name} + + {device.health} + + {/* #1344: the per-row Attach/Detach control — a peer row + only (you attach to ANOTHER machine, not yourself). + Drives the backend verb on server.current's origin. */} + + + +
+
+ role: {device.role} · last-seen: {device.lastSeen} +
+
+ + {(chip) => ( + + )} + +
+ +
+ Toggle a capability to update this machine's row. Role is the reconciled fleet mirror + (changed via enroll / Go Standalone). +
+
+
+ )} +
+
+
+ + + {/* Enroll — launches /create-a-fleet when present; honest state absent. */} +
+
Enroll a device
+ + Guided enrollment isn't available yet on this build. +
+ } + > + + +
+ + + {/* ── This machine ────────────────────────────────────────────────── */} + +
+
+
Server mode
+
{localRow()?.server_mode ?? "unknown"}
+
+ +
+
Window mode
+
{windowMode() ?? "unknown"}
+
+ +
+
Transport
+ +
+ +
+ + +
+
+
+ + {/* ── Hub service (server only) ───────────────────────────────────── */} + +
+
Durable hub
+
+ The durable hub keeps the fleet reachable between sessions. +
+ +
+
+ + {/* ── Grants (#1544 slice 4): lifecycle grant management + pending ── */} + +
+
+
Control grants
+ 0} + fallback={
No control grants issued yet.
} + > +
+ + {(g) => ( +
+
+ + {g.targetMachineId} + {g.scope} + + {g.state} + +
+
+ + {(affordance) => ( + + )} + +
+
+ )} +
+
+
+
+ + {/* Pending requests — the shared-peer request→approve view. Stubbed + until #1545 wires the backend (honest empty, never fabricated). */} +
+
Pending requests
+ 0} + fallback={ +
+ No pending control requests. The request→approve handshake for shared peers arrives in a later + release (#{PENDING_REQUESTS_BACKEND_ISSUE}). +
+ } + > +
+ + {(r) => ( +
+ {r.requesterMachineId} + {r.scope} +
+ )} +
+
+
+
+
+
+ + {/* ── Versions (absorbs the retired Fleet & Versions panel) ───────── */} + +
+
Fleet & versions
+ 0} + fallback={ +
+ Version status is checked by amico doctor. Open this from the + command palette (Fleet & Versions) to load the surface report. +
+ } + > +
+ + {(v) => ( +
+ {v.surface} + + {v.version} → {v.sourceVersion} + + + {v.verdict} + +
+ )} +
+
+
+
+
+ + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.test.ts new file mode 100644 index 000000000..65386b00d --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, test } from "bun:test" +import { + capabilityChip, + shapeFleetDeviceRows, + buildCapabilitiesReport, + fleetManagerCommand, + transportPrefill, + TRANSPORT_OPTIONS, + enrollAction, + CREATE_FLEET_COMMAND, + fleetTransportMessage, + shapeVersionRows, + attachControlFor, + buildAttachRequest, + buildDetachRequest, + switchReloadPlan, + performAttachControl, + performAttachControlWithEffectiveStream, + ATTACH_ROUTE, + DETACH_ROUTE, + shapeGrantRows, + grantAffordances, + shapePendingRequests, + GRANTS_ROUTE, + PENDING_REQUESTS_BACKEND_ISSUE, + type RosterRowLike, + type SanitizedGrantLike, +} from "./fleet-manager" + +const row = (over: Partial = {}): RosterRowLike => ({ + machine_id: "mac-studio-01", + name: "Mac Studio", + server_mode: "server", + capabilities: ["compute", "roaming"], + sshAlias: "mac-studio", + transport: "ssh", + last_report: "2026-09-20T12:00:00.000Z", + health: "reachable", + ...over, +}) + +describe("shapeFleetDeviceRows (AC2 — roster JSON → rendered rows)", () => { + test("maps each roster row to a device row: name, role (server_mode), health, last-seen", () => { + const rows = shapeFleetDeviceRows({ rows: [row({ machine_id: "a", name: "Alpha" })], localMachineId: "a" }) + expect(rows).toHaveLength(1) + expect(rows[0].name).toBe("Alpha") + expect(rows[0].role).toBe("server") // server_mode, surfaced as the "role" label + expect(rows[0].health).toBe("reachable") + expect(rows[0].lastSeen).toBe("2026-09-20T12:00:00.000Z") + expect(rows[0].transport).toBe("ssh") + }) + + test("only THIS machine's own row is editable (single-writer, ADR 0026)", () => { + const rows = shapeFleetDeviceRows({ + rows: [row({ machine_id: "a" }), row({ machine_id: "b" })], + localMachineId: "a", + }) + expect(rows[0].editable).toBe(true) + expect(rows[1].editable).toBe(false) + }) + + test("no local machine known ⇒ no row is editable", () => { + const rows = shapeFleetDeviceRows({ rows: [row({ machine_id: "a" })], localMachineId: null }) + expect(rows[0].editable).toBe(false) + }) +}) + +describe("capabilityChip (AC2 — known/descriptive split; compute not-yet-wired)", () => { + test("compute is known but NOT wired — the honest not-yet-wired affordance (drives no solve routing)", () => { + const chip = capabilityChip("compute") + expect(chip.known).toBe(true) + expect(chip.wired).toBe(false) + expect(chip.note).toBe("not yet wired") + }) + + test("roaming is known AND wired (it defaults transport), no not-yet-wired note", () => { + const chip = capabilityChip("roaming") + expect(chip.known).toBe(true) + expect(chip.wired).toBe(true) + expect(chip.note).toBeUndefined() + }) + + test("an arbitrary descriptive tag round-trips verbatim, marked not-known and unwired", () => { + expect(capabilityChip("gpu-rig")).toEqual({ tag: "gpu-rig", known: false, wired: false }) + }) + + test("shaped rows carry the compute not-yet-wired chip", () => { + const rows = shapeFleetDeviceRows({ rows: [row({ capabilities: ["compute", "gpu-rig"] })], localMachineId: null }) + const compute = rows[0].capabilities.find((c) => c.tag === "compute")! + expect(compute.wired).toBe(false) + expect(compute.note).toBe("not yet wired") + }) +}) + +describe("buildCapabilitiesReport (AC2 — local-row capabilities edit → POST /amicode/roster body)", () => { + test("replaces ONLY capabilities; role (server_mode) and identity are preserved", () => { + const body = buildCapabilitiesReport(row({ machine_id: "a", server_mode: "server" }), ["compute", "gpu-rig"]) + expect(body.capabilities).toEqual(["compute", "gpu-rig"]) + // server_mode is the reconciled fleet.json mirror — NEVER edited from the chip editor (ADR 0026) + expect(body.server_mode).toBe("server") + expect(body.machine_id).toBe("a") + // the rest of the row rides through unchanged (a well-formed RosterRow for POST /amicode/roster) + expect(body.name).toBe("Mac Studio") + expect(body.health).toBe("reachable") + expect(body.sshAlias).toBe("mac-studio") + }) + + test("does not mutate the input row's capabilities array", () => { + const input = row({ capabilities: ["compute"] }) + buildCapabilitiesReport(input, ["compute", "roaming"]) + expect(input.capabilities).toEqual(["compute"]) + }) +}) + +describe("fleetManagerCommand (AC3/AC4 — buttons invoke EXISTING registered commands)", () => { + test("This-machine + Hub actions map to the exact registered command strings", () => { + // These are the commands already registered in the extension (extension.ts): + // the tab INVOKES them, it does not reimplement their logic. + expect(fleetManagerCommand("repair")).toBe("amicode.fleet.repair") + expect(fleetManagerCommand("goStandalone")).toBe("amicode.fleet.goStandalone") + expect(fleetManagerCommand("restartHub")).toBe("amicode.restartHub") + }) +}) + +describe("transportPrefill (AC3 — transport selector prefill)", () => { + test("a roaming-tagged machine prefills to tailscale", () => { + expect(transportPrefill({ capabilities: ["compute", "roaming"], transport: "ssh" })).toBe("tailscale") + }) + + test("a non-roaming machine keeps its recorded transport", () => { + expect(transportPrefill({ capabilities: ["compute"], transport: "ssh" })).toBe("ssh") + }) + + test("a non-roaming machine with no recorded transport falls back to the ssh default", () => { + expect(transportPrefill({ capabilities: [], transport: "" })).toBe("ssh") + expect(transportPrefill({ capabilities: [] })).toBe("ssh") + }) + + test("the selector offers the known transport providers", () => { + expect(TRANSPORT_OPTIONS).toContain("ssh") + expect(TRANSPORT_OPTIONS).toContain("tailscale") + }) + + test("a transport selection rides a value-bearing bridge envelope (writes amicode.fleetTransport)", () => { + expect(fleetTransportMessage("tailscale")).toEqual({ + source: "amicode", + kind: "fleet-set-transport", + value: "tailscale", + }) + }) +}) + +describe("enrollAction (AC6 — Enroll degrades honestly)", () => { + test("launches /create-a-fleet when the skill is present", () => { + expect(enrollAction({ hasCreateFleetSkill: true })).toEqual({ available: true, launch: CREATE_FLEET_COMMAND }) + expect(CREATE_FLEET_COMMAND).toBe("/create-a-fleet") + }) + + test("shows an honest not-yet-available state and launches NOTHING when absent (#1320 not built on this branch)", () => { + const action = enrollAction({ hasCreateFleetSkill: false }) + expect(action.available).toBe(false) + expect(action.launch).toBeUndefined() + }) +}) + +describe("shapeVersionRows (AC5 — Versions renders the retired panel's doctor content)", () => { + test("maps doctor surfaces to version rows; null version → the honest '—' absence marker", () => { + const rows = shapeVersionRows({ + surfaces: [ + { surface: "extension", version: "0.3.6", source_version: "0.3.6", verdict: "current" }, + { surface: "staged-skills", version: null, source_version: null, verdict: "unknown" }, + ], + }) + expect(rows).toEqual([ + { surface: "extension", version: "0.3.6", sourceVersion: "0.3.6", verdict: "current" }, + { surface: "staged-skills", version: "—", sourceVersion: "—", verdict: "unknown" }, + ]) + }) + + test("no report ⇒ no rows (honest empty, never fabricated)", () => { + expect(shapeVersionRows(null)).toEqual([]) + expect(shapeVersionRows(undefined)).toEqual([]) + }) +}) + +// ── #1344 (ADR 0027 §3, Slice 4): the Fleet Manager Attach control ──────────── +// The per-row control that drives the backend attach/detach VERB. NOT a +// FleetManagerAction (that closed union is VS-Code-command invocations via +// postAmicode); attach/detach is a backend API call on server.current's SAME +// loopback origin (the webview stays single-origin — the ADR EXPLICITLY rejected +// reusing the multi-server switcher's setActive for the data plane). + +describe("attachControlFor (AC2 — the per-row Attach/Detach control state)", () => { + test("a row that is NOT the attached one shows the Attach action", () => { + expect(attachControlFor("peer-b", "peer-a")).toEqual({ + machineId: "peer-b", + action: "attach", + label: "Attach", + attached: false, + }) + }) + + test("the currently-attached row shows the Detach action", () => { + expect(attachControlFor("peer-a", "peer-a")).toEqual({ + machineId: "peer-a", + action: "detach", + label: "Detach", + attached: true, + }) + }) + + test("with nothing attached, every row shows Attach", () => { + expect(attachControlFor("peer-a", null).action).toBe("attach") + }) +}) + +describe("buildAttachRequest / buildDetachRequest (AC2 — the verb bodies; machine_id is the candidate key)", () => { + test("attach body carries the machine_id (the roster candidate the backend resolves to a pointer)", () => { + expect(buildAttachRequest("peer-a")).toEqual({ machine_id: "peer-a" }) + }) + test("detach body carries the machine_id", () => { + expect(buildDetachRequest("peer-a")).toEqual({ machine_id: "peer-a" }) + }) + test("the routes are the backend fleet verbs (never proxied — the /amicode/fleet/* honesty surface)", () => { + expect(ATTACH_ROUTE).toBe("/amicode/fleet/attach") + expect(DETACH_ROUTE).toBe("/amicode/fleet/detach") + }) +}) + +describe("switchReloadPlan (AC5 + AC3 — full reload of scoped surfaces, single-origin, fresh cursor)", () => { + test("a switch re-keys scoped /amicode/* surfaces on the attached machine_id (full reload → no previous-studio cache leak)", () => { + expect(switchReloadPlan("peer-b")).toEqual({ + singleOrigin: true, + reloadScopedKey: "peer-b", + clearEventCursor: true, + }) + }) + test("attaching a DIFFERENT peer yields a DIFFERENT reload key — the resources refetch (no stale data survives a switch)", () => { + expect(switchReloadPlan("peer-a").reloadScopedKey).not.toBe(switchReloadPlan("peer-b").reloadScopedKey) + }) + test("detach (back to local) re-keys to null and still clears the cursor; the origin never changes (single-origin)", () => { + const plan = switchReloadPlan(null) + expect(plan.reloadScopedKey).toBeNull() + expect(plan.singleOrigin).toBe(true) + expect(plan.clearEventCursor).toBe(true) // AC3: a stale lastEventID must not carry to a new origin + }) +}) + +describe("performAttachControl (AC2 — the control drives attach AND detach end-to-end; AC5 single-origin)", () => { + test("Attach drives POST /amicode/fleet/attach {machine_id} and returns a reload keyed on the attached peer", async () => { + const posted: { route: string; body: unknown }[] = [] + const result = await performAttachControl({ + control: attachControlFor("peer-b", "peer-a"), // peer-b not attached → Attach + post: async (route, body) => { + posted.push({ route, body }) + return { ok: true, attached: true } + }, + }) + expect(posted).toEqual([{ route: "/amicode/fleet/attach", body: { machine_id: "peer-b" } }]) + expect(result.posted).toEqual({ route: "/amicode/fleet/attach", body: { machine_id: "peer-b" } }) + expect(result.reload).toEqual({ singleOrigin: true, reloadScopedKey: "peer-b", clearEventCursor: true }) + }) + + test("Detach drives POST /amicode/fleet/detach {machine_id} and returns a reload back to local (null key)", async () => { + const posted: { route: string; body: unknown }[] = [] + const result = await performAttachControl({ + control: attachControlFor("peer-a", "peer-a"), // the attached row → Detach + post: async (route, body) => { + posted.push({ route, body }) + return { ok: true, attached: false } + }, + }) + expect(posted).toEqual([{ route: "/amicode/fleet/detach", body: { machine_id: "peer-a" } }]) + expect(result.reload.reloadScopedKey).toBeNull() + expect(result.reload.singleOrigin).toBe(true) + }) + + test("single-origin by construction: the driver is given ONLY a `post` (server.current's origin) — it has NO capability to repoint the app to another server (no setActive/add)", async () => { + // The only injected capability is `post`; there is structurally no + // server-switch function to call. This is the ADR's data-plane rule: a + // switch is a backend pointer flip, never an SDK-origin swap. + let calls = 0 + const result = await performAttachControl({ + control: attachControlFor("peer-b", null), + post: async () => { + calls++ + return { ok: true } + }, + }) + expect(calls).toBe(1) + expect(result.reload.singleOrigin).toBe(true) + }) + + test("reads effective-stream identities around the parsed control response and delegates the generation decision", async () => { + const reads = [ + { ok: true, mode: "legacy-single-pointer", identity: { machine_id: "peer-a", sshAlias: "a", transport: "ssh" } }, + { ok: true, mode: "legacy-single-pointer", identity: { machine_id: "peer-b", sshAlias: "b", transport: "ssh" } }, + ] + const resets: unknown[] = [] + await performAttachControlWithEffectiveStream({ + control: attachControlFor("peer-b", "peer-a"), + post: async () => ({ ok: true }), + readEffectiveStream: async () => reads.shift(), + resetGlobalStream: async (input) => { + resets.push(input) + return true + }, + }) + expect(resets).toEqual([ + { + control: { ok: true }, + before: { ok: true, mode: "legacy-single-pointer", identity: { machine_id: "peer-a", sshAlias: "a", transport: "ssh" } }, + after: { ok: true, mode: "legacy-single-pointer", identity: { machine_id: "peer-b", sshAlias: "b", transport: "ssh" } }, + }, + ]) + }) +}) + +// ── #1544 (slice 4): the Fleet Manager grant-management panel ───────────────── +// The panel lists the lifecycle grants (from GET /amicode/fleet/grants, the +// SANITIZED sanitizeGrantForDisplay output — NEVER the token) with per-state +// enable/disable/revoke affordances, plus a pending-requests view stubbed for +// #1545. These pure helpers are the panel's decision logic (the tab render is +// untested-by-design wiring around them). +const grant = (over: Partial = {}): SanitizedGrantLike => ({ + requesterMachineId: "my-macbook", + targetMachineId: "mac-studio", + scope: "control", + generation: 1, + state: "active", + issuedAt: "2026-09-24T00:00:00.000Z", + ...over, +}) + +describe("#1544 shapeGrantRows — sanitized grants → panel rows (NEVER a token)", () => { + test("maps a grant to a row carrying identity + scope + state + affordances", () => { + const rows = shapeGrantRows([grant()]) + expect(rows).toHaveLength(1) + expect(rows[0].targetMachineId).toBe("mac-studio") + expect(rows[0].scope).toBe("control") + expect(rows[0].state).toBe("active") + expect(rows[0].affordances.length).toBeGreaterThan(0) + }) + + test("a token is NEVER surfaced — even if a malformed input smuggled one in (defensive strip)", () => { + const dirty = { ...grant(), token: "SECRET-abc123" } as unknown as SanitizedGrantLike + const rows = shapeGrantRows([dirty]) + expect(JSON.stringify(rows)).not.toContain("SECRET") + expect(JSON.stringify(rows)).not.toContain("token") + }) + + test("tolerant: non-array / garbage → [] (never throws)", () => { + expect(shapeGrantRows(undefined as unknown as SanitizedGrantLike[])).toEqual([]) + expect(shapeGrantRows("nope" as unknown as SanitizedGrantLike[])).toEqual([]) + }) + + test("the read surface is GET /amicode/fleet/grants (the never-proxied honesty surface)", () => { + expect(GRANTS_ROUTE).toBe("/amicode/fleet/grants") + }) +}) + +describe("#1544 grantAffordances — per-state enable/disable/revoke", () => { + test("an active grant can be disabled and revoked", () => { + expect(grantAffordances("active")).toEqual(["disable", "revoke"]) + }) + test("a revocation-pending grant can be revoked (finalized), not disabled again", () => { + expect(grantAffordances("revocation-pending")).toEqual(["revoke"]) + }) + test("a revoked grant can be re-admitted", () => { + expect(grantAffordances("revoked")).toEqual(["re-admit"]) + }) +}) + +describe("#1544 shapePendingRequests — the #1545 stub (honest empty)", () => { + test("the pending-requests view is stubbed until #1545 wires the request→approve backend", () => { + expect(PENDING_REQUESTS_BACKEND_ISSUE).toBe(1545) + }) + test("reads pending_requests off the grants response; absent/garbage → [] (honest, never fabricated)", () => { + expect(shapePendingRequests({ ok: true, grants: [], pending_requests: [] })).toEqual([]) + expect(shapePendingRequests(undefined)).toEqual([]) + expect(shapePendingRequests({ pending_requests: "nope" })).toEqual([]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.ts new file mode 100644 index 000000000..e0581adb9 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.ts @@ -0,0 +1,427 @@ +// fleet-manager.ts — the Fleet Manager Work Column tab's PURE logic (#1322, +// ADR 0026). Self-contained + browser-safe (no node imports): it runs in the +// SolidJS app, so — like sidebar_fleet_section.ts on the extension side — it +// re-declares the tiny slice of the @amicode/schema roster vocabulary it needs +// rather than importing across the node seam. +// +// What lives here (and is unit-tested), NOT the component render: +// - shapeFleetDeviceRows(): roster JSON → rendered device rows (role, health, +// last-seen, capability chips, single-writer editability). +// - capabilityChip(): a tag → known/descriptive + wired/not-yet-wired chip +// (the `compute` chip is the honest "not yet wired" affordance). +// - buildCapabilitiesReport(): the LOCAL row's capabilities edit → the POST +// /amicode/roster body (server_mode/role is NEVER edited from here). +// - fleetManagerCommand(): the This-machine / Hub buttons' existing command +// strings (invoked, not reimplemented). +// - transportPrefill(): the transport selector's prefill (roaming → tailscale). +// - enrollAction(): the Enroll affordance's honest present/absent degradation. + +/** The per-device reachability tri-state (schema HEALTH_VOCABULARY, #1318). */ +export type RosterHealth = "reachable" | "degraded" | "down" + +/** The KNOWN behavior-adjacent capability tags (schema KNOWN_CAPABILITY_TAGS, + * ADR 0026 §1). Any OTHER tag is a valid descriptive label (known:false). */ +const KNOWN_CAPABILITY_TAGS = ["compute", "roaming"] as const +/** Capabilities carrying ACTIVE behavior today. `roaming` is wired (it defaults + * transport to tailscale); `compute` is declared-but-inert — the honest + * "not yet wired" case (enabling it drives NO solve routing). */ +const WIRED_CAPABILITY_TAGS = ["roaming"] as const + +/** The structural roster-row shape this module consumes (the @amicode/schema + * RosterRow — accepted structurally so the host can pass real parsed rows and + * tests can pass literals, without a node import). */ +export interface RosterRowLike { + machine_id: string + name: string + server_mode: string + capabilities: string[] + sshAlias?: string + transport?: string + last_report: string + health: RosterHealth +} + +/** A capability tag shaped for the chip UI. */ +export interface FleetCapabilityChip { + tag: string + /** true for the behavior-adjacent tags (compute, roaming). */ + known: boolean + /** false ⇒ no active behavior yet; `compute` is the honest not-yet-wired case. */ + wired: boolean + /** an honest affordance note for a known-but-inert capability (compute). */ + note?: string +} + +/** Shape ONE capability tag into its chip (known/descriptive + wired state). */ +export function capabilityChip(tag: string): FleetCapabilityChip { + const known = (KNOWN_CAPABILITY_TAGS as readonly string[]).includes(tag) + const wired = (WIRED_CAPABILITY_TAGS as readonly string[]).includes(tag) + const chip: FleetCapabilityChip = { tag, known, wired } + // The compute chip is declared-but-inert — surface it honestly. + if (tag === "compute") chip.note = "not yet wired" + return chip +} + +/** One rendered device row (roster row after display-labelling). */ +export interface FleetManagerDeviceRow { + machineId: string + name: string + /** server_mode, surfaced under the "role" label — READ-ONLY here. */ + role: string + capabilities: FleetCapabilityChip[] + health: RosterHealth + /** last_report, surfaced under the "last-seen" label. */ + lastSeen: string + transport: string + /** true only for THIS machine's own row — single-writer: only the local + * row's capabilities are editable here (ADR 0026). */ + editable: boolean +} + +/** Roster JSON → rendered device rows. Pure. `localMachineId` marks which row + * (if any) is this machine's own — the only editable one. */ +export function shapeFleetDeviceRows(input: { + rows: RosterRowLike[] + localMachineId: string | null +}): FleetManagerDeviceRow[] { + return input.rows.map((r) => ({ + machineId: r.machine_id, + name: r.name, + role: r.server_mode, + capabilities: r.capabilities.map(capabilityChip), + health: r.health, + lastSeen: r.last_report, + transport: r.transport ?? "", + editable: input.localMachineId !== null && r.machine_id === input.localMachineId, + })) +} + +/** The POST /amicode/roster body for the LOCAL row's capabilities edit. The row + * is carried whole with ONLY `capabilities` replaced — `server_mode` (role) is + * the reconciled fleet.json mirror and is never edited from here (ADR 0026), + * and every other field rides through so the body is a well-formed RosterRow. */ +export function buildCapabilitiesReport(localRow: RosterRowLike, nextCapabilities: string[]): RosterRowLike { + return { ...localRow, capabilities: [...nextCapabilities] } +} + +// ── This-machine / Hub actions → existing registered commands ──────────────── + +/** The tab's command-invoking actions. The tab INVOKES these existing commands + * (via the app→extension bridge, postAmicode) — it never reimplements their + * logic (Key Decision: "the commands stay registered in the Command Palette"). + * `repair` + `goStandalone` are This-machine actions; `restartHub` is the Hub + * service action (server only). */ +export type FleetManagerAction = "repair" | "goStandalone" | "restartHub" + +const FLEET_MANAGER_COMMANDS: Record = { + repair: "amicode.fleet.repair", + goStandalone: "amicode.fleet.goStandalone", + restartHub: "amicode.restartHub", +} + +/** The EXISTING registered command string a This-machine / Hub button invokes. */ +export function fleetManagerCommand(action: FleetManagerAction): string { + return FLEET_MANAGER_COMMANDS[action] +} + +// ── This machine: transport selector ───────────────────────────────────────── + +/** The default transport when a machine has no recorded/roaming hint. */ +export const TRANSPORT_DEFAULT = "ssh" + +/** The transport providers the selector offers (writes `amicode.fleetTransport`). */ +export const TRANSPORT_OPTIONS = ["ssh", "tailscale", "local"] as const + +/** The transport selector's prefill: a `roaming`-tagged machine defaults to + * tailscale (ADR 0026 §1); otherwise the machine's recorded transport, else the + * ssh default. No silent reroute — this is only the SELECTOR's initial value. */ +export function transportPrefill(input: { capabilities: string[]; transport?: string }): string { + if (input.capabilities.includes("roaming")) return "tailscale" + return input.transport && input.transport.trim() !== "" ? input.transport : TRANSPORT_DEFAULT +} + +// ── Enroll: launch /create-a-fleet, or degrade honestly when it is absent ──── + +/** The slash-command the Enroll action launches when the orchestrator (#1320) + * is present. */ +export const CREATE_FLEET_COMMAND = "/create-a-fleet" + +/** The Enroll affordance's resolved state. `available` ⇒ launch the command; + * otherwise the honest not-yet-available state (launches NOTHING) — the live + * wiring lands when #1320 / `/create-a-fleet` ships. */ +export interface EnrollAction { + available: boolean + /** the slash-command to launch — present only when available. */ + launch?: string +} + +/** Resolve the Enroll action honestly: launch `/create-a-fleet` when the skill + * is present, else the not-yet-available state that launches nothing (AC6). */ +export function enrollAction(input: { hasCreateFleetSkill: boolean }): EnrollAction { + if (input.hasCreateFleetSkill) return { available: true, launch: CREATE_FLEET_COMMAND } + return { available: false } +} + +// ── This machine: the transport write envelope (app → extension bridge) ────── + +/** The app→extension message that writes the `amicode.fleetTransport` setting. + * The command bridge (postAmicode) carries no argument, so a transport SELECTION + * (which must carry a value) rides its own value-bearing envelope; the extension + * chat bridge writes the config from it. No silent reroute — the selector's + * change is an explicit, honest write (ADR 0024/0025). */ +export interface FleetTransportMessage { + source: "amicode" + kind: "fleet-set-transport" + value: string +} + +export function fleetTransportMessage(value: string): FleetTransportMessage { + return { source: "amicode", kind: "fleet-set-transport", value } +} + +// ── Versions: the retired Fleet & Versions panel's doctor content ──────────── + +/** A doctor surface record (structural — the extension ships this in the + * open-fleet-manager message; matches fleet_panel.ts FleetSurfaceRecord). */ +export interface DoctorSurfaceLike { + surface: string + version: string | null + source_version: string | null + verdict: string +} + +/** One rendered version row — a surface's running/source version + verdict. */ +export interface FleetVersionRow { + surface: string + version: string + sourceVersion: string + verdict: string +} + +/** The doctor absence marker — the same "—" doctor's own human table uses. */ +const VERSION_NULL_MARKER = "—" + +/** Doctor report → version rows for the tab's Versions section. Null version + * fields render as the honest absence marker; a missing report ⇒ no rows + * (never a fabricated list). This is the content the retired panel showed. */ +export function shapeVersionRows( + report: { surfaces: DoctorSurfaceLike[] } | null | undefined, +): FleetVersionRow[] { + if (!report || !Array.isArray(report.surfaces)) return [] + return report.surfaces.map((s) => ({ + surface: s.surface, + version: s.version ?? VERSION_NULL_MARKER, + sourceVersion: s.source_version ?? VERSION_NULL_MARKER, + verdict: s.verdict, + })) +} + +// ── Devices: the Attach control (#1344, ADR 0027 §3, Slice 4) ──────────────── +// +// The per-row control that INVOKES switching: attach adds an upstream + sets the +// backend D6 pointer (the roster row is the candidate); detach clears it. This +// is NOT a FleetManagerAction — that closed union is VS-Code-command +// invocations (postAmicode). Attach/detach is a BACKEND API call on +// server.current's SAME loopback origin: the webview stays SINGLE-ORIGIN. The +// ADR explicitly rejected reusing the multi-server switcher (useServer's +// add/setActive) for the data plane — a "switch" is a backend pointer flip, not +// an SDK-origin swap. So this module never touches a server-switch surface; the +// driver below is given ONLY a `post`, which the tab wires to +// amicodePost(server.current, …) (resolved per call, so the flip is picked up by +// the next fetch). + +/** The backend fleet verbs — under /amicode/fleet/* so they inherit the + * never-proxied local-honesty-surface exclusion (served on this origin). */ +export const ATTACH_ROUTE = "/amicode/fleet/attach" +export const DETACH_ROUTE = "/amicode/fleet/detach" + +/** One device row's Attach/Detach control state. `attached` marks the row that + * is the CURRENT attachment (it shows Detach); every other row shows Attach. */ +export interface AttachControlState { + machineId: string + action: "attach" | "detach" + /** the button label — "Attach" for a candidate, "Detach" for the attached row. */ + label: string + /** true only for the currently-attached row. */ + attached: boolean +} + +/** Resolve a row's control against the currently-attached machine_id (from GET + * /amicode/fleet/attachment). The attached row detaches; all others attach. */ +export function attachControlFor(machineId: string, attachedMachineId: string | null): AttachControlState { + const attached = attachedMachineId !== null && machineId === attachedMachineId + return attached + ? { machineId, action: "detach", label: "Detach", attached: true } + : { machineId, action: "attach", label: "Attach", attached: false } +} + +/** The POST /amicode/fleet/attach body — the machine_id is the candidate key the + * backend resolves (against the roster) into the pointer's reach coordinates. */ +export function buildAttachRequest(machineId: string): { machine_id: string } { + return { machine_id: machineId } +} + +/** The POST /amicode/fleet/detach body. */ +export function buildDetachRequest(machineId: string): { machine_id: string } { + return { machine_id: machineId } +} + +/** The reload a SWITCH triggers in the app (AC5 + AC3). The switch NEVER swaps + * SDK origins — `singleOrigin` is always true (the webview stays single-origin; + * the flip is a backend pointer re-target of what /amicode/* proxies to). + * Scoped /amicode/* surfaces RE-KEY on `reloadScopedKey` (the attached + * machine_id, or null for local) so they fully refetch — no previous studio's + * cached data leaks across a switch. `clearEventCursor` says the stale SSE + * cursor (a prior origin's lastEventID) must not carry to the new origin. */ +export interface SwitchReloadPlan { + singleOrigin: true + reloadScopedKey: string | null + clearEventCursor: true +} + +export function switchReloadPlan(attachedMachineId: string | null): SwitchReloadPlan { + return { singleOrigin: true, reloadScopedKey: attachedMachineId, clearEventCursor: true } +} + +/** The end-to-end result of driving the control: what was POSTed, and the reload + * the switch now warrants. */ +export interface AttachControlResult { + posted: { route: string; body: { machine_id: string } } + reload: SwitchReloadPlan + /** The parsed local-service control response. The stream reset is gated on + * this exact value reporting `{ ok: true }`, never merely on a pointer write. */ + response: unknown +} + +/** Drive the Attach control end-to-end (AC2). Picks the verb by the control's + * action, POSTs the {machine_id} body via the injected `post` (the tab supplies + * amicodePost(server.current, …) — server.current's SAME origin), and returns + * the reload plan the switch warrants. The ONLY injected capability is `post`: + * there is structurally no server-switch function here, so the control can + * never repoint the app to another origin (AC5 single-origin, by construction). + * attach → reload keyed on the newly-attached peer; detach → reload back to + * local (null key). */ +export async function performAttachControl(input: { + control: AttachControlState + post: (route: string, body: unknown) => Promise +}): Promise { + const { control, post } = input + const route = control.action === "detach" ? DETACH_ROUTE : ATTACH_ROUTE + const body = control.action === "detach" ? buildDetachRequest(control.machineId) : buildAttachRequest(control.machineId) + const response = await post(route, body) + const reload = switchReloadPlan(control.action === "attach" ? control.machineId : null) + return { posted: { route, body }, reload, response } +} + +/** Drive an attachment control through the legacy global-stream boundary. The + * effective-stream reader is deliberately local and authoritative; an unreadable + * signal stays `undefined`, which the reset coordinator rejects without touching + * the healthy stream or cursor. */ +export async function performAttachControlWithEffectiveStream(input: { + control: AttachControlState + post: (route: string, body: unknown) => Promise + readEffectiveStream: () => Promise + resetGlobalStream: (input: { control: unknown; before: unknown; after: unknown }) => Promise +}): Promise { + const before = await input.readEffectiveStream().catch(() => undefined) + const result = await performAttachControl(input) + const after = await input.readEffectiveStream().catch(() => undefined) + await input.resetGlobalStream({ control: result.response, before, after }) + return result +} + +// ── #1544 (slice 4): Fleet Manager grant management ────────────────────────── +// +// The grant-management panel lists the lifecycle grants and offers per-state +// affordances. It reads the SANITIZED grants (sanitizeGrantForDisplay on the +// extension side — the token is NEVER present) off GET /amicode/fleet/grants +// (under /amicode/fleet/* so it inherits the never-proxied local-honesty +// exclusion). The pending-requests view is stubbed until #1545 wires the +// request→approve backend. Control affordances live HERE and on the session +// surface — NEVER on the read-only sidebar (ADR 0034 D7). + +/** The never-proxied read surface for the sanitized grants. */ +export const GRANTS_ROUTE = "/amicode/fleet/grants" + +/** The issue that wires the pending-requests (request→approve) backend. Until + * then the pending-requests view is an honest empty stub. */ +export const PENDING_REQUESTS_BACKEND_ISSUE = 1545 + +/** The sanitized grant shape (mirror of sanitizeGrantForDisplay's output — NO + * token, identity keys truncated on the extension side). */ +export interface SanitizedGrantLike { + requesterMachineId: string + targetMachineId: string + scope: "observe" | "control" | "lifecycle-admin" + generation: number + state: "active" | "revocation-pending" | "revoked" + issuedAt: string + revokedAt?: string + /** requesterIdentityKey may ride through truncated; NEVER a token. */ + requesterIdentityKey?: string +} + +/** The grant-management affordances a grant offers in its current state. */ +export type GrantManagementAffordance = "disable" | "revoke" | "re-admit" + +/** Per-state affordances: active → begin-revocation (disable) or revoke; + * revocation-pending → finalize revoke; revoked → re-admit. */ +export function grantAffordances(state: SanitizedGrantLike["state"]): GrantManagementAffordance[] { + if (state === "active") return ["disable", "revoke"] + if (state === "revocation-pending") return ["revoke"] + return ["re-admit"] +} + +/** One rendered grant row: identity + scope + state + its affordances. The + * token is structurally impossible here (we copy ONLY the safe fields — a + * smuggled `token` on the input is dropped). */ +export interface FleetGrantRow { + requesterMachineId: string + targetMachineId: string + scope: SanitizedGrantLike["scope"] + generation: number + state: SanitizedGrantLike["state"] + issuedAt: string + revokedAt?: string + affordances: GrantManagementAffordance[] +} + +/** Sanitized grants → panel rows. Copies ONLY the safe fields (never a token, + * even if a malformed input carried one). Tolerant: non-array → []. */ +export function shapeGrantRows(grants: SanitizedGrantLike[]): FleetGrantRow[] { + if (!Array.isArray(grants)) return [] + return grants.map((g) => ({ + requesterMachineId: g.requesterMachineId, + targetMachineId: g.targetMachineId, + scope: g.scope, + generation: g.generation, + state: g.state, + issuedAt: g.issuedAt, + ...(g.revokedAt ? { revokedAt: g.revokedAt } : {}), + affordances: grantAffordances(g.state), + })) +} + +/** A pending control request (the shared-peer handshake). Backend is #1545. */ +export interface PendingRequestRow { + requesterMachineId: string + targetMachineId: string + scope: string +} + +/** Read the pending-requests off the grants response. Until #1545 the backend + * always returns [], so this is an honest empty view (never fabricated). */ +export function shapePendingRequests(raw: unknown): PendingRequestRow[] { + if (!raw || typeof raw !== "object") return [] + const pending = (raw as { pending_requests?: unknown }).pending_requests + if (!Array.isArray(pending)) return [] + return pending.flatMap((p) => { + if (!p || typeof p !== "object") return [] + const o = p as Record + if (typeof o.requesterMachineId !== "string" || typeof o.targetMachineId !== "string") return [] + return [{ requesterMachineId: o.requesterMachineId, targetMachineId: o.targetMachineId, scope: typeof o.scope === "string" ? o.scope : "control" }] + }) +} + + + diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts index ef5806dd1..4c00b16f3 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts @@ -347,4 +347,65 @@ describe("createSessionTabs", () => { dispose() }) }) + + // #1322: fleetManager is a Work Column surface with the same lifecycle as + // pulseInspector — open/active, closable, reopenable, and excluded from the + // file panelTabs (it is a named surface, never a file tab). + test("fleetManager is closable and reopenable", () => { + // Step 1: fleetManager is open and active + const initial = { + tabs: { all: ["fleetManager"], active: "fleetManager" as string | undefined }, + preview: undefined, + } + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => initial.tabs.active, all: () => initial.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + expect(result.fleetManagerOpen()).toBe(true) + expect(result.activeTab()).toBe("fleetManager") + expect(result.closableTab()).toBe("fleetManager") + // named surface — never a file/panel tab + expect(result.panelTabs()).toEqual([]) + dispose() + }) + + // Step 2: close fleetManager — removed from state + const afterClose = closeSessionTab(initial, "fleetManager") + expect(afterClose.tabs.all).toEqual([]) + expect(afterClose.tabs.active).toBeUndefined() + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterClose.tabs.active, all: () => afterClose.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + expect(result.fleetManagerOpen()).toBe(false) + expect(result.activeTab()).toBe("home") + dispose() + }) + + // Step 3: re-open fleetManager + const afterReopen = openSessionTab({ tabs: afterClose.tabs, preview: afterClose.preview }, "fleetManager") + expect(afterReopen.tabs.all).toContain("fleetManager") + expect(afterReopen.tabs.active).toBe("fleetManager") + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterReopen.tabs.active, all: () => afterReopen.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + expect(result.fleetManagerOpen()).toBe(true) + expect(result.activeTab()).toBe("fleetManager") + expect(result.closableTab()).toBe("fleetManager") + dispose() + }) + }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts index 16b29e57c..c1f5550cd 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts @@ -47,6 +47,9 @@ export const createSessionTabs = (input: TabsInput) => { const pulseInspectorOpen = createMemo( () => input.tabs().active() === "pulseInspector" || input.tabs().all().includes("pulseInspector"), ) + const fleetManagerOpen = createMemo( + () => input.tabs().active() === "fleetManager" || input.tabs().all().includes("fleetManager"), + ) const homeOpen = createMemo(() => input.tabs().active() === "home" || input.tabs().all().includes("home")) const panelTabs = createMemo( () => { @@ -61,7 +64,8 @@ export const createSessionTabs = (input: TabsInput) => { tab === "vault" || tab === "home" || tab === SESSION_PREVIEW_TAB || - tab === "pulseInspector" + tab === "pulseInspector" || + tab === "fleetManager" ) return [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] @@ -82,6 +86,7 @@ export const createSessionTabs = (input: TabsInput) => { if (active === "home") return active if (active === "context") return active if (active === "pulseInspector") return active + if (active === "fleetManager") return active if (active === SESSION_PREVIEW_TAB && previewOpen()) return active if (active === "vault" && vaultOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active @@ -94,6 +99,7 @@ export const createSessionTabs = (input: TabsInput) => { if (previewOpen()) return SESSION_PREVIEW_TAB if (contextOpen()) return "context" if (pulseInspectorOpen()) return "pulseInspector" + if (fleetManagerOpen()) return "fleetManager" if (review() && hasReview()) return "review" return "home" }) @@ -106,6 +112,7 @@ export const createSessionTabs = (input: TabsInput) => { const active = activeTab() if (active === "context") return active if (active === "pulseInspector" && pulseInspectorOpen()) return active + if (active === "fleetManager" && fleetManagerOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (!openedTabs().includes(active)) return return active @@ -115,6 +122,7 @@ export const createSessionTabs = (input: TabsInput) => { contextOpen, previewOpen, pulseInspectorOpen, + fleetManagerOpen, homeOpen, openFileOpen, panelTabs, diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/preview-human-write-gate.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/preview-human-write-gate.test.ts new file mode 100644 index 000000000..ed2fee06a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/preview-human-write-gate.test.ts @@ -0,0 +1,99 @@ +/** + * preview-human-write-gate.test.ts — #1442 AC5, extended by #1454 (W5) + * + * The human Preview / review-panel write gate. #1454 (W5) resolves the abs-path + * fork toward NORMALIZE-AND-ACCEPT — an absolute path that lands INSIDE the + * workspace is accepted (normalized); only writes resolving OUTSIDE the + * workspace are rejected. Preview paths are legitimately absolute + * (preview-file-helpers.ts:11-21), so a blanket absolute-reject would break real + * writes. The gate is browser-safe (no node:path) because it runs in the + * webview, and it is mounted UPSTREAM of the shared engine file.write handler at + * both human callers (Preview + review panel); the agent's in-process write + * (tool/write.ts) is out of scope. + */ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { validateHumanWritePath } from "../../components/session/preview-human-write-gate" + +describe("preview human write gate (#1442 AC5 / #1454 W5)", () => { + const workspace = "/home/user/project" + + test("a workspace-relative path is allowed", () => { + const result = validateHumanWritePath("src/main.ts", workspace) + expect(result.allowed).toBe(true) + if (result.allowed) expect(result.resolvedPath).toBe("/home/user/project/src/main.ts") + }) + + test("a simple filename is allowed", () => { + const result = validateHumanWritePath("README.md", workspace) + expect(result.allowed).toBe(true) + if (result.allowed) expect(result.resolvedPath).toBe("/home/user/project/README.md") + }) + + test("an absolute path OUTSIDE the workspace is rejected", () => { + const result = validateHumanWritePath("/etc/passwd", workspace) + expect(result.allowed).toBe(false) + if (!result.allowed) expect(result.reason).toBe("path-escapes-workspace") + }) + + // #1454: the fork is resolved toward normalize-and-accept — this SUPERSEDES + // the #1442 "abs-inside rejected" assertion (Preview paths are often absolute). + test("an absolute path INSIDE the workspace is normalized and accepted", () => { + const result = validateHumanWritePath("/home/user/project/src/main.ts", workspace) + expect(result.allowed).toBe(true) + if (result.allowed) expect(result.resolvedPath).toBe("/home/user/project/src/main.ts") + }) + + test("a relative path with ../ that escapes the workspace is rejected", () => { + const result = validateHumanWritePath("../../etc/passwd", workspace) + expect(result.allowed).toBe(false) + if (!result.allowed) expect(result.reason).toBe("path-escapes-workspace") + }) + + test("a relative path with ../ that stays in the workspace is allowed", () => { + const result = validateHumanWritePath("src/../lib/util.ts", workspace) + expect(result.allowed).toBe(true) + if (result.allowed) expect(result.resolvedPath).toBe("/home/user/project/lib/util.ts") + }) + + test("an absolute path that traverses back out of the workspace is rejected", () => { + const result = validateHumanWritePath("/home/user/project/../secret.txt", workspace) + expect(result.allowed).toBe(false) + if (!result.allowed) expect(result.reason).toBe("path-escapes-workspace") + }) + + test("an empty path is rejected", () => { + const result = validateHumanWritePath("", workspace) + expect(result.allowed).toBe(false) + if (!result.allowed) expect(result.reason).toBe("empty-path") + }) + + test("the gate is browser-safe — no node:path import (it runs in the webview)", () => { + const src = readFileSync( + join(import.meta.dir, "../../components/session/preview-human-write-gate.ts"), + "utf8", + ) + expect(/from\s+["']node:path["']/.test(src)).toBe(false) + expect(/require\(\s*["']node:path["']\s*\)/.test(src)).toBe(false) + }) +}) + +// #1454: the gate must have LIVE (non-test) importers — both human write callers +// (Preview + review panel) — killing its dead-module status. The shared engine +// file.write handler is gated UPSTREAM (at these callers), never edited. +describe("preview human write gate is mounted at both human callers (#1454 W5)", () => { + const read = (rel: string) => readFileSync(join(import.meta.dir, rel), "utf8") + + test("preview-file-view imports and applies the gate before writing", () => { + const src = read("../../components/session/preview-file-view.tsx") + expect(src.includes("preview-human-write-gate")).toBe(true) + expect(src.includes("validateHumanWritePath(")).toBe(true) + }) + + test("the review panel imports and applies the gate before writing", () => { + const src = read("./v2/review-panel-v2.tsx") + expect(src.includes("preview-human-write-gate")).toBe(true) + expect(src.includes("validateHumanWritePath(")).toBe(true) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx index 158278474..9baf73a87 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx @@ -95,6 +95,7 @@ import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { floorPanelColumnWidth, WORK_COLUMN_WIDTH_MIN } from "@/pages/session/session-panel-width" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" +import { FleetManagerContent } from "@/pages/session/fleet-manager-tab" type PulseInspectorStage = "optimization" | "calibration" | "compilation" @@ -459,6 +460,21 @@ export function SessionSidePanel(props: { const contextOpen = tabState.contextOpen const previewOpen = tabState.previewOpen const pulseInspectorOpen = tabState.pulseInspectorOpen + const fleetManagerOpen = tabState.fleetManagerOpen + // Bridge listener: open the fleet manager tab when the extension broadcasts + // the open-fleet-manager message. This runs at the panel level so the tab + // activates even when FleetManagerContent is not yet mounted. + if (typeof window !== "undefined") { + const onFleetBridge = (e: MessageEvent) => { + const d = e.data as { source?: string; kind?: string } + if (d?.source !== "amicode" || d.kind !== "open-fleet-manager") return + tabs().open("fleetManager") + tabs().setActive("fleetManager") + openReviewPanel() + } + window.addEventListener("message", onFleetBridge) + onCleanup(() => window.removeEventListener("message", onFleetBridge)) + } const openFileOpen = tabState.openFileOpen const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs @@ -473,6 +489,7 @@ export function SessionSidePanel(props: { if (tab === "review") return reviewTab() && props.canReview() && reviewTabOpen() if (tab === "context") return contextOpen() if (tab === "pulseInspector") return pulseInspectorOpen() + if (tab === "fleetManager") return fleetManagerOpen() return previewOpen() } @@ -563,6 +580,14 @@ export function SessionSidePanel(props: { active: () => activeTab() === "pulseInspector", group: "Quantum", }, + { + id: "fleetManager", + label: "Fleet Manager", + icon: "server", + available: () => true, + active: () => activeTab() === "fleetManager", + group: "Fleet", + }, { id: SESSION_PREVIEW_TAB, label: "Preview", @@ -812,6 +837,39 @@ export function SessionSidePanel(props: { +
+ + tabs().close("fleetManager")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("fleetManager")} + > +
+ +
Fleet Manager
+
+
+
+ + + + + + + + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("fleetManager")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("fleetManager")} + > +
+ +
Fleet Manager
+
+
+
+ + + + + + { + test("archiveSession refreshes the per-session store info-only (the composer + menu source)", () => { + const body = archiveSessionBody(source) + // The per-session store (sync().session.get(id).time.archived) is what the + // composer banner and the menu toggle read; the INFO-ONLY resolve(force) + // updates it without re-pulling the whole message history (the archive-lag fix). + expect(body).toContain("session.resolve(sessionID, { force: true })") + // …and must NOT use the heavy sync(force), which re-fetches all messages. + expect(body).not.toContain("session.sync(sessionID, { force: true })") + }) + + test("archiveSession does NOT navigate away from the archived session", () => { + const body = archiveSessionBody(source) + // Bouncing to another session meant the read-only mode was never seen. + expect(body).not.toContain("navigateAfterSessionRemoval") + }) + + test("archiveSession does NOT evict the per-session info the banner/menu depend on", () => { + const body = archiveSessionBody(source) + // evict() drops the per-session info; the read-only banner and the + // Unarchive toggle both read it, so evicting here re-breaks the flip. + expect(body).not.toContain("session.evict(sessionID)") + }) +}) + +describe("session-header archive menu — #1646 reactivity anchor on the toggle", () => { + test("the archived toggle subscribes to time.updated, not just the time.archived leaf", () => { + // A that touches only the (initially-undefined) time.archived leaf + // never subscribes in the Solid store, so the toggle does not flip when the + // archive event lands. The fix routes the gate through a helper that also + // touches time.updated (the anchor every remember() bumps). + expect(source).toContain("sessionArchived(") + // and the raw un-anchored read is gone from the menu gates + expect(source).not.toContain("when={sync().session.get(id)?.time?.archived}") + }) + + test("sessionArchived touches time.updated as the subscription anchor", () => { + const start = source.indexOf("sessionArchived") + expect(start).toBeGreaterThan(-1) + // The helper (wherever defined) reads time.updated before returning + // time.archived — the #1646 anchor pattern. + const helperDef = source.indexOf("sessionArchived =") + expect(helperDef).toBeGreaterThan(-1) + const window = source.slice(helperDef, helperDef + 400) + expect(window).toContain("time?.updated") + expect(window).toContain("time?.archived") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx index ff4259c0c..5ebc3f87e 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -88,6 +88,8 @@ import { usePrompt } from "@/context/prompt" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" +import { fleetSessionsFromResponse, resolveHeaderProvenance } from "./session-header-provenance" +import { findSessionOwnerInProjection, findSessionControlInProjection, writeAffordanceEnabled } from "@/components/session/session-fleet-peers" import { draftPrompt } from "@/utils/start-prompt" import { inAmicode, postAmicode } from "@/pages/session/use-amicode-commands" import { writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" @@ -98,7 +100,7 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { sessionTitle } from "@/utils/session-title" import { scheduleConnectedMeasure } from "./measure" import { observeElementOffsetReconnectAware } from "./observe-element-offset" -import { smoothScrollInterpolate, SMOOTH_SCROLL_DURATION } from "./smooth-scroll" +import { smoothScrollInterpolate, SMOOTH_SCROLL_DURATION, shouldInstantScroll } from "./smooth-scroll" import { createTimelineProjection } from "./projection" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" @@ -320,7 +322,7 @@ export function MessageTimeline(props: { userMessages: UserMessage[] anchor: (id: string) => string setRevealMessage?: (fn: (id: string) => void) => void - setScrollToEnd?: (fn: () => void) => void + setScrollToEnd?: (fn: (opts?: { smooth?: boolean }) => void) => void setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void }) { let touchGesture: number | undefined @@ -387,6 +389,15 @@ export function MessageTimeline(props: { () => amicodeGet(server.current, "/amicode/run-status"), ) const entityRunStatus = createMemo(() => parseRunStatusResponse(runStatusRaw.latest)) + // #1452 (W4a): fleet-wide session provenance. Fetch the merged fleet-sessions + // projection (W2 #1447) and resolve THIS session's amicode_owner overlay: a + // remote-owned session renders a monitor icon + owner-name tooltip left of the + // title; a local or owner-less (legacy/local-path) session degrades to today's + // no-provenance header. Keyed on server.current — a server switch re-fetches. + const [fleetSessionsRaw] = createResource( + () => server.current, + () => amicodeGet(server.current, "/amicode/fleet/sessions").catch(() => undefined), + ) const openEntityView = (kind: string, seq?: number) => { setEntityViewOpen(true) dialog.show( @@ -431,18 +442,40 @@ export function MessageTimeline(props: { const [listRoot, setListRoot] = createSignal() const sessionID = createMemo(() => params.id) + // #1452 (W4a): the header's provenance — this session's owner overlay mapped + // through resolveSessionProvenance. Recomputes when the projection resolves or + // the session changes; degrades to no-icon when owner-less/local/not-found. + const sessionProvenance = createMemo(() => + resolveHeaderProvenance(fleetSessionsFromResponse(fleetSessionsRaw.latest), sessionID()), + ) const sessionStatus = createMemo(() => { const id = sessionID() if (!id) return idle return sync().data.session_status[id] ?? idle }) + // #1649 — the FLOORED working state for the viewed session, driving the rail's + // live dot. session_working ORs the raw busy leaf, a streaming part, the turn + // floor, AND a non-idle descendant (a foreground subagent) — so the rail stays + // live through a child run even when the parent's own leaf is stray-idled. + const sessionWorking = createMemo(() => { + const id = sessionID() + return !!id && sync().data.session_working(id) + }) const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) const projectedMessages = createMemo(() => { const id = sessionID() if (!id) return [] + const messages = sync().data.session_message[id] ?? [] + // #1579: when visibleUserMessages transiently empties (data.message + // fluctuated during a wire reconcile or mirror hydration), the boundary + // computation picks the FIRST user message as the cutoff and filters + // everything out — the timeline renders zero rows even though + // session_message still holds the full history. Skip the boundary + // filter when the visible set is empty; the full session_message + // renders instead, and the projection corrects on the next tick. const visible = new Set(props.userMessages.map((message) => message.id)) + if (visible.size === 0) return messages const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id - const messages = sync().data.session_message[id] ?? [] return boundary ? messages.filter((message) => message.id < boundary) : messages }) const info = createMemo(() => { @@ -450,6 +483,17 @@ export function MessageTimeline(props: { if (!id) return return sync().session.get(id) }) + // #1646: whether a session reads as archived, with a Solid store subscription + // anchor. A gate that touches only the (initially-undefined) time.archived + // leaf never subscribes in the store, so the Archive/Unarchive menu toggle + // does not flip when the archive event lands. Touch time.updated — which + // every remember() bumps — to guarantee the recompute. Same fix the composer + // memo uses (session-composer-region-controller.ts). + const sessionArchived = (id: string) => { + const s = sync().session.get(id) + void s?.time?.updated + return !!s?.time?.archived + } const titleValue = createMemo(() => info()?.title) const titleLabel = createMemo(() => sessionTitle(titleValue())) const shareUrl = createMemo(() => info()?.share?.url) @@ -574,6 +618,7 @@ export function MessageTimeline(props: { sessionMessages: projectedMessages, parts: getMsgParts, status: sessionStatus, + working: sessionWorking, showReasoningSummaries: settings.general.showReasoningSummaries, inlineComments: settings.general.newLayoutDesigns, tailProseSettled, @@ -769,6 +814,14 @@ export function MessageTimeline(props: { const target = el.scrollHeight - el.clientHeight const current = el.scrollTop if (Math.abs(target - current) < 2) return // already there + // amicode: large delta → instant jump. Animating across more than 1.5× + // the viewport chases a moving target while the virtualizer is still + // measuring off-screen items, producing visible jank. + if (shouldInstantScroll(target, current, el.clientHeight)) { + cancelSmoothScroll() + virtualizer.scrollToEnd() + return + } // If an animation is in flight, restart from current position cancelSmoothScroll() smoothStartY = current @@ -807,7 +860,11 @@ export function MessageTimeline(props: { queueMicrotask(() => { resizeAnchorScheduled = false if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return - smoothScrollToEnd() + // amicode: use instant scroll to avoid fighting the auto-scroller's + // ResizeObserver during streaming — the 180ms animation and the instant + // scrollToBottom were competing, causing visible oscillation. + cancelSmoothScroll() + virtualizer.scrollToEnd() }) } virtualizer.resizeItem = (index, size) => { @@ -916,7 +973,11 @@ export function MessageTimeline(props: { if (index === undefined) return virtualizer.scrollToIndex(index, { align: "center" }) }) - props.setScrollToEnd?.(() => smoothScrollToEnd()) + props.setScrollToEnd?.((opts?: { smooth?: boolean }) => { + cancelSmoothScroll() + if (opts?.smooth) smoothScrollToEnd() + else virtualizer.scrollToEnd() + }) props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor }) }) @@ -1234,22 +1295,52 @@ export function MessageTimeline(props: { if (!session) return if ((await sdk().protocol) !== "v1") return - const sessions = sync().data.session ?? [] - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + // #1647 (S5): control-aware + the session's OWN directory. A REMOTE session + // archives on its owner (the observation write plane routes the non-GET by + // owner); if control is not held the write would 403 into a generic "request + // failed" toast, so gate it here with a clear "enable control" message + // instead. The owner/control come from the fleet projection. Using the + // session's own directory (not sdk().directory) keeps the PATCH keyed + // correctly for a remote session that lives in a different project. + const owner = findSessionOwnerInProjection(fleetSessionsRaw.latest, sessionID) + if (owner && owner.is_local === false) { + const control = findSessionControlInProjection(fleetSessionsRaw.latest, sessionID) + if (!writeAffordanceEnabled(control)) { + showToast({ + title: "Control not enabled", + description: `Enable control of ${owner.owner_name ?? "the owner machine"} to archive its sessions from here.`, + }) + return + } + } + const directory = session.directory ?? sdk().directory await sdk() - .client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) + .client.session.update({ sessionID, directory, time: { archived: Date.now() } }) .then(() => { + // Drop the archived session out of the ACTIVE list store (it belongs to + // the Archived tab now) and clear its tab chrome — but do NOT navigate + // away, and do NOT evict. Archiving the session you are VIEWING should + // flip it to the read-only banner IN PLACE and swap the menu item to + // "Unarchive"; the old behavior bounced you to another session (so the + // read-only mode was never seen) and evict() dropped the per-session + // info the banner and the menu toggle both read. sync().set( produce((draft) => { const index = draft.session.findIndex((s) => s.id === sessionID) if (index !== -1) draft.session.splice(index, 1) }), ) - sync().session.evict(sessionID) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] }) + // #1646: refresh the PER-SESSION store so the composer's `archived` + // memo and this menu's archived gate flip immediately. The list splice + // above touches only the list store; the per-session store + // (sync().session.get(id).time.archived) is what the composer and the + // menu read, and only a forced refresh (or a lazily-arriving SSE) sets it. + // INFO-ONLY `resolve` (session.get → remember), NOT `sync`: the archived + // flag is metadata, so a forced `sync` additionally re-pulled the viewed + // session's full message history + parents (seconds of wasted wire). + void sync().session.resolve(sessionID, { force: true }).catch(() => {}) }) .catch((err) => { showToast({ @@ -1261,8 +1352,12 @@ export function MessageTimeline(props: { const unarchiveSession = async (sessionID: string) => { if ((await sdk().protocol) !== "v1") return - await (sdk().client.session.update as Function)({ sessionID, directory: sdk().directory, time: { archived: null } }) - .then(() => void sync().session.sync(sessionID, { force: true })) + // #1647 (S5): use the session's own directory (remote sessions may live in a + // different project); the owner-routed write plane handles remote routing. + const directory = sync().session.get(sessionID)?.directory ?? sdk().directory + await (sdk().client.session.update as Function)({ sessionID, directory, time: { archived: null } }) + // INFO-ONLY resolve (metadata flip), NOT sync — see the archive path above. + .then(() => void sync().session.resolve(sessionID, { force: true })) .catch((err: unknown) => { showToast({ title: language.t("common.requestFailed"), @@ -1393,7 +1488,15 @@ export function MessageTimeline(props: { ) } - const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID + // #1637: the transcript per-turn indicator reflects session_working (the + // turn-active floor), NOT raw status type — a stray idle during a live turn + // (incl. a no-part turn) must not flip the indicator to done. The diff/todo + // refetch readers in session.tsx stay on raw status (they want the real idle + // edge) and are deliberately NOT migrated. + const workingTurn = (userMessageID: string) => { + const id = sessionID() + return !!id && sync().data.session_working(id) && activeMessageID() === userMessageID + } const turnDurationMs = (userMessageID: string) => { const message = messageByID().get(userMessageID) @@ -2041,6 +2144,20 @@ export function MessageTimeline(props: { }} >
+ {/* #1452 (W4a): fleet provenance — a remote-owned session shows + a monitor icon (tooltip = owner machine name) left of the + title; local / owner-less sessions render nothing. */} + + + + + + +