Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 80 additions & 0 deletions docs/adr/0009-live-capture-effects-channel.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Loading