diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 179809f..ef8c2d7 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -9,9 +9,12 @@ ## Active milestone **S5 — Live capture (0.4.0): in progress.** S0–S4 are done — analysis parity, a -headless CLI, and the native inspector. S5 is the flagship: a live WebSocket -proxy between a charge point and its CSMS. First landing is the hardened -RFC 6455 transport codec (#54, ADR-0008); see [ROADMAP.md](ROADMAP.md). +headless CLI, and the native inspector. S5's flagship — a live WebSocket MITM +proxy between a charge point and its CSMS — now works end to end from the +terminal: WS codec (#54), decode (#55), proxy (#56), and the `studio capture` +CLI (#57). The effects-channel spike (#58, ADR-0009) confirmed the live GUI +(#59/#60) is feasible **in-runner** (via `update_fx` + `fx.spawn`), not a +runner-eject; see [ROADMAP.md](ROADMAP.md). ## What's done diff --git a/docs/adr/0009-live-capture-effects-channel.md b/docs/adr/0009-live-capture-effects-channel.md new file mode 100644 index 0000000..0057951 --- /dev/null +++ b/docs/adr/0009-live-capture-effects-channel.md @@ -0,0 +1,80 @@ +# ADR-0009 — Live-capture effects channel + +- **Status:** Accepted +- **Date:** 2026-07-12 + +## Context + +The live-capture GUI (#59) must append events to a timeline **as they stream**, +and the notifier (#60) must fire an OS notification when a critical failure is +detected live. Both need something the offline inspector never did: a way to move +data into the TEA loop from *outside* a user interaction — an async source +feeding `Msg`s. + +The S4 replay spike (#44) concluded the zero-config runner had "no timer/effect +scheduler" and deferred wall-clock replay to the runner-eject bucket (#33). That +conclusion examined the **plain `update`** path (`fn(*Model, Msg) void`) — which +Studio uses today — and it was **incomplete**. This spike re-examined the runtime +against its source (`runtime/ui_app.zig`, `runtime/effects.zig`, +`platform/types.zig`). + +## Finding + +**A full effects channel exists in the zero-config runtime — outcome (i).** +`UiApp` supports two update forms, exactly one per app +(`runtime/ui_app.zig` asserts `(update != null) != (update_fx != null)`): + +- `update: fn(*Model, Msg) void` — the pure form Studio uses now. +- **`update_fx: fn(*Model, Msg, *Effects) void`** — the effects-capable form. The + runtime calls it with a live `*Effects` (`update_fx(&model, msg, &self.effects)`). + +`Effects` (TEA's Cmd half, `runtime/effects.zig`) exposes: + +- **`fx.spawn(SpawnOptions)`** — run a subprocess and **stream its stdout back as + `Msg`s**. `SpawnOptions{ key: u64, argv, stdin?, output: .lines|.collect, + max_line_bytes, on_line: ?LineMsgFn, on_exit: ?ExitMsgFn }`. In `.lines` mode + (the default) each stdout line becomes an `on_line` Msg carrying an + `EffectLine`; an `EffectExit` Msg ends the stream. Bounded (argv count/bytes, + stdin, line length) and cancellable via **`fx.cancel(key)`** (exactly one + `on_exit`, no `on_line` after). Requests that can't run are reported through + `on_exit` with reason `.rejected` — `spawn` never fails from the caller's view. +- `fx.fetch`, `fx.writeFile`, `fx.readFile`, and an `EffectTimer` (a real timer + effect). + +OS notifications exist as a **platform service**: +`services.showNotification(NotificationOptions{ title, subtitle, body })`, gated +by the `notifications` permission (`security.permission_notifications`, declared +in `app.zon`). + +## Decision + +**#59 and #60 proceed in-runner — no runner eject.** + +- **#59 (live view):** switch the app to `update_fx`. On "start capture", + `fx.spawn` the app's own binary as `studio capture --listen … --upstream … + --ndjson` in `.lines` mode; each NDJSON line arrives as an `on_line` Msg, is + decoded (`capture.decode` / `parser`), and appended to the live timeline; + `fx.cancel(key)` stops it. This is the plan's "self-spawn worker" (winning idea + #2): crash-isolated (subprocess), cancellable, and it reuses the exact + `studio capture` path shipped in #57 — the CLI and GUI share one capture engine. +- **#60 (notifications):** on a `critical` live failure, call + `services.showNotification`; add `notifications` to `app.zon` permissions + (macOS-first per ADR-0002). + +## Consequences + +- The S5 GUI tier is **unblocked in-runner**; the #33 runner-eject is *not* needed + for live capture. Adopting `update_fx` is an ordinary `create`-options change, + not an eject. +- **Revisit the S4 deferral.** Because a timer effect (`EffectTimer`) and the + effects channel exist, wall-clock replay auto-play (#44, parked in #33) is + feasible in-runner too — worth reopening once the effects update lands. This ADR + supersedes the effects-related half of the #44 spike's conclusion (that there is + "no effect scheduler"); the platform-open-dialog / drag-drop parts of #33 are + untouched and still need investigation. +- **De-risking hand-off:** #59 should begin with a minimal `fx.spawn` smoke (spawn + a trivial command, assert the `on_line`/`on_exit` Msgs arrive) to confirm the + wiring before building the full view, and resolve the self-executable path + (argv[0] / `selfExePath`) for the spawn argv. +- Studio stays a single binary: the GUI drives the same `capture` subcommand a + user runs by hand. diff --git a/docs/adr/README.md b/docs/adr/README.md index 051e72d..56bbd5f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,4 +18,5 @@ supersedes the old one rather than editing history. | [0006](0006-inspector-builder-view.md) | Inspector as a Zig builder view (not `.native` markup) | Accepted | | [0007](0007-trusted-ingestion.md) | Trusted-ingestion limits for user-opened traces | Accepted | | [0008](0008-websocket-transport.md) | WebSocket transport: a hand-rolled RFC 6455 subset | Accepted | +| [0009](0009-live-capture-effects-channel.md) | Live-capture effects channel (`update_fx` + `fx.spawn`) | Accepted | | [0010](0010-live-proxy-transport-concurrency.md) | Live-proxy transport & concurrency | Accepted |