diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index c763d9934..18d449abb 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -14,7 +14,7 @@ The standalone app checks for updates on launch and prompts in the Baseboard. ** ### Quit-time install -**The updater owns no quit interception** — install runs only when `hasPendingUpdate()` is true, after the quit orchestrator's teardown and save/drain steps (`docs/specs/standalone.md` §Quit flow) (rationale). **It runs in `main`, the window the quit walk tears down last and the only one holding `updater:*`** (`capabilities/main-only.json`); every other window has handed on by then, so nothing it could still be writing outlives the install. +**The updater owns no quit interception** — install runs only when `hasPendingUpdate()` is true, after the quit orchestrator's teardown and save/drain steps (`docs/specs/standalone.md` §Quit flow) (rationale). **It runs in `main`, the window the quit walk tears down last and the only one holding `updater:default`** (`capabilities/main-only.json`); every other window has handed on by then, so nothing it could still be writing outlives the install. **Only `main` ever checks**, so it is the only window that can hold a download at all — and **closing `main` throws away an approved one**, which lives in that webview's memory. Its close confirmation says so, and is shown for that reason alone even with nothing running (`docs/specs/standalone.md` → "Per-window close"); a session that has closed `main` simply has no update to install until it relaunches (rationale). `installPendingUpdate()` writes the success marker *before* `install()` (§localStorage), and on Windows first awaits bounded sidecar teardown (§Sidecar teardown on Windows). **It never closes the window itself** — exiting the process is `quit_proceed`'s job, after this returns. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 7515a317c..25f12f8c4 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -165,7 +165,7 @@ The union projection and its indicators are owned by `docs/specs/alert.md` → W Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`. -**Every Workspace verb has a `dor` counterpart** (`docs/specs/dor-cli.md` → "dor workspace"), taking the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently. +**Create, rename, close, and switch have `dor` counterparts** (`docs/specs/dor-cli.md` → "dor workspace"); reorder is strip-only, and transfer and tear-out stay drag-only until `docs/specs/dor-cli.md` → Future "Cross-Window targeting" lands. Each takes the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently. ## Modes diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 8512e6a3a..451ec40af 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -327,7 +327,7 @@ so `titleBarStyle`, `hiddenTitle`, `dragDropEnabled` and the CSP carry across with no second copy of any of them. **Capabilities are split**: `default.json` covers `main` and the `ws-*` glob, -and `main-only.json` scopes `updater:*` and `core:app:allow-version` to `main`, +and `main-only.json` scopes `updater:default` and `core:app:allow-version` to `main`, which structurally enforces that the install runs in the window the walk tears down last (`docs/specs/auto-update.md`). Custom commands need no capability entry. `standalone/scripts/tauri-conf.test.mjs` pins both. @@ -339,7 +339,9 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`, | Sidecar event | Key | Goes to | |---|---|---| -| `pty:data`, `terminal:semanticEvents`, `terminal:protocolEvents` | `data.id` | its owner; dropped while the id is mid-transfer | +| `pty:data` | `data.id` | its owner; dropped while the id is mid-transfer, its bytes being in the replay | +| `terminal:semanticEvents` | `data.id` | its owner; dropped while the id is mid-transfer — the target re-derives them from the raw replay, feeding both pane state and its `AlertManager` (rationale) | +| `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay, which rebuilds none of them; at most `HELD_EVENTS_MAX` (256) per id, overflow dropping the oldest (`held_events_come_back_in_order_and_bounded`) | | `pty:exit`, `pty:replay` | `data.id` | its owner, never suppressed | | `pty:list` | `data.forWindow` | the window that asked | | `alert:*` carrying `data.id` | `data.id` | its owner | @@ -540,8 +542,9 @@ below reads that record rather than inferring itself from the suppression map. "arrived before armed" class of bug (rationale). Rust answers `pty:requestInit` with **that arrival's ids and no others**; `pty:list` and each `pty:replay` echo the collector's token. The target resumes over them, - hydrates the notes, seeds each persisted TODO into its own `AlertManager`, - and mounts the Workspace at the drop index. + hydrates the notes and mounts the Workspace at the drop index. + **Must seed persisted alerts before requesting replay**, so the older state + cannot erase WATCHING rebuilt by replay (`standalone/src/workspace-move.test.ts`). 4. **Target adopted** invokes `adopt_done(workspaceId)`. Rust retires the record, clears what is left of the suppression, and emits `workspace-departed` for **that Workspace alone** to its own source. @@ -571,6 +574,8 @@ below reads that record rather than inferring itself from the suppression map. source unsuppressed, drop the record, and emit `workspace-arrival-failed`; the source clears **transferring** and the Workspace is simply still there. With both ends gone the shells are reaped rather than left owned by a dead label. + **The gap is lost on a hand-back**: suppressed from the invoke with no replay + to follow, it is the one path nothing recovers. - **`planArrival` never throws into `bootstrap()`.** A refused sole arrival on the boot path renders a fresh one-pane Workspace, never a blank window. - **`take_arrivals` does not consume.** The record settles at `adopt_done`, so a diff --git a/docs/specs/standalone.rationale.md b/docs/specs/standalone.rationale.md index 66685ab07..0c57ce2a7 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -43,6 +43,16 @@ minted in `pty_spawn`, so an unowned id is one whose window went away, and the broadcast reached every sibling's AlertManager — which rang, and offered a TODO, for a pane none of them showed. +The first hold queued both derived streams, on the premise that neither is in +any replay. Semantic events are: the replay is the raw bytes, OSCs included, and +the target's replay listener re-parses them. The flushed queue then re-applied +`commandStart` on top of state the replay had just rebuilt, and `commandStart` +is not idempotent — it mints a fresh id and consumes the pending command line — +so a transfer that split a command's `commandLine` from its `commandStart` left +the arriving window with a derived title for a command whose real line the +replay had already recovered. What the replay path genuinely did not rebuild was +the AlertManager's copy, and that is a listener fix, not a routing one. + ## What a window's `Destroyed` settles Tauri removes a label from `webview_windows()` only when the window is actually diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0aeff01e8..c439a3ce5 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 9000, + "docs/specs/standalone.md": 9100, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/sidecar/main-wiring.test.js b/standalone/sidecar/main-wiring.test.js new file mode 100644 index 000000000..d848fdafb --- /dev/null +++ b/standalone/sidecar/main-wiring.test.js @@ -0,0 +1,21 @@ +// The sidecar's entry wires pty-core to the bundles it requires. These are +// source checks, because loading main.js needs the built bundles and a live +// stdin; each pins one injection whose absence fails silently at runtime. +const { test } = require('node:test'); +const assert = require('node:assert'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); + +const source = readFileSync(path.join(__dirname, 'main.js'), 'utf8'); + +test('pty-core is created with the shared sliceSince, so recovery capture reads a buffer', () => { + // Without it `outputSince` answers '' and `captureAgentRecovery` records + // nothing, with no error anywhere (pty-core.js -> outputSince). + assert.match(source, /\{\s*captureAgentRecovery,\s*createRecoveryStore,\s*sliceSince\s*\}\s*=\s*require\('\.\/recovery\.cjs'\)/); + assert.match(source, /nodePty,\s*\{\s*replay:\s*true,\s*sliceSince\s*\}\)/); +}); + +test('recovery capture and the record take are answered from pty-core marks', () => { + assert.match(source, /receivedChars:\s*\(id\)\s*=>\s*mgr\.receivedChars\(id\)/); + assert.match(source, /outputSince:\s*\(id,\s*mark\)\s*=>\s*mgr\.outputSince\(id,\s*mark\)/); +}); diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 04e1251ae..e813948c7 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -86,6 +86,18 @@ struct RoutingState { /// dor requestId -> the window handling it, so a cancel reaches the window /// holding the subscription, watch or completion claim it releases. dor_targets: HashMap, + /// Protocol events that arrived while their id was suppressed, delivered + /// to the new owner behind its replay (`routing::Route::Hold`). Only ever + /// emptied together with `awaiting_replay` (`lift_suppression`). + held: HashMap>, +} + +impl RoutingState { + /// `routing::lift_suppression` over this state's two halves. The caller + /// republishes `WindowState::suppressed` after it, still under the lock. + fn lift_suppression(&mut self, id: &str) -> Vec { + routing::lift_suppression(&mut self.awaiting_replay, &mut self.held, id) + } } #[derive(Default)] @@ -139,10 +151,10 @@ impl WindowState { fn mint(&self, id: &str, label: &str) { let mut routing = guard(&self.routing); routing.owners.insert(id.to_string(), label.to_string()); - if routing.awaiting_replay.remove(id).is_some() { - self.suppressed - .store(routing.awaiting_replay.len(), Ordering::Relaxed); - } + // Whatever was held belonged to the PTY that never arrived, not this one. + routing.lift_suppression(id); + self.suppressed + .store(routing.awaiting_replay.len(), Ordering::Relaxed); } /// Refuse every later `save_session` for `label` (a deliberate close removed @@ -167,7 +179,12 @@ impl WindowState { if suppress { routing.awaiting_replay.insert(id.clone(), now); } else { - routing.awaiting_replay.remove(id); + // A hand-back. The gap is lost here: the source was suppressed + // like any other non-owner from the invoke on, and no replay + // follows a hand-back, so the bytes and everything derived from + // them are gone from its pane. A later stage recovers the gap + // (docs/specs/standalone.md -> "Arrival queue"). + routing.lift_suppression(id); } } self.suppressed @@ -178,7 +195,7 @@ impl WindowState { fn forget_pty(&self, id: &str) { let mut routing = guard(&self.routing); routing.owners.remove(id); - routing.awaiting_replay.remove(id); + routing.lift_suppression(id); self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } @@ -190,7 +207,7 @@ impl WindowState { fn clear_suppression(&self, ids: &[String]) { let mut routing = guard(&self.routing); for id in ids { - routing.awaiting_replay.remove(id); + routing.lift_suppression(id); } self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); @@ -209,7 +226,7 @@ impl WindowState { let mut routing = guard(&self.routing); for id in lost.iter().flat_map(|arrival| &arrival.terminal_ids) { routing.owners.remove(id); - routing.awaiting_replay.remove(id); + routing.lift_suppression(id); } let owned = routing.owned_by(label); for id in &owned { @@ -266,6 +283,8 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { }; let mut released: Vec = Vec::new(); + // Held events an expired suppression releases, flushed to the owner below. + let mut flushed: Vec<(String, Vec)> = Vec::new(); let delivery = { // Before the routing lock, never inside it (§`arrivals`). Nothing is // transferring in the steady state, so this second acquisition is paid @@ -287,6 +306,13 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { state .suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); + for id in &released { + // The sweep already took the map entry; this takes the queue. + let queue = routing.lift_suppression(id); + if let (false, Some(label)) = (queue.is_empty(), routing.owners.get(id)) { + flushed.push((label.clone(), queue)); + } + } } } @@ -300,6 +326,12 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { }, ) { Route::Drop => Delivery::Nowhere, + Route::Hold => { + if let Some(id) = data.get("id").and_then(JsonValue::as_str) { + routing::hold_event(&mut routing.held, id, event, data.clone()); + } + Delivery::Nowhere + } Route::Broadcast => Delivery::Broadcast, Route::EmitTo(label) => Delivery::To(label.to_string()), // Resolved here, where the focus order is a sibling of the map the @@ -318,6 +350,12 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { } }; + for (label, queue) in flushed { + for (held_event, held_data) in queue { + let _ = app.emit_to(label.as_str(), held_event.as_str(), &held_data); + } + } + let mut delivered: Option<&str> = None; match &delivery { Delivery::Nowhere => {} @@ -361,11 +399,21 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { } "pty:replay" => { if let Some(id) = id() { - let mut routing = guard(&state.routing); - routing.awaiting_replay.remove(id); - state - .suppressed - .store(routing.awaiting_replay.len(), Ordering::Relaxed); + let queue = { + let mut routing = guard(&state.routing); + let queue = routing.lift_suppression(id); + state + .suppressed + .store(routing.awaiting_replay.len(), Ordering::Relaxed); + queue + }; + // Behind the replay, to the window that just received it: the + // events describe bytes the replay carried. + if let Some(label) = delivered { + for (held_event, held_data) in queue { + let _ = app.emit_to(label, held_event.as_str(), &held_data); + } + } } } "dor:controlRequest" => { @@ -4334,12 +4382,56 @@ mod tests { state.reassign(&["pane-a".to_string()], "ws-2", true); assert_eq!(state.suppressed.load(Ordering::Relaxed), 1); + super::routing::hold_event( + &mut guard(&state.routing).held, + "pane-a", + "terminal:protocolEvents", + serde_json::json!({"n": 1}), + ); + state.mint("pane-a", "main"); assert!(guard(&state.routing).awaiting_replay.is_empty()); + // Nothing held for the PTY that never arrived survives under its id. + assert!(guard(&state.routing).held.is_empty()); assert_eq!(state.suppressed.load(Ordering::Relaxed), 0); assert_eq!(state.owned_by("main"), vec!["pane-a".to_string()]); } + /// Every way out of a suppression takes the held queue with it: a queue + /// left behind would be flushed ahead of the *next* transfer's own gap. + #[test] + fn every_lift_of_a_suppression_takes_its_held_queue() { + let state = super::WindowState::default(); + let ids = ["pane-a".to_string()]; + let queue_up = || { + state.reassign(&ids, "ws-2", true); + super::routing::hold_event( + &mut guard(&state.routing).held, + "pane-a", + "terminal:protocolEvents", + serde_json::json!({"n": 1}), + ); + assert_eq!(state.suppressed.load(Ordering::Relaxed), 1); + }; + let lifted = || { + let routing = guard(&state.routing); + routing.awaiting_replay.is_empty() && routing.held.is_empty() + }; + + queue_up(); + state.clear_suppression(&ids); + assert!(lifted()); + assert_eq!(state.suppressed.load(Ordering::Relaxed), 0); + + queue_up(); + state.reassign(&ids, "main", false); + assert!(lifted()); + + queue_up(); + state.forget_pty("pane-a"); + assert!(lifted()); + } + #[test] fn sweep_orphan_session_temps_removes_only_temps() { let dir = TempDir::new("sessions-sweep"); diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index 77ea20150..6b4165e24 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -38,10 +38,15 @@ pub enum Route<'a> { /// (the argument `docs/specs/vscode.md` -> "Peer surfaces across windows" /// makes for its own fan-out). Broadcast, - /// Nothing is delivered: the id is mid-transfer and its bytes are already in - /// the replay the new owner is about to receive, or no window owns it at all - /// and every window would otherwise ring for a pane none of them shows. + /// Nothing is delivered: the id is mid-transfer and what this carries is + /// already in the replay the new owner is about to receive (its bytes, or + /// the semantic events the owner re-derives from them), or no window owns it + /// at all and every window would otherwise ring for a pane none of them shows. Drop, + /// Kept for the id's next owner: the id is mid-transfer and this event is + /// in no replay and re-derived from none. The caller queues it and delivers + /// the queue, in order, right after the replay that lifts the suppression. + Hold, /// A `dor` request naming no Surface belongs to whichever window the user /// is looking at. Resolved by the caller, which alone holds the focus order. Focused, @@ -93,7 +98,7 @@ fn owner<'a>(map: &'a HashMap, id: &str) -> Route<'a> { pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Route<'a> { match event { // Terminal traffic, keyed by the PTY it came from. - "pty:data" | "terminal:semanticEvents" | "terminal:protocolEvents" => { + "pty:data" => { let Some(id) = str_field(data, "id") else { return Route::Broadcast; }; @@ -102,6 +107,29 @@ pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Rout } owner(view.owners, id) } + // The replay is the raw bytes, OSCs included, and the target's replay + // path re-derives these from it — so a held copy would apply on top of + // what the replay rebuilt, and `commandStart` is not idempotent. + "terminal:semanticEvents" => { + let Some(id) = str_field(data, "id") else { + return Route::Broadcast; + }; + if view.awaiting_replay.contains_key(id) { + return Route::Drop; + } + owner(view.owners, id) + } + // Derived once at the sidecar's parse site and rebuilt by no replay + // path, so a chunk's protocol events outlive the chunk's drop. + "terminal:protocolEvents" => { + let Some(id) = str_field(data, "id") else { + return Route::Broadcast; + }; + if view.awaiting_replay.contains_key(id) { + return Route::Hold; + } + owner(view.owners, id) + } // Never suppressed: a replay is exactly what the suppression is waiting // for, and the caller lifts the suppression after this emit. "pty:exit" | "pty:replay" => match str_field(data, "id") { @@ -308,6 +336,43 @@ pub fn sweep_awaiting( stale } +/// One event held for an id mid-transfer, in arrival order. +pub type HeldEvent = (String, JsonValue); + +/// Queue an event for an id whose suppression is up. Bounded per id: a +/// transfer lasts seconds, and an id that outruns the bound is one whose +/// arrival is wedged, which the arrival watchdog hands back anyway. Past the +/// bound the oldest goes, so a long gap delivers its suffix. +pub fn hold_event( + held: &mut HashMap>, + id: &str, + event: &str, + data: JsonValue, +) { + let queue = held.entry(id.to_string()).or_default(); + if queue.len() >= HELD_EVENTS_MAX { + queue.remove(0); + } + queue.push((event.to_string(), data)); +} + +/// Cap on events held per id. +pub const HELD_EVENTS_MAX: usize = 256; + +/// Lift `id`'s transfer suppression. The suppression and what was held under +/// it go together — one site clearing the map and leaving the queue would +/// deliver a stale gap ahead of the next transfer's own — so this is the one +/// way out of both, and the caller decides whether the queue is delivered +/// (behind the replay) or discarded (a hand-back, a reuse, an exit). +pub fn lift_suppression( + awaiting_replay: &mut HashMap, + held: &mut HashMap>, + id: &str, +) -> Vec { + awaiting_replay.remove(id); + held.remove(id).unwrap_or_default() +} + /// The next `ws-`, above every label given — live windows and saved /// snapshots alike, so a torn-out window can never claim a saved window's file. pub fn seed_next_ws(labels: impl IntoIterator>) -> u64 { @@ -553,6 +618,34 @@ mod tests { ); } + #[test] + fn held_events_come_back_in_order_and_bounded() { + let mut awaiting = awaiting(&["a", "b", "c"]); + let mut held = HashMap::new(); + hold_event(&mut held, "a", "terminal:protocolEvents", json!({"n":1})); + hold_event(&mut held, "a", "terminal:protocolEvents", json!({"n":2})); + hold_event(&mut held, "b", "terminal:protocolEvents", json!({"n":3})); + assert_eq!( + lift_suppression(&mut awaiting, &mut held, "a"), + vec![ + ("terminal:protocolEvents".to_string(), json!({"n":1})), + ("terminal:protocolEvents".to_string(), json!({"n":2})), + ] + ); + // Both halves went together, and nothing of "b" went with them. + assert!(!awaiting.contains_key("a")); + assert!(awaiting.contains_key("b")); + assert!(lift_suppression(&mut awaiting, &mut held, "a").is_empty()); + assert_eq!(held.len(), 1); + + for n in 0..(HELD_EVENTS_MAX + 5) { + hold_event(&mut held, "c", "terminal:protocolEvents", json!({"n":n})); + } + let queue = lift_suppression(&mut awaiting, &mut held, "c"); + assert_eq!(queue.len(), HELD_EVENTS_MAX); + assert_eq!(queue[0].1, json!({"n":5})); + } + #[test] fn a_transferring_pty_is_suppressed_until_its_replay() { let owned = labels(&[("a", "ws-2")]); @@ -565,6 +658,17 @@ mod tests { dor_targets: &no_dor, }; assert_eq!(route("pty:data", &json!({"id":"a"}), &suppressed), Route::Drop); + // The target re-derives semantic events from the raw replay, so a held + // copy would apply twice: dropped with the bytes they describe. + assert_eq!( + route("terminal:semanticEvents", &json!({"id":"a"}), &suppressed), + Route::Drop + ); + // Protocol events are rebuilt by no replay path: held, not dropped. + assert_eq!( + route("terminal:protocolEvents", &json!({"id":"a"}), &suppressed), + Route::Hold + ); // The replay itself is never suppressed — it is what is being waited for. assert_eq!( route("pty:replay", &json!({"id":"a"}), &suppressed), diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index c29d064cd..ec2b75a0d 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { PlatformAdapter, PtyDataDetail } from "dormouse-lib/lib/platform/types"; +import type { AlertStateDetail, PlatformAdapter, PtyDataDetail } from "dormouse-lib/lib/platform/types"; +import { getTerminalPaneState } from "dormouse-lib/lib/terminal-state-store"; // Stub the Tauri modules so `./tauri-adapter` imports and constructs outside a // Tauri webview — same reason as tauri-adapter.test.ts. Nothing here exercises @@ -129,6 +130,20 @@ describe("BrowserSidecarAdapter terminal stream", () => { expect(send.mock.calls.filter(([cmd]) => cmd === "pty_write")).toEqual([]); }); + // Same contract as TauriAdapter: the replay is all a transferred pane's new + // window sees, so it rebuilds the AlertManager's half too. + it("rebuilds alert state from a replay, not only pane state", async () => { + const { adapter, deliver } = await listening(); + const alerts: AlertStateDetail[] = []; + adapter.onAlertState((detail) => void alerts.push(detail)); + adapter.alertSetWatchedCommands(["sleep"]); + + deliver("pty:replay", { id: "replay-b", data: "\x1b]633;E;sleep 5\x07\x1b]633;C\x07" }); + + expect(getTerminalPaneState("replay-b").currentCommand?.rawCommandLine).toBe("sleep 5"); + expect(alerts.some((detail) => detail.id === "replay-b" && detail.watchingEnabled)).toBe(true); + }); + it("pushes the resolved theme so the sidecar can answer a colour query", async () => { const { adapter, send } = await listening(); adapter.requestInit(); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index e0931208f..6cd583bcb 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -371,7 +371,9 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // why the one-shot parser still needs the theme. const { id, data: text, requestId } = data as PtyReplayDetail; const parsed = new TerminalProtocolParser(themeColorProvider).process(text); - applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events)); + const events = collectTerminalSemanticEvents(parsed.events); + this.alertManager.applyTerminalSemanticEvents(id, events); + applyTerminalSemanticEvents(id, events); for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData, requestId }); } else if (event === BURROW_RESULT_EVENT) { this.burrowClient.onResult(data as BurrowResult); diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index 922dd985a..9f54d0b2e 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -155,9 +155,9 @@ async function bootstrap() { // store wholesale (`docs/specs/standalone.md` → "Arrival queue"). armWorkspaceMoves?.(); - // Only `main` runs the periodic check, and only `main` holds `updater:*` - // (`capabilities/main-only.json`), so a session whose `main` was closed has - // no update to install until it relaunches (docs/specs/auto-update.md). + // Only `main` runs the periodic check and holds the updater capability, so a + // session whose `main` was closed has no update until it relaunches + // (docs/specs/auto-update.md). if (isMainWindow()) startUpdateCheck(); createRoot(document.getElementById("root")!).render( diff --git a/standalone/src/quit.ts b/standalone/src/quit.ts index 3233685d8..570052067 100644 --- a/standalone/src/quit.ts +++ b/standalone/src/quit.ts @@ -135,11 +135,11 @@ async function runQuitTeardown(last: boolean): Promise { `[quit] teardown exceeded ${QUIT_TEARDOWN_CEILING_MS}ms; proceeding to exit`, ); } - // Install strictly after the completed final save, and only in `main` — - // the window the walk tears down last, and the only one holding `updater:*` - // (`capabilities/main-only.json`; docs/specs/auto-update.md). A fresh - // `quit_progress` gives install its own watchdog budget instead of the - // teardown remainder. + // Install strictly after the completed final save, in the window the walk + // tears down last. Only `main` ever holds a pending download and the + // updater capability (docs/specs/auto-update.md), so this is `main` or a + // no-op. A fresh `quit_progress` gives install its own watchdog budget + // instead of the teardown remainder. if (last && hasPendingUpdate()) { void invoke("quit_progress").catch(() => {}); // install phase begins await installPendingUpdate(); diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 0c0d9cf1e..ef39108c7 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -453,6 +453,25 @@ describe("TauriAdapter terminal stream", () => { expect(alerts.some((detail) => detail.id === "sem-pty")).toBe(true); }); + // A transferred pane's new window sees nothing but the replay: Rust drops the + // gap's semantic events because this path re-derives them, so it must rebuild + // both halves — pane state and the AlertManager's watch. + it("rebuilds alert state from a replay, not only pane state", async () => { + const { adapter, deliver } = await listening(); + const alerts: AlertStateDetail[] = []; + adapter.onAlertState((detail) => void alerts.push(detail)); + // The rule set is the sidecar's; this window hears it as a broadcast. + deliver("alert:watchedCommands", { names: ["sleep"] }); + + deliver("pty:replay", { + id: "replay-pty", + data: "\x1b]633;E;sleep 5\x07\x1b]633;C\x07", + }); + + expect(getTerminalPaneState("replay-pty").currentCommand?.rawCommandLine).toBe("sleep 5"); + expect(alerts.some((detail) => detail.id === "replay-pty" && detail.watchingEnabled)).toBe(true); + }); + it("pushes the resolved theme so the sidecar can answer a colour query", async () => { const { adapter, invoke } = await listening(); adapter.requestInit(); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 1ecaedf85..4ec1d04db 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -187,9 +187,15 @@ export class TauriAdapter implements PlatformAdapter { // the asker is long gone (docs/specs/terminal-escapes.md). It still // needs the theme: a *declined* colour query is not consumed, so it // reaches xterm.js instead, and answering is the owner's alone. + // Both consumers of the live `terminal:semanticEvents` path, because a + // replay is the whole of what a transferred pane's new window has: Rust + // drops the gap's semantic events rather than holding them + // (docs/specs/standalone.md → "Routing"). const { id, data, requestId } = event.payload; const parsed = new TerminalProtocolParser(themeColorProvider).process(data); - applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events)); + const events = collectTerminalSemanticEvents(parsed.events); + this.alertManager.applyTerminalSemanticEvents(id, events); + applyTerminalSemanticEvents(id, events); for (const handler of this.replayHandlers) { handler({ id, data: parsed.visibleData, requestId }); } diff --git a/standalone/src/window-label.ts b/standalone/src/window-label.ts index facbda0c3..2ef15afb7 100644 --- a/standalone/src/window-label.ts +++ b/standalone/src/window-label.ts @@ -31,9 +31,7 @@ export function currentWindowLabel(): string { } /** The window the quit walk tears down last while it is open, and the only one - * that runs the periodic update check or holds `updater:*` - * (`capabilities/main-only.json`). With `main` closed the walk still ends on - * some window, which has nothing to install (docs/specs/auto-update.md). */ + * that checks for, downloads, and installs updates (`capabilities/main-only.json`). */ export function isMainWindow(): boolean { return label === MAIN_WINDOW_LABEL; } diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index 92f169edf..5e4386781 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -338,6 +338,18 @@ describe("the target half", () => { expect(getNotes("pane-a").map((note) => note.content)).toEqual([{ kind: "plain", text: "keep me" }]); }); + it("seeds the persisted alert before asking for the replay that rebuilds WATCHING", async () => { + const order: string[] = []; + const platform = fakePlatform(order); + vi.mocked(platform.alertSeed!).mockImplementation(() => { order.push("seed"); }); + arrivals = [payload()]; + arrivals[0].workspace.session.panes[0].alert = { status: "WATCHING_DISABLED", todo: true, notification: null }; + initWorkspaceMoves(platform); + await settle(); + expect(order.indexOf("seed")).toBeGreaterThan(-1); + expect(order.indexOf("seed")).toBeLessThan(order.indexOf("adopt_ready")); + }); + it("resumes each of two simultaneous arrivals over its own PTYs", async () => { // A tear-out with a second tab dropped on it moments later. A window-wide // answer would let each collector finish on the other's shells. diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 7516f3b6a..5def36c54 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -195,6 +195,10 @@ async function planArrival( platform: PlatformAdapter, payload: MovePayload, ): Promise { + // Seed the older persisted state before replay re-derives a running watch. + for (const pane of payload.workspace.session.panes) { + if (pane.alert) platform.alertSeed?.(pane.id, pane.alert); + } const ptyIds = new Set(payload.terminalIds); const live = await collectLivePtys(platform, { // The token rides through Rust to the sidecar's `list` and comes back on the @@ -218,11 +222,6 @@ async function planArrival( // The notes travelled in the payload rather than through the archive: a move // is not a closure (`docs/specs/notepad.md` → "Closure"). hydrateNotepadFromVolatile(payload.notepad, payload.allIds); - // The AlertManager is per webview, so a persisted TODO has to be seeded into - // this one — the source's went with its window. - for (const pane of payload.workspace.session.panes) { - if (pane.alert) platform.alertSeed?.(pane.id, pane.alert); - } return wallBootFromResult(result); }