From 0af79965e3d4dab69dbbd124bbdbc300f45ecef0 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 22 Aug 2026 13:43:18 -0600 Subject: [PATCH] Adds the Livebook inspector assembly StatifierUI.Kino.inspect/3 composes the four merged panes - configuration diagram, datamodel explorer, event injection, event log - over one shared Trace.Subscriber via Kino.Layout. Every process it starts goes through Kino.start_child/1, so re-evaluating the cell terminates them and the session drops the dead subscriber: the clean detach. Compiled only when the optional :kino dependency is present (ADR-0004); otherwise a stub raises with instructions. Trace.Subscriber.attach/3 gains catch_up: true (statifier ADR-0049): the subscription and the recording snapshot happen in one session call, the replayed prefix is folded into the buffer before the live suffix, and an unrecorded session falls back to live delivery with a :not_recorded diagnostic the inspector surfaces as "Live-only" - a partial stream is never presented as whole. StatifierUI.Inspector is the pure pane-assembly fold behind the Kino shell (active configuration from the newest trace.macrostep_stable, pane markdown, the status header), testable without a Livebook runtime. notebooks/inspector.livemd drives a checkout chart end to end and doubles as the milestone's manual acceptance test. Refs: sui-t36.8 --- README.md | 4 +- changelog.d/sui-t36.8.md | 16 ++ ...822-sui-t36.8-inspector-widget-assembly.md | 84 ++++++ lib/statifier_ui/inspector.ex | 119 ++++++++ lib/statifier_ui/kino.ex | 259 ++++++++++++++++++ lib/statifier_ui/trace/subscriber.ex | 119 ++++++-- notebooks/inspector.livemd | 157 +++++++++++ test/statifier_ui/inspector_test.exs | 122 +++++++++ test/statifier_ui/kino_test.exs | 65 +++++ test/statifier_ui/trace/subscriber_test.exs | 76 +++++ test/support/trace/session_case.ex | 43 +++ 11 files changed, 1043 insertions(+), 21 deletions(-) create mode 100644 changelog.d/sui-t36.8.md create mode 100644 docs/plans/260822-sui-t36.8-inspector-widget-assembly.md create mode 100644 lib/statifier_ui/inspector.ex create mode 100644 lib/statifier_ui/kino.ex create mode 100644 notebooks/inspector.livemd create mode 100644 test/statifier_ui/inspector_test.exs create mode 100644 test/statifier_ui/kino_test.exs diff --git a/README.md b/README.md index f604758..46e4993 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ effects; the engine needs nothing changed to support it. ## Status Early. Nothing is published to hex yet, and the engine dependency is a git dep -until statifier publishes. The first milestone is the Livebook inspector. +until statifier publishes. The first milestone is the Livebook inspector: +`StatifierUI.Kino.inspect/3` over a running `Statifier.Session` - +[`notebooks/inspector.livemd`](notebooks/inspector.livemd) walks it end to end. ## Development diff --git a/changelog.d/sui-t36.8.md b/changelog.d/sui-t36.8.md new file mode 100644 index 0000000..db35b66 --- /dev/null +++ b/changelog.d/sui-t36.8.md @@ -0,0 +1,16 @@ +### Added + +- `StatifierUI.Kino.inspect/3` assembles the Livebook inspector: the + configuration diagram, datamodel explorer, event injection, and event + log panes composed over one shared subscriber, live-updating, detaching + cleanly on cell re-evaluation. Compiled only when the optional `:kino` + dependency is present. +- `StatifierUI.Trace.Subscriber.attach/3` accepts `catch_up: true`: on a + session started with `record: true` the missed prefix is replayed into + the buffer atomically with the subscription (statifier ADR-0049); an + unrecorded session falls back to live delivery with a `:not_recorded` + diagnostic the inspector surfaces as "Live-only". +- `StatifierUI.Inspector` - the pure pane-assembly fold the Kino shell + renders, usable by any other frontend. +- `notebooks/inspector.livemd` - the demo notebook, doubling as the + milestone's manual acceptance test. diff --git a/docs/plans/260822-sui-t36.8-inspector-widget-assembly.md b/docs/plans/260822-sui-t36.8-inspector-widget-assembly.md new file mode 100644 index 0000000..1263517 --- /dev/null +++ b/docs/plans/260822-sui-t36.8-inspector-widget-assembly.md @@ -0,0 +1,84 @@ +# sui-t36.8 - Inspector widget assembly and demo notebook + +- Date: 2026-08-22 +- Bead: `sui-t36.8` (parent `sui-t36`) +- Status: implemented in the same session this plan was written + +## Goal + +The epic's integration piece: `StatifierUI.Kino.inspect(session, fixtures)` +composes the four merged panes (configuration diagram, event log, event +injection, datamodel explorer) into one Kino layout, wired to one shared +`StatifierUI.Trace.Subscriber`, detaching cleanly on cell re-evaluation. +Plus the demo notebook that doubles as the milestone's manual acceptance +test. + +## Design decisions + +1. **Catch-up lives in the Subscriber** (bead PRECONDITION note; statifier + ADR-0049). `Subscriber.attach(sub, session, catch_up: true)` calls + `Statifier.Session.subscribe(session_pid, self(), catch_up: true)` from + inside its own `handle_call`, so the replayed prefix is folded into the + buffer before any mailbox suffix is processed - `prefix ++ suffix` with + no overlap, no gap, no dedup (trust the seam). + - `{:ok, recording}`: session id comes from + `Statifier.Session.Recording.opts(recording)[:session_id]` (the + resolved id - Recording documents this), the `session.start` manifest + is emitted as seq 0, then `Statifier.Replay.run(recording).stream` + elements (already the un-enveloped subscriber shapes) go through the + normal normalize path. + - `{:error, :not_recorded}`: the pid was NOT added. Fall back to + `subscribe/2` and record a `:not_recorded` diagnostic so the widget + labels the panes live-only - never silently show a partial trace as + whole. + - `Replay.run/1` error: same fallback, `:catch_up_failed` diagnostic. +2. **Pure assembly module `StatifierUI.Inspector`** (no Kino dependency): + folds `(machine, messages, opts)` into the per-pane render sources - + Mermaid source for the diagram (configuration = the latest + `trace.macrostep_stable` payload, else a supplied initial), event log + Markdown, live datamodel Markdown, and a status line (session id, + status, seq, dropped, live-only labeling from diagnostics). Fully + testable without a Livebook runtime; this is where the logic and the + coverage live. +3. **`StatifierUI.Kino` is a thin shell**, compiled only when + `Code.ensure_loaded?(Kino)` (sui-8di / ADR-0004); otherwise a stub + raising a clear "add :kino to your deps" error. It snapshots the + session (`Statifier.Session.snapshot/1`) for the machine and initial + configuration, starts the Subscriber and an updater GenServer via + `Kino.start_child/1` (cell re-evaluation terminates both; the session + drops the dead subscriber from its monitored set - that is the clean + detach), registers the updater as listener BEFORE attaching (so no + messages/1-then-add_listener gap), renders panes into `Kino.Frame`s on + a coalesced tick, and wires the injection controls + (`Kino.Control.form` + one button per palette entry, via + `Kino.Control.tagged_stream` and `Kino.listen`) to + `StatifierUI.EventInjection.send_draft/3`. +4. **Correlation-id question (owned here, deferred from t36.6/t36.7)**: + resolved for this milestone as *not needed* - the notebook is + single-user and the event log updates on the injection it just made, so + "sent" plus the live log is adequate correlation. A caller-supplied + correlation id on `Session.send_event/2` remains an engine (`st-`) + change if a future embedder needs exact injection-to-dequeue matching; + recorded as an open question, not built around. +5. **Replay cost is O(run)** (bead note 3): acceptable for the demo + notebook's short runs; the notebook says so where it starts the session. + +## Phases + +1. Subscriber `catch_up: true` attach path + tests + (`test/statifier_ui/trace/subscriber_test.exs` additions; sessions + started with `record: true`). +2. `StatifierUI.Inspector` pure pane assembly + tests; `StatifierUI.Kino` + shell (thin, guarded). +3. `notebooks/inspector.livemd` - the walkable manual acceptance test - + plus README pointer. + +Gate: full `mix quality` green before commit; `mix gate.verify` for the +attestation. + +## Out of scope + +- Live datamodel editing (t36.7 scope guard), ADR-0006 datasets + (`sui-bob`), the elkjs renderer (ADR-0008 later phase), LiveView + components, invoke-tree child attachment UI (st-fd7n's + `invocations/1` - noted in the notebook as an exploration point only). diff --git a/lib/statifier_ui/inspector.ex b/lib/statifier_ui/inspector.ex new file mode 100644 index 0000000..654350e --- /dev/null +++ b/lib/statifier_ui/inspector.ex @@ -0,0 +1,119 @@ +defmodule StatifierUI.Inspector do + @moduledoc """ + Pure pane assembly for the Livebook inspector: folds a compiled + `Statifier.Machine` and a `StatifierUI.Trace.Subscriber` message list + into the render source each pane displays - Mermaid source for the + configuration diagram, Markdown for the event log and the datamodel + explorer, and a status line from the subscriber's `stats/1` snapshot. + + No Kino, no process, no session: everything here is testable from a + message list, exactly like the pane modules it composes. The Kino shell + (`StatifierUI.Kino`) maps these strings into widgets and owns nothing + else. + + The active configuration is read from the newest + `trace.macrostep_stable` message - the quiescent configuration of the + last completed macrostep (`docs/wire-format.md`). Before any macrostep + has completed in view, the caller-supplied initial configuration + (typically `Statifier.Session.snapshot/1`'s) is used instead. + """ + + alias Statifier.Machine + alias StatifierUI.DatamodelExplorer + alias StatifierUI.Diagram + alias StatifierUI.EventLog + alias StatifierUI.Trace.Message + alias StatifierUI.Trace.Subscriber + + @typedoc "Options shared by the fold functions." + @type opt :: {:initial_configuration, Enumerable.t()} + + @doc """ + The active configuration `messages` implies: the newest + `trace.macrostep_stable`'s `configuration` payload, or + `opts[:initial_configuration]` (default `[]`) when no macrostep has + stabilized in view. + """ + @spec active_configuration([Message.t()], [opt()]) :: [non_neg_integer()] + def active_configuration(messages, opts \\ []) do + messages + |> Enum.reverse() + |> Enum.find_value(fn + %Message{type: "trace.macrostep_stable", payload: %{"configuration" => configuration}} -> + configuration + + _other -> + nil + end) + |> case do + nil -> Enum.to_list(Keyword.get(opts, :initial_configuration, [])) + configuration -> configuration + end + end + + @doc """ + Mermaid `stateDiagram-v2` source for the configuration pane: + `StatifierUI.Diagram.render/2` over `active_configuration/2`. + """ + @spec diagram(Machine.t(), [Message.t()], [opt()]) :: String.t() + def diagram(machine, messages, opts \\ []) do + Diagram.render(machine, active_configuration(messages, opts)) + end + + @doc """ + Markdown for the event log pane: `StatifierUI.EventLog.build/1` rendered + collapsible with the last macrostep open. A build failure renders as a + visible error line rather than raising - the inspector keeps showing + the other panes. + """ + @spec event_log([Message.t()]) :: String.t() + def event_log(messages) do + case EventLog.build(messages) do + {:ok, log} -> EventLog.Markdown.render(log) + {:error, reason} -> "**Event log unavailable:** `#{inspect(reason)}`" + end + end + + @doc """ + Markdown for the datamodel explorer pane: + `StatifierUI.DatamodelExplorer.build_live/1` over `messages`, rendered + with the default markers. A build failure renders as a visible error + line, same policy as `event_log/1`. + """ + @spec datamodel([Message.t()]) :: String.t() + def datamodel(messages) do + case DatamodelExplorer.build_live(messages) do + {:ok, pane} -> DatamodelExplorer.Markdown.render(pane) + {:error, reason} -> "**Datamodel explorer unavailable:** `#{inspect(reason)}`" + end + end + + @doc """ + The one-line (plus warnings) status header: session id, subscriber + status, message and drop counts, and one blockquote line per diagnostic. + A `:not_recorded` or `:catch_up_failed` diagnostic is what labels the + whole inspector live-only - a partial stream is never presented as + whole (statifier ADR-0049; this bead's precondition note). + """ + @spec status(Subscriber.stats()) :: String.t() + def status(stats) do + session = stats.session || "(awaiting first message)" + + header = + "**Session** `#{session}` - #{stats.status} - " <> + "#{stats.buffered} messages buffered (#{stats.dropped} dropped, #{stats.errors} errors)" + + warnings = + Enum.map(stats.diagnostics, fn diagnostic -> + "> **#{label(diagnostic.kind)}:** #{diagnostic.message}" + end) + + Enum.join([header | warnings], "\n\n") + end + + @spec label(atom()) :: String.t() + defp label(:not_recorded), do: "Live-only" + defp label(:catch_up_failed), do: "Live-only" + defp label(:late_attach), do: "Late attach" + defp label(kind), do: kind |> Atom.to_string() |> String.capitalize() +end diff --git a/lib/statifier_ui/kino.ex b/lib/statifier_ui/kino.ex new file mode 100644 index 0000000..2dbb9d3 --- /dev/null +++ b/lib/statifier_ui/kino.ex @@ -0,0 +1,259 @@ +if Code.ensure_loaded?(Kino) do + defmodule StatifierUI.Kino do + @moduledoc """ + The Livebook inspector: `inspect/3` composes the four panes - + configuration diagram, datamodel explorer, event injection, event + log - over one shared `StatifierUI.Trace.Subscriber`, attached with + catch-up (statifier ADR-0049), inside one `Kino.Layout`. + + Compiled only when the optional `:kino` dependency is present + (ADR-0004); a host without it gets a stub whose `inspect/3` raises + with instructions. Nothing else in this package touches Kino. + + ## Lifecycle + + Every process this module starts - the subscriber and the updater - + goes through `Kino.start_child/1`, so re-evaluating the cell + terminates them with it. The session notices the dead subscriber + through its own monitor and drops it from its subscriber set: that is + the clean detach, with nothing to unsubscribe by hand. The session + itself is *not* owned here - it keeps running across cell + re-evaluations, which is exactly what makes catch-up worth having. + + ## Catch-up needs `record: true` + + Start the session with `record: true` (and `trace: true`) for the + inspector to reconstruct everything it missed. Without it the + subscriber falls back to live delivery and the status header labels + the panes **Live-only** - a partial stream is never presented as + whole. Replay cost grows with run length (it re-runs the recording in + the attaching process), so expect cell evaluation on a very long-lived + session to take correspondingly longer. + """ + + alias StatifierUI.EventInjection + alias StatifierUI.Fixtures + alias StatifierUI.Kino.Updater + alias StatifierUI.Trace.Subscriber + + @doc """ + Builds the inspector for `session` and returns the composed + `Kino.Layout` for the cell to render. + + `fixtures` is a `StatifierUI.Fixtures.t/0` (or `nil`): it feeds the + injection palette's per-event buttons. `opts`: + + * `:source` - the SCXML text, forwarded to the subscriber for the + `session.start` manifest. + * `:capacity` - the subscriber's buffer capacity (default 1000). + """ + @spec inspect(pid(), Fixtures.t() | nil, keyword()) :: Kino.Layout.t() + def inspect(session, fixtures \\ nil, opts \\ []) when is_pid(session) do + snapshot = Statifier.Session.snapshot(session) + + frames = %{ + status: Kino.Frame.new(placeholder: false), + diagram: Kino.Frame.new(placeholder: false), + datamodel: Kino.Frame.new(placeholder: false), + log: Kino.Frame.new(placeholder: false) + } + + subscriber_opts = + [machine: snapshot.machine] ++ + Keyword.take(opts, [:source, :capacity]) + + sub = Kino.start_child!({Subscriber, subscriber_opts}) + + updater = + Kino.start_child!( + {Updater, + sub: sub, + machine: snapshot.machine, + frames: frames, + initial_configuration: snapshot.configuration} + ) + + # Listener first, then attach: every message the attach folds in (the + # replayed prefix included) also pings the updater, so there is no + # messages/1-then-add_listener gap to fall into. + :ok = Subscriber.add_listener(sub, updater) + :ok = Subscriber.attach(sub, session, catch_up: true) + Updater.refresh(updater) + + Kino.Layout.grid( + [ + frames.status, + Kino.Layout.grid([frames.diagram, frames.datamodel], columns: 2), + injection_ui(session, fixtures), + frames.log + ], + columns: 1 + ) + end + + # -- injection pane ------------------------------------------------------ + + @spec injection_ui(pid(), Fixtures.t() | nil) :: Kino.Layout.t() + defp injection_ui(session, fixtures) do + feedback = Kino.Frame.new(placeholder: false) + + form = + Kino.Control.form( + [ + name: Kino.Input.text("Event name"), + payload: Kino.Input.textarea("Payload (JSON, optional)", monospace: true) + ], + submit: "Send" + ) + + Kino.listen(form, fn %{data: %{name: name, payload: payload}} -> + deliver(session, name, payload, feedback) + end) + + children = palette_buttons(session, fixtures, feedback) ++ [form, feedback] + Kino.Layout.grid(children, columns: 1) + end + + @spec palette_buttons(pid(), Fixtures.t() | nil, Kino.Frame.t()) :: [Kino.Layout.t()] + defp palette_buttons(session, fixtures, feedback) do + case EventInjection.build(fixtures) do + {:ok, pane} -> + buttons = + Enum.map(EventInjection.entries(pane), &palette_button(session, &1, feedback)) + + if buttons == [], do: [], else: [Kino.Layout.grid(buttons, columns: 3)] + + {:error, reason} -> + Kino.Frame.render( + feedback, + Kino.Markdown.new("**Palette unavailable:** `#{Kernel.inspect(reason)}`") + ) + + [] + end + end + + @spec palette_button(pid(), EventInjection.Entry.t(), Kino.Frame.t()) :: Kino.Control.t() + defp palette_button(session, entry, feedback) do + button = Kino.Control.button(entry.name) + + Kino.listen(button, fn _event -> + deliver(session, entry.name, entry.payload_text, feedback) + end) + + button + end + + @spec deliver(pid(), String.t(), String.t() | nil, Kino.Frame.t()) :: :ok + defp deliver(session, name, payload_text, feedback) do + payload = + case payload_text && String.trim(payload_text) do + nil -> nil + "" -> nil + trimmed -> trimmed + end + + note = + case EventInjection.send_draft(session, name, payload) do + :ok -> "Sent `#{name}` - watch the event log for its macrostep." + {:error, reason} -> "**Not sent:** `#{Kernel.inspect(reason)}`" + end + + Kino.Frame.render(feedback, Kino.Markdown.new(note)) + end + end + + defmodule StatifierUI.Kino.Updater do + @moduledoc """ + The inspector's render loop: a `GenServer` registered as the shared + subscriber's listener, re-rendering every frame from the subscriber's + buffer on a coalesced tick (one render at most every 80 ms, however + fast messages arrive). Started via + `Kino.start_child/1` by `StatifierUI.Kino.inspect/3`, and terminated + with the cell - which is what detaches the inspector. + """ + + use GenServer + + alias StatifierUI.Inspector + alias StatifierUI.Trace.Subscriber + + @coalesce_ms 80 + + @doc "Renders every pane now, skipping the coalescing delay." + @spec refresh(GenServer.server()) :: :ok + def refresh(server), do: GenServer.cast(server, :refresh) + + @doc false + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @impl GenServer + def init(opts) do + state = %{ + sub: Keyword.fetch!(opts, :sub), + machine: Keyword.fetch!(opts, :machine), + frames: Keyword.fetch!(opts, :frames), + initial_configuration: Keyword.fetch!(opts, :initial_configuration), + timer: nil + } + + {:ok, state} + end + + @impl GenServer + def handle_cast(:refresh, state), do: {:noreply, render_panes(state)} + + @impl GenServer + def handle_info({:statifier_ui, _session_id, _message}, %{timer: nil} = state) do + {:noreply, %{state | timer: Process.send_after(self(), :render, @coalesce_ms)}} + end + + def handle_info({:statifier_ui, _session_id, _message}, state), do: {:noreply, state} + + def handle_info(:render, state), do: {:noreply, render_panes(state)} + + def handle_info(_other, state), do: {:noreply, state} + + @spec render_panes(map()) :: map() + defp render_panes(state) do + if state.timer, do: Process.cancel_timer(state.timer) + + messages = Subscriber.messages(state.sub) + stats = Subscriber.stats(state.sub) + opts = [initial_configuration: state.initial_configuration] + + Kino.Frame.render(state.frames.status, Kino.Markdown.new(Inspector.status(stats))) + + Kino.Frame.render( + state.frames.diagram, + Kino.Mermaid.new(Inspector.diagram(state.machine, messages, opts)) + ) + + Kino.Frame.render( + state.frames.datamodel, + Kino.Markdown.new(Inspector.datamodel(messages)) + ) + + Kino.Frame.render(state.frames.log, Kino.Markdown.new(Inspector.event_log(messages))) + + %{state | timer: nil} + end + end +else + defmodule StatifierUI.Kino do + @moduledoc """ + Stub compiled when the optional `:kino` dependency is absent + (ADR-0004). Add `{:kino, "~> 0.14"}` to the host's dependencies to + get the real Livebook inspector. + """ + + @doc "Raises: the inspector needs the optional `:kino` dependency." + @spec inspect(pid(), term(), keyword()) :: no_return() + def inspect(_session, _fixtures \\ nil, _opts \\ []) do + raise RuntimeError, + "StatifierUI.Kino.inspect/3 needs the optional :kino dependency - " <> + "add {:kino, \"~> 0.14\"} to your deps and restart" + end + end +end diff --git a/lib/statifier_ui/trace/subscriber.ex b/lib/statifier_ui/trace/subscriber.ex index 0e78812..a410f05 100644 --- a/lib/statifier_ui/trace/subscriber.ex +++ b/lib/statifier_ui/trace/subscriber.ex @@ -35,6 +35,27 @@ defmodule StatifierUI.Trace.Subscriber do the initialize burst is gone by the time this call can run, because `Statifier.Session.start_link/2` already returned. + ## The catch-up attach path + + `attach(sub, session, catch_up: true)` closes the late-attach gap for a + session started with `record: true` (statifier ADR-0049): the + subscription and the recording snapshot happen in the *same* session + `handle_call`, the missed prefix is `Statifier.Replay.run/1`'s `stream`, + and this subscriber folds that prefix into its buffer inside its own + `attach` call - before any live suffix from its mailbox is processed. + Prefix and suffix are one uniform stream with no overlap, no gap, and no + dedup key (the ADR's mid-run invariant; trust the seam). The session id + is read from the recording's resolved `:session_id` option, so the + `session.start` manifest is emitted as `seq: 0` ahead of the prefix. + + On a session started *without* `record: true` the session answers + `{:error, :not_recorded}` and does not subscribe, so this subscriber + falls back to `Statifier.Session.subscribe/2` and records a + `:not_recorded` diagnostic in `stats/1` - the stream is live-only and + says so; it is never silently presented as whole. A `Statifier.Replay.run/1` + failure records `:catch_up_failed` the same way (the live subscription + from the catch-up call itself is already in place in that case). + ## Session id discovery This subscriber never asks the session for its id (decision 9 of the @@ -67,6 +88,7 @@ defmodule StatifierUI.Trace.Subscriber do require Logger + alias Statifier.Session.Recording alias StatifierUI.Fixtures alias StatifierUI.Trace.Buffer alias StatifierUI.Trace.Manifest @@ -171,6 +193,11 @@ defmodule StatifierUI.Trace.Subscriber do where `session` already carries this subscriber's pid in its own `:subscribers` start option. + `opts[:catch_up]` (default `false`) selects the catch-up path described + in the moduledoc - `Statifier.Session.subscribe/3` with `catch_up: true`, + the replayed prefix folded in before this call returns. When set, + `opts[:subscribe]` is ignored: catch-up decides its own subscription. + Idempotent about the monitor: a second `attach/3` call for the same `session` pid does not stack a second monitor. It does not attempt to detect an existing subscription, which is why the two paths are @@ -178,8 +205,14 @@ defmodule StatifierUI.Trace.Subscriber do """ @spec attach(server(), session :: pid(), opts :: keyword()) :: :ok def attach(server, session, opts \\ []) when is_pid(session) do - subscribe? = Keyword.get(opts, :subscribe, true) - GenServer.call(server, {:attach, session, subscribe?}) + mode = + cond do + Keyword.get(opts, :catch_up, false) -> :catch_up + Keyword.get(opts, :subscribe, true) -> :late + true -> :early + end + + GenServer.call(server, {:attach, session, mode}) end @doc """ @@ -228,27 +261,23 @@ defmodule StatifierUI.Trace.Subscriber do end @impl GenServer - def handle_call({:attach, session_pid, subscribe?}, _from, state) do + def handle_call({:attach, session_pid, mode}, _from, state) do monitor_ref = ensure_monitor(state, session_pid) - if subscribe? do - :ok = Statifier.Session.subscribe(session_pid, self()) - end + state = %{state | session_pid: session_pid, monitor_ref: monitor_ref, status: :attached} - diagnostics = - if subscribe? do - state.diagnostics ++ [late_attach_diagnostic()] - else - state.diagnostics - end + state = + case mode do + :early -> + state - state = %{ - state - | session_pid: session_pid, - monitor_ref: monitor_ref, - status: :attached, - diagnostics: diagnostics - } + :late -> + :ok = Statifier.Session.subscribe(session_pid, self()) + %{state | diagnostics: state.diagnostics ++ [late_attach_diagnostic()]} + + :catch_up -> + attach_catch_up(state, session_pid) + end {:reply, :ok, state} end @@ -321,6 +350,55 @@ defmodule StatifierUI.Trace.Subscriber do } end + # -- catch-up ------------------------------------------------------------- + + # The subscription is already made by the time either branch runs: + # `Statifier.Session.subscribe/3` with `catch_up: true` adds this pid in + # the same session handle_call that snapshots the recording, so the + # replayed prefix and the mailbox suffix meet with no overlap and no gap + # (statifier ADR-0049). Only `:not_recorded` leaves this pid + # unsubscribed, and that branch subscribes live itself. + @spec attach_catch_up(State.t(), pid()) :: State.t() + defp attach_catch_up(state, session_pid) do + case Statifier.Session.subscribe(session_pid, self(), catch_up: true) do + {:ok, recording} -> + replay_prefix(state, recording) + + {:error, :not_recorded} -> + :ok = Statifier.Session.subscribe(session_pid, self()) + + record_diagnostic( + state, + :not_recorded, + "catch-up requested but the session was not started with record: true - " <> + "attached live-only; everything before this attach is missing from the stream" + ) + end + end + + @spec replay_prefix(State.t(), Recording.t()) :: State.t() + defp replay_prefix(state, recording) do + session_id = recording |> Recording.opts() |> Keyword.fetch!(:session_id) + + case Statifier.Replay.run(recording) do + {:ok, %{stream: stream}} -> + state = emit_manifest(%{state | session: session_id}) + Enum.reduce(stream, state, &handle_statifier_message(&2, session_id, &1)) + + {:error, reason} -> + # Subscribed live (the catch-up call added this pid); the prefix is + # simply unavailable. `session` stays nil so the first live message + # still emits the manifest. + record_diagnostic( + state, + :catch_up_failed, + "catch-up subscribed, but replaying the recording failed " <> + "(#{inspect(reason)}) - live-only; everything before this attach " <> + "is missing from the stream" + ) + end + end + # -- message handling ----------------------------------------------------- @spec handle_statifier_message(State.t(), String.t(), term()) :: State.t() @@ -361,7 +439,8 @@ defmodule StatifierUI.Trace.Subscriber do @spec record_diagnostic(State.t(), atom(), term()) :: State.t() defp record_diagnostic(state, kind, reason) do - diagnostic = %{kind: kind, message: inspect(reason), path: [], source: nil} + message = if is_binary(reason), do: reason, else: inspect(reason) + diagnostic = %{kind: kind, message: message, path: [], source: nil} %{state | diagnostics: state.diagnostics ++ [diagnostic]} end diff --git a/notebooks/inspector.livemd b/notebooks/inspector.livemd new file mode 100644 index 0000000..39bb7e1 --- /dev/null +++ b/notebooks/inspector.livemd @@ -0,0 +1,157 @@ +# Statifier inspector + +```elixir +Mix.install([ + {:kino, "~> 0.14"}, + {:statifier_ui, path: Path.join(__DIR__, "..")} +]) +``` + +## What this notebook is + +The Livebook inspector end to end, driving a small checkout chart. It is +also the milestone's **manual acceptance test**: each numbered step below +says what to do and what you should see, so walking it top to bottom +verifies the assembled widget (`sui-t36.8`) against a live session. + +Livebook evaluates cells in order; use "Evaluate" on each code cell as you +reach it. + +## 1. Compile the chart + +Text-first: the SCXML below is the source of truth, and everything the +inspector shows is read from it or from the session's trace effects. + +```elixir +xml = """ + + + + + + + + + + + + + + + +""" + +{:ok, machine} = Statifier.compile(xml) +``` + +**Expect:** `{:ok, %Statifier.Machine{...}}`. A compile error here means +the chart text was edited into something invalid - fix it before going on. + +## 2. Fixtures for the injection palette + +One sample payload per event name (ADR-0003). These become the one-click +buttons in the injection pane. + +```elixir +{:ok, fixtures} = + StatifierUI.Fixtures.new( + events: %{ + "checkout" => %{}, + "payment.success" => %{"amount" => 1999, "currency" => "USD"}, + "payment.failure" => %{"reason" => "card_declined"} + } + ) +``` + +## 3. Start a recorded session + +`trace: true` puts the run on the wire; `record: true` is what lets the +inspector catch up on anything it missed (statifier ADR-0049). Without it +the inspector still works but labels itself **Live-only**. Replay cost +grows with run length, so a very long-lived session makes the inspector +cell slower to evaluate - not a concern at this notebook's scale. + +```elixir +{:ok, session} = Statifier.Session.start_link(machine, trace: true, record: true) +``` + +## 4. Open the inspector + +```elixir +StatifierUI.Kino.inspect(session, fixtures, source: xml) +``` + +**Expect, immediately:** + +1. **Status header** - the session id, `attached`, a message count, and + **no** "Live-only" warning. +2. **Configuration diagram** (left) - the chart as a Mermaid state + diagram with `cart` highlighted. The initialize burst happened before + this cell ran; catch-up is why the diagram still knows about it. +3. **Datamodel explorer** (right) - `attempts` with its initial `0`. +4. **Injection pane** - one button per fixture event, then a free-form + name/payload form. +5. **Event log** - macrostep 1 (the initialize burst), collapsed except + the last macrostep. + +## 5. Walk the chart from the palette + +Do these in order, watching the panes after each click: + +1. Click **checkout**. Expect: feedback line "Sent `checkout` ..."; + diagram highlight moves to `pending`; datamodel shows `attempts` as + `1` with a changed marker; the event log grows a macrostep whose + cause is `checkout`. +2. Click **payment.failure**. Expect: highlight returns to `cart`; the + log's newest macrostep names `payment.failure`. +3. Click **checkout** again, then **payment.success**. Expect: highlight + reaches `paid` (a final state); `attempts` reads `2`; the status + header still says `attached` - a halted chart's session process is + alive, and the inspector keeps its whole trace readable. + +## 6. The form, including its error path + +1. In the free-form fields, enter name `payment.failure` and payload + `{"reason": "manual"}`, press **Send**. Expect: "Sent" feedback (the + chart is in `paid`, so nothing transitions - the event is dequeued + and logged with no selected transitions, which is itself worth seeing + in the log). +2. Now enter payload `{not json`. Expect: **Not sent**, with the JSON + error - the session was never touched (the draft failed to build). + +## 7. Re-evaluate the inspector cell + +Re-evaluate the `StatifierUI.Kino.inspect(...)` cell (step 4). + +**Expect:** the previous widget's processes are terminated with the old +cell evaluation (that is the clean detach - the session simply drops the +dead subscriber), and the fresh widget shows the **entire** history +again: every macrostep you drove above is in the log, `attempts` is `2`, +and the diagram highlights `paid`. That round trip is catch-up doing its +job. + +To see the honest degraded mode: start a session **without** +`record: true`, inspect it, and note the status header's **Live-only** +warning - the inspector refuses to present a partial stream as whole. + +## 8. Session death + +```elixir +Process.exit(session, :kill) +``` + +**Expect:** the status header moves to `terminated`, the log's footer +gains a `session.terminated` line, and every pane keeps rendering the +buffered trace - death is an observation, not a reset. (Because the +session was started from this notebook with `start_link`, killing it may +also take down the cell's evaluator; re-evaluate from step 3 to go +again.) + +## Where to go next + +* `StatifierUI.Trace.Subscriber` - the one process behind all four panes. +* `Statifier.Session.invocations/1` plus `:inherit_observers` (statifier + ADR-0050) - attaching one subscriber to a whole invoke tree; the + inspector composes per session today. +* `docs/wire-format.md` - every message type the panes fold over. diff --git a/test/statifier_ui/inspector_test.exs b/test/statifier_ui/inspector_test.exs new file mode 100644 index 0000000..77fc7e1 --- /dev/null +++ b/test/statifier_ui/inspector_test.exs @@ -0,0 +1,122 @@ +defmodule StatifierUI.InspectorTest do + use ExUnit.Case, async: true + + alias Statifier.Session + alias StatifierUI.Inspector + alias StatifierUI.Test.Support.Trace.SessionCase + alias StatifierUI.Trace.Subscriber + + @two_state """ + + + + + + + + """ + + # The datamodel chart mirrors the explorer pane's own live-mode tests: + # one declared variable, assigned on the driven transition. + @counter """ + + + + + + + + + + + + """ + + defp driven_messages(xml, session_id) do + machine = SessionCase.compile!(xml) + {sub, session} = SessionCase.start_early!(machine, session_id) + Session.send_event(session, "go") + SessionCase.wait_for_seq(sub, 15) + {machine, Subscriber.messages(sub), Subscriber.stats(sub)} + end + + describe "active_configuration/2" do + test "reads the newest trace.macrostep_stable" do + {_machine, messages, _stats} = driven_messages(@two_state, "sess_insp_conf") + + # After "go" the chart sits in "b" (index 2, root index 0 included). + assert Inspector.active_configuration(messages) == [0, 2] + end + + test "falls back to the initial configuration when no macrostep is in view" do + assert Inspector.active_configuration([], initial_configuration: MapSet.new([0, 1])) == + [0, 1] + + assert Inspector.active_configuration([]) == [] + end + end + + describe "diagram/3" do + test "highlights the fold's configuration" do + {machine, messages, _stats} = driven_messages(@two_state, "sess_insp_diag") + + source = Inspector.diagram(machine, messages) + assert String.starts_with?(source, "stateDiagram-v2") + assert source =~ "class s2 active" + refute source =~ "class s1 active" + end + end + + describe "event_log/1" do + test "renders the driven macrostep" do + {_machine, messages, _stats} = driven_messages(@two_state, "sess_insp_log") + + markdown = Inspector.event_log(messages) + assert markdown =~ "Macrostep 2" + assert markdown =~ "go" + end + + test "an empty message list still renders" do + assert is_binary(Inspector.event_log([])) + end + end + + describe "datamodel/1" do + test "renders live entries with their current values" do + machine = SessionCase.compile!(@counter) + {sub, session} = SessionCase.start_early!(machine, "sess_insp_data") + Session.send_event(session, "bump") + SessionCase.wait_until(sub, 1000, fn stats -> stats.seq >= 10 end) + + markdown = Inspector.datamodel(Subscriber.messages(sub)) + assert markdown =~ "count" + end + end + + describe "status/1" do + test "renders session, counts, and no warnings for a whole stream" do + {_machine, _messages, stats} = driven_messages(@two_state, "sess_insp_status") + + markdown = Inspector.status(stats) + assert markdown =~ "`sess_insp_status`" + assert markdown =~ "attached" + refute markdown =~ "Live-only" + end + + test "labels a :not_recorded diagnostic Live-only" do + machine = SessionCase.compile!(@two_state) + {:ok, session} = Session.start_link(machine, trace: true, session_id: "sess_insp_lo") + {:ok, sub} = Subscriber.start_link(machine: machine) + :ok = Subscriber.attach(sub, session, catch_up: true) + + markdown = Inspector.status(Subscriber.stats(sub)) + assert markdown =~ "**Live-only:**" + assert markdown =~ "record: true" + end + + test "a stats snapshot before any message names no session" do + {:ok, sub} = Subscriber.start_link(machine: SessionCase.compile!(@two_state)) + assert Inspector.status(Subscriber.stats(sub)) =~ "(awaiting first message)" + end + end +end diff --git a/test/statifier_ui/kino_test.exs b/test/statifier_ui/kino_test.exs new file mode 100644 index 0000000..0e287a5 --- /dev/null +++ b/test/statifier_ui/kino_test.exs @@ -0,0 +1,65 @@ +defmodule StatifierUI.KinoTest do + # `configure_livebook_bridge` swaps this process's group leader, so these + # tests stay out of the async pool. + use ExUnit.Case, async: false + + import Kino.Test + + alias Statifier.Session + alias StatifierUI.Fixtures + alias StatifierUI.Test.Support.Trace.SessionCase + alias StatifierUI.Trace.Subscriber + + setup :configure_livebook_bridge + + @two_state """ + + + + + + + + """ + + test "inspect/3 composes a layout over a recorded session, palette included" do + machine = SessionCase.compile!(@two_state) + + {:ok, session} = + Session.start_link(machine, trace: true, record: true, session_id: "sess_kino_smoke") + + {:ok, fixtures} = Fixtures.new(events: %{"go" => %{"note" => "demo"}}) + + layout = StatifierUI.Kino.inspect(session, fixtures, source: @two_state) + assert %Kino.Layout{} = layout + + # The widget must not have subscribed live-only: the session records, + # so a second subscriber catching up now sees the same whole stream the + # widget's own subscriber folded in - the initialize burst included. + sub = SessionCase.attach_catch_up!(machine, session) + messages = Subscriber.messages(sub) + assert Enum.any?(messages, &(&1.type == "session.start")) + assert Enum.any?(messages, &(&1.payload["indexes"] == [0, 1])) + + # Driving the session after assembly must not crash anything the + # widget started; the updater re-renders on its coalesced tick. + Session.send_event(session, "go") + SessionCase.wait_for_seq(sub, 15) + end + + test "inspect/3 without fixtures renders no palette and still assembles" do + machine = SessionCase.compile!(@two_state) + + {:ok, session} = + Session.start_link(machine, trace: true, record: true, session_id: "sess_kino_bare") + + assert %Kino.Layout{} = StatifierUI.Kino.inspect(session) + end + + test "inspect/3 on an unrecorded session still assembles (live-only)" do + machine = SessionCase.compile!(@two_state) + {:ok, session} = Session.start_link(machine, trace: true, session_id: "sess_kino_lo") + + assert %Kino.Layout{} = StatifierUI.Kino.inspect(session) + end +end diff --git a/test/statifier_ui/trace/subscriber_test.exs b/test/statifier_ui/trace/subscriber_test.exs index 18bb894..a83dea3 100644 --- a/test/statifier_ui/trace/subscriber_test.exs +++ b/test/statifier_ui/trace/subscriber_test.exs @@ -71,6 +71,82 @@ defmodule StatifierUI.Trace.SubscriberTest do end end + describe "catch-up attach (statifier ADR-0049)" do + test "reconstructs the full stream after the fact - prefix, then live suffix" do + machine = SessionCase.compile!(@two_state) + session = SessionCase.start_recorded!(machine, "sess_catch_up") + + # Drive the transition BEFORE any subscriber exists, and wait until + # the session has fully processed it (the driven macrostep is 2). + Session.send_event(session, "go") + SessionCase.wait_for_macrostep(session, 2) + + sub = SessionCase.attach_catch_up!(machine, session) + + # The replayed prefix is available synchronously - attach/3 folds it + # into the buffer inside its own call. + messages = Subscriber.messages(sub) + assert length(messages) == @full_seq + assert Enum.map(messages, & &1.seq) == Enum.to_list(0..(@full_seq - 1)) + + # The initialize burst a plain late attach can never see is present. + assert [ + %{type: "session.start", seq: 0}, + %{type: "session.datamodel", seq: 1}, + %{type: "trace.entry_set", seq: 2} = burst | _ + ] = messages + + assert burst.payload["indexes"] == [0, 1] + + # No :late_attach diagnostic - this stream is whole. + assert Subscriber.stats(sub).diagnostics == [] + + # The live suffix continues seamlessly: nothing more to transition + # to, but the dequeue itself is notified and lands after the prefix. + Session.send_event(session, "go") + stats = SessionCase.wait_for_seq(sub, @full_seq + 1) + assert stats.seq > @full_seq + seqs = Enum.map(Subscriber.messages(sub), & &1.seq) + assert seqs == Enum.to_list(0..(length(seqs) - 1)) + end + + test "an unrecorded session falls back to live-only with a :not_recorded diagnostic" do + machine = SessionCase.compile!(@two_state) + {:ok, session} = Session.start_link(machine, trace: true, session_id: "sess_unrecorded") + + {:ok, sub} = Subscriber.start_link(machine: machine) + :ok = Subscriber.attach(sub, session, catch_up: true) + + assert [%{kind: :not_recorded}] = Subscriber.stats(sub).diagnostics + + # The fallback did subscribe live: a driven event still arrives, but + # the initialize burst stays missing - exactly the late-attach shape. + Session.send_event(session, "go") + SessionCase.wait_until(sub, 1000, fn stats -> stats.seq > 0 end) + + entry_sets = + Enum.filter(Subscriber.messages(sub), &(&1.type == "trace.entry_set")) + + assert Enum.all?(entry_sets, &(&1.payload["indexes"] != [0, 1])) + assert Enum.any?(entry_sets, &(&1.payload["indexes"] == [2])) + end + + test "catch-up attach monitors the session: a kill still yields session.terminated" do + machine = SessionCase.compile!(@two_state) + session = SessionCase.start_recorded!(machine, "sess_catch_up_down") + SessionCase.wait_for_macrostep(session, 1) + + sub = SessionCase.attach_catch_up!(machine, session) + + Process.unlink(session) + Process.exit(session, :kill) + + stats = SessionCase.wait_until(sub, 1000, fn stats -> stats.status == :terminated end) + assert stats.status == :terminated + assert List.last(Subscriber.messages(sub)).type == "session.terminated" + end + end + describe "seq stamping" do test "session.start is seq 0, the first effect is seq 1, and seq is monotone with no gaps" do machine = SessionCase.compile!(@two_state) diff --git a/test/support/trace/session_case.ex b/test/support/trace/session_case.ex index a403889..ef335b2 100644 --- a/test/support/trace/session_case.ex +++ b/test/support/trace/session_case.ex @@ -51,6 +51,49 @@ defmodule StatifierUI.Test.Support.Trace.SessionCase do {sub, session} end + @doc """ + The catch-up attach path (statifier ADR-0049): starts a session over + `machine` with `record: true` and `session_id` pinned - and no + subscriber, so everything up to the attach is missed mail - then starts + a subscriber and attaches with `catch_up: true`. The caller drives + events between the two halves via the returned session; use + `wait_for_macrostep/3` to know the session has processed them before + attaching. Returns the session; pair with `attach_catch_up!/2`. + """ + @spec start_recorded!(Statifier.Machine.t(), String.t()) :: pid() + def start_recorded!(machine, session_id) do + {:ok, session} = + Session.start_link(machine, trace: true, record: true, session_id: session_id) + + session + end + + @doc "Starts a subscriber over `machine` and attaches it to `session` with `catch_up: true`." + @spec attach_catch_up!(Statifier.Machine.t(), pid(), keyword()) :: pid() + def attach_catch_up!(machine, session, subscriber_opts \\ []) do + {:ok, sub} = Subscriber.start_link(Keyword.put(subscriber_opts, :machine, machine)) + :ok = Subscriber.attach(sub, session, catch_up: true) + sub + end + + @doc """ + Polls `Statifier.Session.status/1` until `macrostep` reaches at least + `target` with an empty external queue, or `timeout` milliseconds elapse. + Returns the last observed status projection either way. + """ + @spec wait_for_macrostep(pid(), non_neg_integer(), timeout()) :: map() + def wait_for_macrostep(session, target, timeout \\ 1000) do + status = Session.status(session) + + if (status.macrostep >= target and status.queued_events == 0) or timeout <= 0 do + status + else + step = min(10, timeout) + Process.sleep(step) + wait_for_macrostep(session, target, timeout - step) + end + end + @doc """ Polls `Subscriber.stats/1` until `seq` reaches at least `target`, or `timeout` milliseconds elapse. Returns the last observed `stats()` map