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