StatifierUI.Inspector (StatifierUI v0.9.1)

Copy Markdown View Source

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 message that stamped one - a trace.macrostep_stable, the quiescent configuration of the last completed macrostep, or a trace.done, the configuration a halted run exited in (docs/wire-format.md). Before either has arrived in view, the caller-supplied initial configuration (typically Statifier.Session.snapshot/1's) is used instead.

A halted chart's final configuration (sui-dc7)

A run that ends by entering a top-level <final> never stabilizes in its last macrostep: quiescence is never reached, so no trace.macrostep_stable is emitted for it and the engine stamps the configuration on trace.done instead. Reading only macrostep_stable therefore left the diagram highlighting the state the chart left, while the datamodel pane showed the assignment that moved it out.

Both stamps are read, and the wire format is what settles that they may be: its trace.done row defines configuration as "the full configuration as it stood at exit, a genuine set, sorted ascending" - the same field, shape, and authority as a macrostep_stable payload. Nothing here re-derives an exit configuration from the exit sets that precede trace.done; that would be re-implementing Appendix D, which this repo does not do (ADR-0002, StatifierUI.EventLog). The two readings stay labelled apart, so a configuration the chart exited in is never presented as one it settled in.

Selecting a past macrostep (sui-3gg)

Every fold function takes a :selection, which is :live (the default, the behaviour above) or {:macrostep, n}. Under a selection the diagram shows the configuration macrostep n settled in, the event log marks that macrostep's entry - shown in the diagram and opens it, and selection_note/2 renders the line that says which point is on screen. That is the whole of the link between the two panes.

Nothing here re-derives a configuration. Every configuration this module can show was stamped by the engine on a trace.macrostep_stable or a trace.done, and a stream reconstructed by catch-up got there through statifier ADR-0034 replay, which re-drives the core in a pure fold rather than rewinding a live session. Time travel is a read of replay output as data (ADR-0002's inherited clause), so selecting a macrostep neither touches the session nor asks the engine for anything new.

A selected macrostep that stamped no configuration of its own - in flight, or its stamp dropped - has none to draw. Rather than draw nothing, the newest configuration at or below it is shown and selection_note/2 says which macrostep it was carried from - a carried configuration is never presented as a measured one.

The configuration as state ids (sui-hq0)

active_configuration_ids/2 answers the same configuration as active_configuration/2, resolved through the stream's own session.start manifest into the chart's ids, and active_invokes/2 does the same for the invocations live at that point. Both are pure reads a host can call from a render/1 to drive a diagram of its own without parsing the manifest itself; both refuse with {:error, :no_manifest} on a stream that carries none.

Stepping a persisted trace (sui-2uz)

datamodel/2 takes the same :selection every other fold here does, so the datamodel pane can show the values as they stood at a selected macrostep rather than only at the live tip, and datamodel_diff/2 renders what one macrostep changed. Both are the same stream cut short and folded again by StatifierUI.DatamodelExplorer.build_live/2; the comparison itself is StatifierUI.DatamodelExplorer.Diff. Nothing re-runs the chart and nothing re-implements an assignment - a persisted stream is a recording, and stepping it is a read of a shorter prefix.

Linking out to a trace (sui-4w2)

Every fold function also takes :deep_link, the host's APM URL template (ADR-0013). With one configured, the event log's macrostep summaries and selection_note/2 gain an [open trace](...) link for each macrostep whose messages carry the wire format's otel key. Without the option, or on a macrostep carrying no correlation, every pane renders exactly as it did - including the carried-forward wording above, which the link is appended to rather than replacing.

Summary

Types

Options shared by the fold functions.

One selectable point, for a caller building a scrubber.

Which point in the run the panes show: the live tip, or one macrostep.

Functions

The configuration messages and opts[:selection] imply.

The invocations live at the point opts[:selection] names, as {state_id, invoke_type | nil} - the invoke half of active_configuration_ids/2.

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.

Markdown for the datamodel diff pane: what macrostep n changed in the datamodel, as a table (sui-2uz).

Mermaid stateDiagram-v2 source for the configuration pane: StatifierUI.Diagram.render/3 over active_configuration/2.

Markdown for the event log pane: StatifierUI.EventLog.build/1 rendered collapsible. The selected macrostep is opened and marked - shown in the diagram; with no selection the last macrostep is opened, as before. A build failure renders as a visible error line rather than raising - the inspector keeps showing the other panes.

The status header for a stream read back from storage rather than watched live.

The macrosteps a caller can select, oldest first - the scrubber's own vocabulary, so a UI never has to fold the log itself. A log that refuses to build yields [].

How the selection resolved, for a caller that needs to say so: :live, {:quiescent, n}, {:final, n} (macrostep n halted the run, so the configuration it exited in is shown), {:carried, n, from} (macrostep n stamped no configuration, so macrostep from's is shown), or {:before_first, n} (nothing at or below n stamped a configuration, so the initial configuration is shown).

The one-line note naming the point on screen, for display above the diagram: which macrostep and its event, whether the configuration was measured there or carried from an earlier macrostep, and - on :live - that the diagram is following the tip.

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).

Moves a selection one scrubber step over points, purely - the widget layer holds the selection, this decides where a move lands.

Types

opt()

@type opt() ::
  {:initial_configuration, Enumerable.t()}
  | {:selection, selection()}
  | {:deep_link, String.t() | StatifierUI.Trace.DeepLink.t() | nil}
  | {:active_style, StatifierUI.Diagram.active_style()}

Options shared by the fold functions.

point()

@type point() :: %{
  macrostep: non_neg_integer(),
  event: String.t() | nil,
  quiescent?: boolean(),
  final?: boolean()
}

One selectable point, for a caller building a scrubber.

quiescent? says the macrostep settled, final? that it halted the run (sui-dc7). They are never both true, and a macrostep with a configuration to draw is one where either is - which is why final? was added beside quiescent? rather than widening it: a halting macrostep is not quiescent, and a scrubber saying so is not the same as one saying it has nothing to show.

selection()

@type selection() :: :live | {:macrostep, non_neg_integer()}

Which point in the run the panes show: the live tip, or one macrostep.

Functions

active_configuration(messages, opts \\ [])

@spec active_configuration([StatifierUI.Trace.Message.t()], [opt()]) :: [
  non_neg_integer()
]

The configuration messages and opts[:selection] imply.

On :live (the default): the configuration payload of the newest trace.macrostep_stable or trace.done, whichever arrived last, or opts[:initial_configuration] (default []) when neither is in view.

On {:macrostep, n}: the configuration macrostep n settled in - or, for the macrostep that halted the run, exited in - per StatifierUI.EventLog.configuration_at/2, falling back to the newest one below it when n stamped neither, and to opts[:initial_configuration] when nothing at or below n stamped one. A message list the log refuses (mixed sessions) degrades to the live reading rather than raising, same policy as event_log/2.

active_configuration_ids(messages, opts \\ [])

@spec active_configuration_ids([StatifierUI.Trace.Message.t()], [opt()]) ::
  {:ok, [String.t()]} | {:error, :no_manifest}

The configuration active_configuration/2 answers, as state IDs.

The indexes that function returns are the wire format's own document-order positions, which mean nothing outside the stream that produced them. A host that draws its own diagram - the debugger canvas in a block editor, say - works in the chart's ids, so this resolves them through the stream's own session.start manifest and hands back the names.

The resolution rule is StatifierUI.EventLog.Labels.state/2, the one the event log already renders indexes with: a state's "id", "<scxml>" for the synthesized root, and "#<index>" for a state the document left anonymous. There is no second answer to what an index is called.

A stream carrying no session.start returns {:error, :no_manifest} rather than a list of "#<index>" strings. That is the late-attach case (StatifierUI.Trace.Subscriber emits no manifest when it joins a session that already has one), and a caller marking a canvas needs to know it got no names rather than to mark nothing and call it an empty configuration. active_configuration/2 still answers the indexes there.

Nothing here re-derives a configuration or parses the manifest itself: both halves are reads of what the engine already stamped.

Examples

iex> {:ok, machine} =
...>   Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="pending"><state id="pending"/><state id="authorized"/></scxml>))
iex> {:ok, manifest} = StatifierUI.Trace.Manifest.build(machine, "sess_ops")
iex> StatifierUI.Inspector.active_configuration_ids([manifest], initial_configuration: [1])
{:ok, ["pending"]}

iex> StatifierUI.Inspector.active_configuration_ids([], initial_configuration: [1])
{:error, :no_manifest}

active_invokes(messages, opts \\ [])

@spec active_invokes([StatifierUI.Trace.Message.t()], [opt()]) ::
  {:ok, [{String.t(), String.t() | nil}]} | {:error, :no_manifest}

The invocations live at the point opts[:selection] names, as {state_id, invoke_type | nil} - the invoke half of active_configuration_ids/2.

An invocation is live from the effect.invoke that started it until the effect.cancel_invoke that ends it, both of which the engine stamps with the owning state_index (docs/wire-format.md). Nothing else is inferred: a stream that never reached the <invoke> seam answers {:ok, []}, and the pairing is by invoke_id, the format's own identity for an invocation. The list is in start order.

What the second element is, and is not

The wire format defines no outcome field for an invocation - its only outcome is trace.conds_evaluated's guard discriminator - so this pair's second element is the invoke_type an effect.invoke carries, nil when the <invoke> element set neither type nor typeexpr. A caller wanting to know how an invocation ended reads the done.invoke.* event in the event log; answering that here would mean this module deciding what "finished" means, which is the engine's call and not a read (ADR-0002's inherited clause).

{:error, :no_manifest} for the same reason and in the same case as active_configuration_ids/2.

Examples

iex> {:ok, machine} =
...>   Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="pending"><state id="pending"/></scxml>))
iex> {:ok, manifest} = StatifierUI.Trace.Manifest.build(machine, "sess_ops")
iex> StatifierUI.Inspector.active_invokes([manifest])
{:ok, []}

datamodel(messages, opts \\ [])

@spec datamodel([StatifierUI.Trace.Message.t()], [opt()]) :: String.t()

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.

Under opts[:selection] of {:macrostep, n} the pane folds the stream as it stood at the end of macrostep n rather than at the live tip (sui-2uz) - the same cut every other selection-aware read here takes, so the datamodel a caller shows beside a selected configuration is the one that configuration was reached with. On :live (the default) the whole list folds, byte-identical to what datamodel/1 always rendered.

Nothing is re-derived: the cut is a shorter prefix of the same messages, folded by the same build_live/2.

datamodel_diff(messages, opts \\ [])

@spec datamodel_diff([StatifierUI.Trace.Message.t()], [opt()]) :: String.t()

Markdown for the datamodel diff pane: what macrostep n changed in the datamodel, as a table (sui-2uz).

The two sides are the same stream cut twice - everything below macrostep n, and everything through it - folded by StatifierUI.DatamodelExplorer.build_live/2 and compared by StatifierUI.DatamodelExplorer.Diff.between/2. "Adjacent" is therefore the cut, not a pair of buckets: a macrostep the log holds no entry for still diffs correctly against whatever precedes it, and macrostep n's own diff against the session's seeded datamodel is what the first macrostep shows.

opts[:selection] picks n. On :live the newest macrostep in view is diffed, so a pane following the tip answers "what did the step that just happened change". A stream with no macrostep at all has nothing to diff and says so.

Options are opt/0's, plus StatifierUI.DatamodelExplorer.Diff.Markdown's :title and :empty_note, which are forwarded.

diagram(machine, messages, opts \\ [])

Mermaid stateDiagram-v2 source for the configuration pane: StatifierUI.Diagram.render/3 over active_configuration/2.

opts[:active_style] is passed through unchanged, so a host under a dark theme can drop the shipped classDef and style the active class from its own stylesheet - see StatifierUI.Diagram.

event_log(messages, opts \\ [])

@spec event_log([StatifierUI.Trace.Message.t()], [opt()]) :: String.t()

Markdown for the event log pane: StatifierUI.EventLog.build/1 rendered collapsible. The selected macrostep is opened and marked - shown in the diagram; with no selection the last macrostep is opened, as before. A build failure renders as a visible error line rather than raising - the inspector keeps showing the other panes.

persisted_status(messages)

@spec persisted_status([StatifierUI.Trace.Message.t()]) :: String.t()

The status header for a stream read back from storage rather than watched live.

A persisted stream has no StatifierUI.Trace.Subscriber behind it, so there is no status, no buffered or dropped count, and no diagnostics - and this says persisted rather than inventing them. StatifierUI.Live's status pane takes the same position for the same reason (status_kind(nil)), so both surfaces describe a reloaded trace the same way.

What a persisted stream can still say is whether it is projected: the projection header rides on its own session.start message (ADR-0012), so a reloaded capture that withholds values still announces that it does.

points(messages)

@spec points([StatifierUI.Trace.Message.t()]) :: [point()]

The macrosteps a caller can select, oldest first - the scrubber's own vocabulary, so a UI never has to fold the log itself. A log that refuses to build yields [].

resolution(messages, opts \\ [])

@spec resolution([StatifierUI.Trace.Message.t()], [opt()]) ::
  :live
  | {:quiescent, non_neg_integer()}
  | {:final, non_neg_integer()}
  | {:carried, non_neg_integer(), non_neg_integer()}
  | {:before_first, non_neg_integer()}

How the selection resolved, for a caller that needs to say so: :live, {:quiescent, n}, {:final, n} (macrostep n halted the run, so the configuration it exited in is shown), {:carried, n, from} (macrostep n stamped no configuration, so macrostep from's is shown), or {:before_first, n} (nothing at or below n stamped a configuration, so the initial configuration is shown).

selection_note(messages, opts \\ [])

@spec selection_note([StatifierUI.Trace.Message.t()], [opt()]) :: String.t()

The one-line note naming the point on screen, for display above the diagram: which macrostep and its event, whether the configuration was measured there or carried from an earlier macrostep, and - on :live - that the diagram is following the tip.

status(stats)

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).

step(selection, points, move)

@spec step(selection(), [point()], :live | :first | :prev | :next) :: selection()

Moves a selection one scrubber step over points, purely - the widget layer holds the selection, this decides where a move lands.

  • :live returns :live from anywhere.
  • :first selects the oldest macrostep in view.
  • :prev from :live pins the newest macrostep (the same picture, no longer following the tip); from a macrostep it selects the newest one below it, staying put at the oldest.
  • :next selects the oldest macrostep above the current one, and returns to :live once there is none - so a run driven forward while the scrubber sits at the tip keeps following it.

With no points at all every move returns :live: there is nothing else to show.