Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions changelog.d/sui-t36.8.md
Original file line number Diff line number Diff line change
@@ -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.
84 changes: 84 additions & 0 deletions docs/plans/260822-sui-t36.8-inspector-widget-assembly.md
Original file line number Diff line number Diff line change
@@ -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).
119 changes: 119 additions & 0 deletions lib/statifier_ui/inspector.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading