Powered by AppSignal & Oban Pro

Statifier inspector

notebooks/inspector.livemd

Statifier inspector

Mix.install([
  {:kino, "~> 0.14"},
  {:statifier_ui, path: Path.join(__DIR__, "..")}
])

What this notebook is

The Livebook inspector end to end, driving a small card-authorization chart - the same domain, and the same pending -> authorized -> captured spine, the README's worked example uses. 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.

xml = """
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" initial="pending" version="1.0">
    <datamodel>
        <data id="amount_cents" expr="1999"/>
        <data id="captured_cents" expr="0"/>
        <data id="authorization_attempts" expr="0"/>
    </datamodel>
    <state id="pending">
        <transition event="authorize.declined" target="pending">
            <assign location="authorization_attempts" expr="authorization_attempts + 1"/>
        </transition>
        <transition event="authorize.approved" target="authorized">
            <assign location="authorization_attempts" expr="authorization_attempts + 1"/>
        </transition>
    </state>
    <state id="authorized">
        <transition event="capture.settled" target="captured">
            <assign location="captured_cents" expr="amount_cents"/>
        </transition>
    </state>
    <final id="captured"/>
</scxml>
"""

{:ok, machine} = Statifier.compile(xml)

One deliberate difference from the README's chart: a declined authorization here returns to pending rather than reaching a final declined state, so the card can be retried. The walk below needs one event that takes a real step without moving the highlight, and that self-transition is it.

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.

{:ok, fixtures} =
  StatifierUI.Fixtures.new(
    events: %{
      "authorize.approved" => %{"amount_cents" => 1999, "currency" => "USD"},
      "authorize.declined" => %{"reason" => "insufficient_funds"},
      "capture.settled" => %{"captured_cents" => 1999}
    }
  )

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.

{:ok, session} = Statifier.Session.start_link(machine, trace: true, record: true)

4. Open the inspector

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. Scrubber row - four buttons, |< First, < Prev, Next >, Live - and under them the line Showing the live tip.
  3. Configuration diagram (left) - the chart as a Mermaid state diagram with pending highlighted. The initialize burst happened before this cell ran; catch-up is why the diagram still knows about it.
  4. Datamodel explorer (right) - amount_cents at 1999, captured_cents at 0, and authorization_attempts at 0.
  5. Injection pane - one button per fixture event, then a free-form name/payload form.
  6. Event log - macrostep 1 (the initialize burst), collapsed except the last macrostep, and nothing marked "- selected" while the scrubber is live.

5. Walk the chart from the palette

Do these in order, watching the panes after each click:

  1. Click authorize.declined. Expect: feedback line "Sent authorize.declined ..."; the highlight stays on pending, and that is correct - the self-transition still exits and re-enters, so the datamodel shows authorization_attempts as 1 with a changed marker and the event log grows a macrostep whose cause is authorize.declined. A step the diagram cannot show is exactly what the log is for.

  2. Click authorize.approved. Expect: highlight moves to authorized; authorization_attempts reads 2; the log's newest macrostep names authorize.approved.

  3. Click capture.settled. Expect: captured_cents reads 1999 with a changed marker, copied from amount_cents by the transition's <assign>; the log's newest macrostep names capture.settled and carries the entry of captured; the status header still says attached - a halted chart's session process is alive, and the inspector keeps its whole trace readable.

    Expect the highlight to move to captured. Entering a top-level <final> halts the run, so this macrostep never reaches quiescence and ends in trace.done rather than trace.macrostep_stable. trace.done carries the configuration the run exited in, and StatifierUI.Inspector.active_configuration/2 reads it (sui-dc7) - so the diagram shows where the chart ended, not the state it left.

6. Scrub back through the run

The scrubber moves the diagram to any macrostep in the log while the log itself stays whole. Nothing is recomputed: every configuration it can show was stamped by the engine on a trace.macrostep_stable, and a caught-up stream got there through statifier ADR-0034 replay.

  1. Click |< First. Expect: the note reads Showing macrostep 1 (initialize), at its quiescent configuration, the diagram highlights pending, and macrostep 1's entry in the log is open and suffixed - selected. Exactly one entry carries that suffix.
  2. Click Next > repeatedly. Expect: the note and the marked log entry advance together, macrostep by macrostep; the highlight follows what each macrostep settled in (so it sits on pending through the declined attempt, then moves to authorized).
  3. Keep clicking Next > past the newest macrostep. Expect: the note returns to Showing the live tip, no entry is marked, and the diagram is following the run again.
  4. Click < Prev from the live tip. Expect: the newest macrostep is pinned - the same picture as the live tip, but no longer following it, and its log entry now carries the marker. Live clears both.
  5. Select the macrostep that entered captured - the halting one. Expect the note to say at the final configuration the run halted in, and the diagram to highlight captured. The halting macrostep stamped its configuration on trace.done rather than on a trace.macrostep_stable (sui-dc7), and the note keeps the two readings apart: a configuration the chart exited in is never worded as one it settled in.

The datamodel explorer in this widget does not move with the scrubber - it always shows the current values. The per-macrostep fold exists (StatifierUI.Inspector.datamodel/2 takes the same :selection every other read here does, sui-2uz), and the persisted stepper in step 10 is built on it; wiring it into the live inspector's own pane is a separate change. Here the diagram and the log are the pair this walk exercises.

7. The form, including its error path

  1. In the free-form fields, enter name authorize.declined and payload {"reason": "manual"}, press Send. Expect: "Sent" feedback, and no new trace message anywhere - not a macrostep, not even an event-received line. The chart halted when it entered captured, so the run is over and the event is dropped rather than processed. "Sent" is honest about what the injection pane did (it handed the event to the session); the empty log is honest about what the session did with it. Worth seeing both, because they are two different facts.
  2. Now enter payload {not json. Expect: Not sent, with the JSON error ({:invalid_json, ...}) - the session was never touched, because the draft failed to build before anything was sent.

8. 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, authorization_attempts is 2, captured_cents is 1999, and the diagram highlights captured - the halted run's final configuration, read from trace.done (sui-dc7). That round trip is catch-up doing its job: a fresh widget over the same recorded session reproduces the previous one exactly, halt included.

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.

9. Session death

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

10. Step through the run after it is over

Save the recording and reopen it. This is StatifierUI.Trace.Capture's record / save / reload, and the reopened widget is a stepper rather than a snapshot.

{:ok, messages} = StatifierUI.Trace.Capture.record(session, machine, source: xml)
path = Path.join(System.tmp_dir!(), "authorization.jsonl")
:ok = StatifierUI.Trace.Capture.save(messages, path)

StatifierUI.Kino.inspect_trace(path)

Expect: the status header says persisted and reports no subscriber counts (there is no subscriber), and above the diagram sit the same four scrubber buttons plus a Jump to select listing every macrostep by number and event.

  1. Pick a macrostep from Jump to. Expect: the note, the diagram, the event log's marked entry, and the datamodel pane all move to that point together - unlike step 6, the values shown are the ones that macrostep ended with, not the ones the run finished with.
  2. Read the Datamodel changes in macrostep N table below the panes. Expect: one row per slot the step moved, with the value before and after. A step that assigned nothing says so in words rather than showing an empty table.
  3. Step to the initialize macrostep. Expect: each declared <data expr> shows as a change from :undefined to its declared value - the evaluation of the declaration is something the run did, and the diff says so.
  4. Click Next > past the newest macrostep. Expect: the selection returns to the end of the recording. A file has no tip that moves, so "Live" here means the last thing that happened.

Nothing in this step asks the engine for anything. Every configuration and every value came out of the file, and the file's contents were produced by statifier ADR-0034 replay re-driving the core at capture time. Stepping is a read of a shorter prefix of the same messages.

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.