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