From 23475d0a5c08befeb8b89a02741355f040f380da Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sun, 12 Jul 2026 18:23:38 +0300 Subject: [PATCH] feat(capture): OS notifications on critical live failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last S5 issue. When live detection raises a critical failure, fire an OS notification so an operator watching a session gets pinged without staring at the timeline (macOS-first, ADR-0002). Reaching the notification service (spike, ADR-0011): the effects channel (fx) exposes no notification verb, and PlatformServices is built inside the runner, never handed to main. But Effects binds the platform services as a public field (`fx.services`), set on the loop thread before dispatch — so update_fx reaches `fx.services.?.showNotification(...)` directly, on the loop thread where services are meant to be called. Guarded, so a null- services build (tests / null platform) is a silent no-op. Decision logic is pure and headless-tested (workspace.zig): LiveCapture tracks the critical codes already notified this session (std.EnumSet), enqueues one notification per new code (title/body copied into fixed buffers, deduped per code, reset on start); update_fx fires and clears the queue each turn. Split pendingNotifications (const) / clearNotifications (void) so the value-returning accessor stays const — the runtime reflects model accessors and requires it. Notify on explicit faults only: connector_fault (a "Faulted" status) and diagnostics_failure (a failed upload). The absence-based criticals — unresponsive_csms (an in-flight Call) and station_offline_during_session (an unclosed transaction) — are the normal mid-session state of a growing live prefix and would ping on nearly every session before resolving, so they stay in the failure panel but don't notify. Declares the `notifications` permission (app.zon + the runtime permission set); no notification is delivered without it. Tests: a critical fault queues one notification (deduped per code); non-critical severities don't notify; prefix-transient criticals don't notify though they show as failures; a new session resets the dedup. 165/165 green; ReleaseFast build clean; app.zon valid. Closes #60. --- CURRENT_STATE.md | 33 ++-- app.zon | 2 +- docs/adr/0011-live-notification-services.md | 85 ++++++++++ docs/adr/README.md | 1 + src/main.zig | 17 +- src/ui/workspace.zig | 170 ++++++++++++++++++++ 6 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 docs/adr/0011-live-notification-services.md diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 0898664..866a088 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -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 @@ -163,12 +164,12 @@ Every issue landed: ## What's next -**S5 — Live 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. +**S6 — public 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 @@ -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`) | diff --git a/app.zon b/app.zon index 23428c8..1e01763 100644 --- a/app.zon +++ b/app.zon @@ -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 = .{ diff --git a/docs/adr/0011-live-notification-services.md b/docs/adr/0011-live-notification-services.md new file mode 100644 index 0000000..068a930 --- /dev/null +++ b/docs/adr/0011-live-notification-services.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 56bbd5f..7996834 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/src/main.zig b/src/main.zig index 25ee049..95ffd5d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 }, }; @@ -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) { @@ -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 { diff --git a/src/ui/workspace.zig b/src/ui/workspace.zig index 4caded6..3e63b04 100644 --- a/src/ui/workspace.zig +++ b/src/ui/workspace.zig @@ -60,6 +60,28 @@ pub const default_upstream = "ws://127.0.0.1:8080/ocpp"; /// is active (`Model.mutTrace`). pub const Surface = enum { traces, live }; +/// Max OS notifications queued at once — bounded by the distinct critical +/// failure codes, since dedup emits at most one per code per session. +pub const max_pending_notifications: usize = 8; + +/// A queued OS notification for a critical live failure. Title/body are copied +/// into fixed buffers so a queued notification never dangles into the live +/// trace's arena (rebuilt on every reload). `update_fx` drains and fires these +/// through the platform notification service (ADR-0011). +pub const Notification = struct { + title_buf: [96]u8 = undefined, + title_len: usize = 0, + body_buf: [256]u8 = undefined, + body_len: usize = 0, + + pub fn title(self: *const Notification) []const u8 { + return self.title_buf[0..self.title_len]; + } + pub fn body(self: *const Notification) []const u8 { + return self.body_buf[0..self.body_len]; + } +}; + /// Timeline search + filter state. Facets AND-compose; an inactive filter shows /// the whole timeline. Lives on the `Model` (never memcpy'd) and stores the /// search query as a fixed buffer + length + caret — no stored slice pointer — @@ -234,6 +256,12 @@ pub const LiveCapture = struct { trace: LoadedTrace = .{}, exit_code: i32 = 0, exit_reason: ?native_sdk.EffectExitReason = null, + /// Critical failure codes already notified this session — dedup, so one + /// condition raises at most one OS notification per capture. + notified: std.EnumSet(types.FailureCode) = std.EnumSet(types.FailureCode).initEmpty(), + /// Notifications queued for `update_fx` to fire (drained each turn). + pending_buf: [max_pending_notifications]Notification = undefined, + pending_len: usize = 0, pub fn listen(self: *const LiveCapture) []const u8 { return self.listen_buf[0..self.listen_len]; @@ -454,6 +482,8 @@ pub const Model = struct { self.live.key +%= 1; if (self.live.key == 0) self.live.key = 1; self.live.exit_reason = null; + self.live.notified = std.EnumSet(types.FailureCode).initEmpty(); + self.live.pending_len = 0; self.live.status = .capturing; } @@ -471,6 +501,62 @@ pub const Model = struct { self.live.ndjson.append(self.backing, '\n') catch return; self.trimLiveBuffer(); self.reloadLiveTrace(); + self.queueCriticalNotifications(); + } + + /// Scan the freshly reloaded live trace for `critical` failures whose code + /// has not yet notified this session, and enqueue one notification per new + /// code (deduped via `notified`, bounded by the queue). `update_fx` fires + /// them; see ADR-0011. + fn queueCriticalNotifications(self: *Model) void { + for (self.live.trace.failures) |f| { + if (f.severity != .critical) continue; + if (!isLiveNotifiable(f.code)) continue; + if (self.live.notified.contains(f.code)) continue; + self.live.notified.insert(f.code); + self.enqueueNotification(f); + } + } + + fn enqueueNotification(self: *Model, f: types.Failure) void { + if (self.live.pending_len >= max_pending_notifications) return; + var n = Notification{}; + const title = std.fmt.bufPrint(&n.title_buf, "Critical: {s}", .{f.code.toWire()}) catch n.title_buf[0..0]; + n.title_len = title.len; + const bcap = @min(f.description.len, n.body_buf.len); + @memcpy(n.body_buf[0..bcap], f.description[0..bcap]); + n.body_len = bcap; + self.live.pending_buf[self.live.pending_len] = n; + self.live.pending_len += 1; + } + + /// Which critical failures warrant a live OS notification: those signalling + /// an EXPLICIT fault the station reported — a "Faulted" connector + /// (`connector_fault`), a failed diagnostics upload (`diagnostics_failure`). + /// Absence-based criticals are deliberately excluded: a live stream is a + /// growing prefix, so an in-flight Call (`unresponsive_csms`) and an + /// as-yet-unclosed transaction (`station_offline_during_session`) are the + /// normal mid-session state and would ping on nearly every session before + /// resolving. They still appear in the live failure panel — they just don't + /// raise an OS notification. See ADR-0011. + fn isLiveNotifiable(code: types.FailureCode) bool { + return switch (code) { + .connector_fault, .diagnostics_failure => true, + else => false, + }; + } + + /// The notifications queued since the last clear (read-only). `update_fx` + /// fires these and then calls `clearNotifications`. Split read/clear rather + /// than one draining call so the accessor stays `*const Model` — the runtime + /// reflects value-returning model accessors and requires them const. + pub fn pendingNotifications(self: *const Model) []const Notification { + return self.live.pending_buf[0..self.live.pending_len]; + } + + /// Clear the notification queue (after `update_fx` has fired the pending set). + pub fn clearNotifications(self: *Model) void { + self.live.pending_len = 0; } /// The capture worker exited: its reason decides the terminal status. @@ -1018,3 +1104,87 @@ test "endpoint fields edit through the input path and freeze while capturing" { try testing.expectEqualStrings("0.0.0.0:7000", model.live.listen()); try testing.expectEqualStrings(default_upstream, model.live.upstream()); } + +// --- live notifications (#60) ---------------------------------------------- + +/// A StartTransaction opening a session, then a "Faulted" StatusNotification +/// during it — the minimal stream that raises CONNECTOR_FAULT (critical). +const fault_start = "{\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m1\",\"StartTransaction\",{\"connectorId\":1,\"idTag\":\"T\",\"meterStart\":0,\"timestamp\":\"2024-01-01T00:00:00Z\"}]}"; +const fault_status = "{\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m2\",\"StatusNotification\",{\"connectorId\":1,\"status\":\"Faulted\",\"errorCode\":\"OtherError\"}]}"; + +fn streamCapture(model: *Model, line: []const u8) void { + update(model, .{ .capture_line = .{ .key = model.live.key, .line = line } }); +} + +test "a critical live failure queues one notification, deduplicated per code" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .start_capture); + + // The StartTransaction alone raises only absence-based criticals (an open + // transaction, an unanswered Call) — not notifiable, so nothing is queued. + streamCapture(&model, fault_start); + try testing.expectEqual(@as(usize, 0), model.live.pending_len); + + // The Faulted StatusNotification raises CONNECTOR_FAULT (critical) → one + // notification, titled with the code and carrying the description. + streamCapture(&model, fault_status); + const pending = model.pendingNotifications(); + try testing.expectEqual(@as(usize, 1), pending.len); + try testing.expect(std.mem.indexOf(u8, pending[0].title(), "CONNECTOR_FAULT") != null); + try testing.expect(pending[0].body().len > 0); + + // Clearing empties the queue (what `update_fx` does after firing). + model.clearNotifications(); + try testing.expectEqual(@as(usize, 0), model.pendingNotifications().len); + + // A further line still re-detects the same fault, but it does not re-notify. + streamCapture(&model, "{\"direction\":\"CSMS_TO_CS\",\"message\":[3,\"m2\",{}]}"); + try testing.expect(model.live.trace.failures.len > 0); + try testing.expectEqual(@as(usize, 0), model.pendingNotifications().len); +} + +test "non-critical live failures do not notify" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .start_capture); + + // FAILED_AUTHORIZATION is a warning, not critical. + streamCapture(&model, "{\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m1\",\"Authorize\",{\"idTag\":\"BAD\"}]}"); + streamCapture(&model, "{\"direction\":\"CSMS_TO_CS\",\"message\":[3,\"m1\",{\"idTagInfo\":{\"status\":\"Invalid\"}}]}"); + try testing.expect(model.live.trace.failures.len > 0); // a (warning) failure was detected + try testing.expectEqual(@as(usize, 0), model.pendingNotifications().len); // but none notified +} + +test "prefix-transient criticals do not notify, though they show as failures" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .start_capture); + + // An open transaction (StartTransaction, no StopTransaction) trips + // STATION_OFFLINE_DURING_SESSION, and the unanswered StartTransaction Call + // trips UNRESPONSIVE_CSMS — both critical, both inferred from a not-yet- + // arrived message, so both are the normal mid-session state of a live stream. + streamCapture(&model, fault_start); + try testing.expect(model.live.trace.failuresOf(.critical) > 0); // detected… + try testing.expectEqual(@as(usize, 0), model.pendingNotifications().len); // …but not notified +} + +test "starting a new capture resets the notification dedup" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + update(&model, .start_capture); + streamCapture(&model, fault_start); + streamCapture(&model, fault_status); + try testing.expectEqual(@as(usize, 1), model.pendingNotifications().len); + + // Stop, then a fresh capture clears the dedup set — the same fault notifies + // again in the new session. + update(&model, .stop_capture); + update(&model, .start_capture); + try testing.expectEqual(@as(usize, 0), model.live.pending_len); + streamCapture(&model, fault_start); + streamCapture(&model, fault_status); + try testing.expectEqual(@as(usize, 1), model.pendingNotifications().len); +}