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
33 changes: 17 additions & 16 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@

## Active milestone

**S5 — Live capture (0.4.0): in progress.** S0–S4 are done — analysis parity, a
headless CLI, and the native inspector. S5's flagship — a live WebSocket MITM
proxy between a charge point and its CSMS — works end to end from the terminal
(WS codec #54, decode #55, proxy #56, the `studio capture` CLI #57) and now in
the GUI: a live-capture surface (#59) streams the proxy's events into the
inspector through the effects channel (#58, ADR-0009) — `update_fx` + `fx.spawn`,
**in-runner**, not a runner-eject. Only OS notifications on critical live
failures (#60) remain; see [ROADMAP.md](ROADMAP.md).
**S5 — Live capture (0.4.0): complete.** S0–S5 are done. S5's flagship — a live
WebSocket MITM proxy between a charge point and its CSMS — works end to end from
the terminal (WS codec #54, decode #55, proxy #56, the `studio capture` CLI #57)
and in the GUI: a live-capture surface (#59) streams the proxy's events into the
inspector through the effects channel (#58, ADR-0009), and explicit-fault
criticals raise OS notifications live (#60, ADR-0011) — all **in-runner**
(`update_fx` + `fx.spawn` + the effects-bound platform services), no runner-eject.

Next is **S6 — public release & launch (0.5.0)**; see [ROADMAP.md](ROADMAP.md).

## What's done

Expand Down Expand Up @@ -163,12 +164,12 @@ Every issue landed:

## What's next

**S5Live capture ⭐ (0.4.0)** — one issue remains: OS notifications on
critical live failures (#60). The flagship is otherwise landed — the WS proxy,
the `studio capture` CLI, and the live inspector surface (#54–#59): a live
WebSocket proxy between a charge point and its CSMS, decoding OCPP frames in
flight, running detection as events stream, recording to the canonical trace
format, and surfacing it in a live timeline.
**S6public release & launch (0.5.0)** — package the app (signed/notarized
macOS + a Linux package via `native package`), freeze `contract-v1`, and polish
docs for a first public release. Studio stays pre-1.0 while Zig, the Native SDK,
and the toolkit conformance reference are all pre-1.0 (see the ROADMAP versioning
note). The S5 flagship — the live WS proxy, the `studio capture` CLI, and the
live inspector surface with notifications (#54–#60) — is landed.

## Known blockers / decisions pending

Expand All @@ -181,7 +182,7 @@ format, and surfacing it in a live timeline.
| `repo` (tooling, CI) | ✅ done for S0 |
| `docs` (docs, ADRs) | ✅ done for S0 |
| `ocpp` (engine) | ✅ S2 + ingestion (#29) + reports (#41) + anonymize (#42) + diff (#43) + replay core (#44); O(n) detection pending (#36) |
| `ui` (native views) | ✅ S3 inspector (#27–#32) + replay transport (#44) + live-capture view (#59) |
| `capture` (live proxy) | ✅ S5: WS transport (#54) + frame decode (#55) + MITM proxy (#56) |
| `ui` (native views) | ✅ S3 inspector (#27–#32) + replay transport (#44) + live-capture view (#59) + live notifications (#60) |
| `capture` (live proxy) | ✅ S5: WS transport (#54) + frame decode (#55) + MITM proxy (#56) + live notifications (#60) |
| `cli` (headless) | ✅ inspect/report/diff/anonymize/ci/scenario (#45) + live `capture` (#57) |
| `conformance` | ✅ done for S2 (15/15, `contract-v1`) |
2 changes: 1 addition & 1 deletion app.zon
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
.version = "0.0.0",
.icons = .{"assets/icon.png"},
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.permissions = .{ "view", "command", "notifications" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
Expand Down
85 changes: 85 additions & 0 deletions docs/adr/0011-live-notification-services.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# ADR-0011 — Live-capture notifications via the effects-bound platform services

- **Status:** Accepted
- **Date:** 2026-07-12

## Context

The live notifier (#60) must fire an OS notification when detection raises a
`critical` failure during a live capture (macOS-first, ADR-0002). ADR-0009
recorded the intent — "on a `critical` live failure, call
`services.showNotification`; add `notifications` to `app.zon` permissions" — but
did **not** identify how the notification actually reaches the platform from the
zero-config update loop. This ADR settles that seam.

The obstacle: the effects-capable update is `update_fx(*Model, Msg, *Effects)`.
`Effects` (`runtime/effects.zig`) exposes `spawn` / `fetch` / `writeFile` /
`readFile` / timers / clipboard / window verbs — but **no notification method**.
And `PlatformServices` (which *does* own `showNotification`) is constructed
inside the runner (`app_runner/root.zig` → `runNull` / `runMacos` / …) and never
handed back to `main`. So "just call `services.showNotification`" has no obvious
caller in a zero-config app.

## Finding

`Effects` **binds the platform services as a field**:
`services: ?*const platform.PlatformServices`, set once from the loop thread
before the first dispatch (`runtime/ui_app.zig` binds it; the field's own doc
notes workers reach `services.wake()` through it). Zig has no private fields, so
`update_fx` can read `fx.services` and call
`fx.services.?.showNotification(NotificationOptions{ title, subtitle, body })`.

- **Thread-safe here.** Dispatch (and therefore `update_fx`) runs on the loop
thread — where platform services are meant to be called. (`wake()` is the one
entry documented as callable from a worker; calling `showNotification` from the
*loop thread* is the ordinary case, not the worker case.)
- **Degrades cleanly.** `PlatformServices.showNotification` returns
`error.UnsupportedService` when the platform provides no notifier. macOS,
Linux, Windows, and the null platform all provide one; a guarded call swallows
the error so a notifier-less build is a silent no-op, never a crash.

## Decision

**Fire in-runner through `fx.services`, with the decision logic kept pure.**

- **Decision logic in the model (pure, headless-tested).** `LiveCapture` tracks
which `critical` failure codes it has already notified this session
(`std.EnumSet(FailureCode)`). After each live reload, every *new* notifiable
critical code enqueues one `Notification` (title + body copied into fixed
buffers — no dependence on the live arena's lifetime). Deduplicated per code per
session; reset on start. This is what the acceptance test drives — no platform
involved.
- **Notify on explicit faults only, not prefix-transient criticals.** A live
session is detected on a *growing prefix*, and two of the critical rules are
inferred from a message that has not arrived *yet*: `unresponsive_csms` (a Call
awaiting its CallResult) and `station_offline_during_session` (a
StartTransaction with no StopTransaction). Both are the normal mid-session
state — every in-flight request and every open transaction trips them — so
notifying on them would ping on nearly every session and resolve moments later.
The notifier fires only on criticals that signal a fault the station
*explicitly reported*: `connector_fault` (a "Faulted" status) and
`diagnostics_failure` (a failed upload). The absence-based criticals still
appear in the live failure panel; they just don't raise an OS notification
(`Model.isLiveNotifiable`).
- **Firing in `update_fx` (thin).** After `workspace.update`, drain the queue and
fire each via `fx.services.?.showNotification(...)`, guarded by
`if (fx.services) |svc|` so tests and the null platform are a no-op.
- **Permission.** Declare `notifications`
(`native_sdk.security.permission_notifications`) in the app's runtime
permission set and in `app.zon`. No notification is delivered without it (the
platform gates on the declared permission plus the OS's own prompt).

## Consequences

- Real OS notifications on critical live failures land **in-runner** — the #33
runner-eject is not needed. This closes the last S5 issue.
- The split is clean and testable: the **model decides** (pure `EnumSet` dedup,
covered by headless tests — one notification per critical code, duplicates
suppressed, non-critical ignored), and **`update_fx` fires** (one guarded
call). The platform never enters a unit test.
- Reaching `fx.services` uses a **public-but-internal field**, not a dedicated
effects verb. If a future SDK adds `fx.showNotification` (or a services
accessor), swap the single call site in `updateFx`. The coupling is isolated
and documented here so it stays legible.
- This ADR makes concrete — and thereby supersedes — the notification half of
ADR-0009's decision, which named the API but not the seam.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ supersedes the old one rather than editing history.
| [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 |
| [0011](0011-live-notification-services.md) | Live-capture notifications via the effects-bound platform services | Accepted |
17 changes: 15 additions & 2 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const window_height: f32 = 800;
const window_min_width: f32 = 900;
const window_min_height: f32 = 560;

const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view, native_sdk.security.permission_notifications };
const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Studio canvas", .accessibility_label = "OCPP DebugKit Studio", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
Expand Down Expand Up @@ -61,7 +61,8 @@ const max_trace_file_bytes: usize = 256 * 1024 * 1024;
/// The effects-capable update: runs the pure `workspace.update`, then issues the
/// live-capture effects — on start, spawn the capture worker (`studio capture …
/// --ndjson`), whose stdout NDJSON lines stream back as `capture_line` Msgs; on
/// stop, cancel it. See ADR-0009.
/// stop, cancel it (ADR-0009) — and fires any OS notifications the live detector
/// queued this turn (ADR-0011).
fn updateFx(model: *Model, msg: Msg, fx: *InspectorApp.Effects) void {
workspace.update(model, msg);
switch (msg) {
Expand All @@ -83,6 +84,18 @@ fn updateFx(model: *Model, msg: Msg, fx: *InspectorApp.Effects) void {
.stop_capture => fx.cancel(model.live.key),
else => {},
}
// A critical live failure queues an OS notification (workspace.zig decides;
// deduped per code per session). The effects channel exposes no notification
// verb, so reach the loop-thread-bound platform services directly (ADR-0011).
// Fire only when a notifier is present — a null-services build (tests / null
// platform) stays a silent no-op — then clear the queue regardless (bounding
// it) so a serviceless build can't accumulate.
if (fx.services) |services| {
for (model.pendingNotifications()) |n| {
services.showNotification(.{ .title = n.title(), .body = n.body() }) catch {};
}
}
model.clearNotifications();
}

pub fn main(init: std.process.Init) !void {
Expand Down
Loading
Loading