runview

package
v0.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package runview folds a run's recorded events into the step tree a reader sees. It is the one fold both front ends draw from — the browser transcript (internal/web) and the terminal — so the two cannot disagree about what a run looked like.

A run's events arrive as a flat ordered log (because that is what actually happened, including concurrent steps interleaving), and a reader needs them as steps with their traffic underneath. Everything here does that reshaping and nothing else — no queries, no rendering — so the shapes are testable without a server or a terminal.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func EventOf

func EventOf(row store.RunEventRow, job string) events.Event

EventOf is rowOf's inverse, for replaying a recorded run. job is the run's, since run_events does not store it and Live's header and summary name it.

func Log

func Log(ctx context.Context, logger *slog.Logger) func(events.Event)

Log renders a run as log lines, for a process whose runs are read in the browser rather than on its terminal: what Plain prints becomes debug detail, except a note, which keeps its level, and nothing else is said — the job's start and end are logged where the drain runs it, and every byte a step wrote is in the run's record.

A nil logger is whichever slog.Default() is current when each line is written, rather than the one current when the observer was made.

func Plain

func Plain(w io.Writer) func(events.Event)

Plain renders a run as the terminal has always read it, one line per thing that happened, for a bus observer to call. Output is not among them: it streams as it is written.

func Scrub

func Scrub(text string) string

Scrub drops every escape sequence and control character but tab and newline, with web.frameLines' rule for carriage returns.

func ScrubEvent

func ScrubEvent(event events.Event) events.Event

ScrubEvent makes a recorded event safe to print to somebody else's terminal: its text can come from untrusted input — a PR under review — and an OSC sequence in it writes the viewer's clipboard or retitles their window.

Every printed field, not only the free-text ones: a job name is unvalidated YAML, and the pipeline can come from the same untrusted branch.

func Slug

func Slug(name string) string

Slug renders a step name as a URL fragment: lowercase, with every run of non-alphanumerics collapsed to a single dash. An across: cell named review[security] becomes review-security, so a step is linkable by name rather than by position alone.

Types

type Call

type Call struct {
	Name     string
	OK       bool
	ArgsJSON string
}

Call is one recorded tool call read back from a node's result.

type Change

type Change struct {
	Opened bool
	Closed bool
	Other  bool
	Turns  int
	// Streamed counts output chunks that arrived, which like Turns a reader's copy can be appended to rather than redrawn.
	Streamed int
}

Change is what one batch of events did to a step's row, in the terms the live stream decides by: whether the row itself came or went, how many turns were hung under it, and whether anything else about it moved.

Turns are counted rather than flagged because they are the one change the stream can send as an APPEND — the turn's own markup, not the row's — and only when nothing else about the row moved in the same batch. An agent step that spoke three hundred times used to be re-sent whole on each of them.

func (Change) Any

func (c Change) Any() bool

Any reports whether the change moved anything a reader would see.

func (Change) Merge

func (c Change) Merge(other Change) Change

Merge folds a later change on the same row into this one.

type Folder

type Folder struct {
	// contains filtered or unexported fields
}

Folder is the fold itself, kept open.

The page reads a run once and folds every event in one go; the live stream folds each flush into the view it already has, which is the only way it can render a delta without re-reading the whole run 2.5 times a second — and without stopping dead at runEventLimit, which is what a re-read does to a run longer than that. Both go through this type rather than through two folds that would have to agree.

func NewFolder

func NewFolder() *Folder

NewFolder is an empty fold.

func (*Folder) Add

func (f *Folder) Add(rows []store.RunEventRow, results map[string]store.NodeRow) map[string]Change

Add folds one batch of events in, in order, reporting what changed on which steps' rows as a result.

The fold reports it rather than the caller reading it off the events, because the two disagree exactly where it matters: a sub-agent's turns carry the CHILD's name, and attachTurn hangs them on the plan step still running. A caller deriving the set from stepKey(row) alone therefore holds a key belonging to no row — and the live stream, which sends only the rows its batch named, sent NOTHING for the whole of a sub-agent conversation.

func (*Folder) Steps

func (f *Folder) Steps() []*Step

Steps is every step folded so far, flat and in start order, without re-hanging the tree the way View does.

func (*Folder) View

func (f *Folder) View(run store.RunRow) Transcript

View is what has been folded so far, with the tree hung and the run row as it stands — the row keeps changing under a live fold, and it is read for the job error the step template asks each row about. Safe to call after every batch: linkTree rebuilds the parent links rather than adding to them, and settle recomputes which steps went unreported, so a close that lands after the run row went terminal still wins on the next call.

type Live

type Live struct {
	// Width is the terminal's width in columns, asked on every draw so a resize needs no signal handler.
	Width func() int
	// Height, when set, is the terminal's height in rows: a region taller than the screen cannot be moved back over, and every redraw would leave a copy of it in scrollback.
	Height func() int
	// Color allows SGR styling; cursor movement is used either way, since the region cannot be redrawn without it.
	Color bool
	// Spend, when set, is what a run has cost so far, for the header; asked when an agent step finishes, which is when spend is recorded.
	Spend func(runID string) string
	// contains filtered or unexported fields
}

Live draws a run the way buildx does. A finished step leaves one line in the terminal's own scrollback; a region at the bottom is redrawn in place with what is running now — each step's elapsed time, the last lines it printed, an agent's calls. Display only: it reads no keys, so the terminal stays the person's.

func NewLive

func NewLive(term io.Writer) *Live

NewLive draws onto term, which must be the terminal: the region is redrawn with cursor movement.

func (*Live) Event

func (l *Live) Event(event events.Event)

Event is the bus observer.

func (*Live) Hold

func (l *Live) Hold() (io.Writer, func())

Hold takes the region off the screen and hands the terminal to a prompt until release. Installed as events.Output.Hold.

func (*Live) Log

func (l *Live) Log() io.Writer

Log is a writer for whole log records, printed into scrollback above the region rather than through it.

func (*Live) Start

func (l *Live) Start()

Start redraws on a tick because an elapsed clock must keep moving while no event arrives.

func (*Live) Stop

func (l *Live) Stop()

Stop leaves only scrollback behind: a region left on screen would read as a run still going.

func (*Live) Stream

func (l *Live) Stream(stepID int64, _ bool) io.Writer

Stream is where one step's bytes go: into its tail, shown while it runs. Installed as events.Output.Step.

type Note

type Note struct {
	Level string
	Text  string
	At    time.Time
}

Note is one TypeStepNote, as a reader sees it.

func (Note) Warn

func (n Note) Warn() bool

Warn reports a note worth a reader's attention, not only their record.

type Step

type Step struct {
	// ID and ParentID are the run's display tree (see events.Event). Zero on
	// a run recorded before the tree existed, which folds back to the flat
	// list this page used to be.
	ID       int64
	ParentID int64
	// Children are the steps that ran inside this one, in start order.
	Children []*Step
	// FirstSeq is the sequence of the event that PUT this step on the page,
	// which is what the live stream asks when deciding whether the reader
	// already has the row. Not always a step.started: a step swallowed by a
	// chain skip is opened by its step.skipped, and asking about starts alone
	// meant such a row was swapped over an id that was never drawn — an
	// out-of-band swap htmx drops in silence.
	FirstSeq int64
	Index    int
	Name     string
	Kind     string
	Status   string
	Hash     string
	Reason   string // why it was skipped, when it was
	Error    string
	// Worker is where the step's commands ran, "tag (address)", empty for
	// this machine. Drawn because a red step on a fleet otherwise names no
	// machine — see events.Event.Worker.
	Worker   string
	Duration time.Duration
	Started  time.Time
	// Deadline is when a running agent step's resolved timeout: expires —
	// Started plus the ceiling. Never set by the fold: the caller holding the configuration fills it in (web's attachStepDeadlines).
	// Zero (HasDeadline false) for a finished or non-agent step, an
	// unlimited timeout, or a run whose configuration no longer matches
	// what is loaded — the same "unknowable, not uncapped" reasoning
	// web's runView.Ceilings documents.
	Deadline time.Time
	// Timeout is the agent step's resolved per-attempt timeout:, finished or
	// not — what Slow measures a request against. Never set by the fold: the
	// caller holding the configuration fills it in (web's
	// attachStepDeadlines). Zero for unlimited, unknowable or non-agent.
	Timeout time.Duration
	// Ended is when the step's end event was recorded, zero while it runs.
	Ended time.Time
	// Turns is agent conversation traffic that arrived while this step was
	// the one running. Empty for every other kind.
	Turns []Turn
	// Result is the node's recorded result, decoded — the verdict, the
	// response, the trajectory. Nil when the step recorded none.
	Result map[string]any
	// Outputs is what the step printed, one entry per output event.
	//
	// A slice rather than a string because a step with attempts: publishes one
	// output per attempt, and the live stream appends a block for each. Held as
	// a single overwritten string, the page a reader watched three attempts on
	// would silently drop two of them at the closing reload.
	Outputs []string
	// Streaming is what a running step has printed so far, a chunk per event (TypeStepOutputChunk). Emptied by the step's TypeStepOutput, which records the same bytes whole.
	Streaming []string
	// Notes is what the step's machinery said about it, in order.
	Notes []Note
	// contains filtered or unexported fields
}

Step is one step of a run, with whatever the step produced beneath it — including, for a block step, the steps that ran inside it.

func (Step) Active

func (s Step) Active() bool

Active reports a step still running, or holding something that is.

It is what lights the rail down the branch the work is actually on, so a reader who has folded half the page still knows where to look. Recursive rather than a flag set at fold time, because a container's own status stays running until every child has finished — the two answers agree, and this one needs no second pass to maintain. The children are asked first because a step's hooks run after it has finished.

func (Step) Anchor

func (s Step) Anchor() string

Anchor is the step's own id in the page, and the target of the # link beside its name.

Built on the step occurrence rather than on (index, name), because those two are shared: a try: and the step inside it produced the SAME anchor, so the page carried duplicate ids and a link pasted at someone opened the wrapper instead of the step they meant. The slug stays for a human reading the URL; the id is what makes it point at one row.

func (Step) AnswerTurn

func (s Step) AnswerTurn() Turn

AnswerTurn is the turn Conversation dropped for duplicating the Response, so the answer block can still say how long its request took — often the longest of the step. The zero Turn when nothing was dropped.

func (Step) Block

func (s Step) Block() bool

Block reports a step that contains plan steps, as distinct from a step whose only children are its hooks: a task with an ensure: is still a task, and the questions a block answers (open it, roll it up, leave its output to the steps inside) are wrong for it.

func (Step) CompactionStalled

func (s Step) CompactionStalled() bool

CompactionStalled reports a conversation whose recent turns alone outgrew the compaction budget, so compaction gave up for the rest of it.

func (Step) Compactions

func (s Step) Compactions() int

Compactions is how many times the step's conversation had its older turns summarized away, 0 when it never did. A result decoded from the store carries JSON's float64; one built in process carries an int.

func (Step) Container

func (s Step) Container() bool

Container reports a step that ran other steps inside it, hooks included: it is what draws a subtree at all.

func (Step) Conversation

func (s Step) Conversation() []Turn

Conversation is the step's turns with the one redundant turn dropped: the model's last text is normally the answer the Response block already shows in full, and printing a whole response twice on one page — once mid-transcript, once labeled — is noise exactly where a reader is trying to find the answer.

Only that turn, and only when it matches: a model's running commentary mid-conversation is not the answer, and a response the model never said in a text turn (a wrapped-up conversation, a verdict-only step) still has to appear.

func (Step) Elapsed

func (s Step) Elapsed() time.Duration

Elapsed is how long a running step has been running, for the row's own clock. The page's timer script keeps it counting; this is what it reads before the first tick, and what a reader with scripting off sees.

func (Step) Failed

func (s Step) Failed() bool

Failed reports a step that ended badly, by any classification.

func (Step) HasBody

func (s Step) HasBody() bool

HasBody reports whether the step produced anything to show under its own row — as distinct from the steps that ran INSIDE it, which are a subtree.

The two are separate because the body is rendered at all only when there is one: an empty .stepbody still carries its padding, which on a container opened a visible gap between the block and the first step inside it.

func (Step) HasDeadline

func (s Step) HasDeadline() bool

HasDeadline reports whether Deadline could be resolved for this step.

func (Step) HasDetail

func (s Step) HasDetail() bool

HasDetail reports whether the step has anything to show when expanded. A step with no body must not be foldable: an expandable row that opens onto nothing reads as a broken page, and a chevron that promises detail there isn't is worse than no chevron.

func (Step) Hook

func (s Step) Hook() bool

Hook reports a hook's own row — an on_failure/ensure/… that ran after the step or job it guards, rather than a step of the plan.

func (Step) Key

func (s Step) Key() string

Key identifies this row to the live stream, which must find the row the server already drew rather than appending a second one. Mirrors stepKey.

func (Step) Note

func (s Step) Note() string

Note pulls the verdict's note.

func (Step) OpenByDefault

func (s Step) OpenByDefault() bool

OpenByDefault reports a row the page draws expanded: only the path to a failure or to live work, because a reader opening a finished run came for what broke, and every passed transcript drawn open buries it. Everything else is one click (or e) away.

An abort beside a sibling that really failed does not open: fail_fast aborts the rest of its branches, and opening each would rebuild the wall around the one real failure. Any other abort does — a timeout or a user abort is the failure, and its output is why the run stopped. A passed block never opens for what is inside it — a failure under one was tolerated by design (a try:, a race: loser), the rollup still says so, and f still finds it. A running hook does not open its step: the row would fold shut under the reader the moment the hook passed. A put opens on the version it produced.

func (Step) Remaining

func (s Step) Remaining() time.Duration

Remaining is how long until Deadline, for the row's own clock — the countdown counterpart to Elapsed, floored at 0 rather than going negative for the rare page load that lands after the deadline technically passed but before the step's own end event has arrived.

func (Step) Response

func (s Step) Response() string

Response pulls the agent's final answer.

func (Step) Rollup

func (s Step) Rollup() Tally

Rollup summarises the step's DIRECT children — the unit a reader counts. A matrix reports its cells, not the tasks and agents inside them, which is the number the pipeline itself printed when it fanned out.

func (Step) Running

func (s Step) Running() bool

Running reports a step that started and has not reported an end, on a run that has not ended either.

func (Step) Skipped

func (s Step) Skipped() bool

Skipped reports a step that did not execute.

func (Step) Slow

func (s Step) Slow(took time.Duration) bool

Slow reports a request that took more than a tenth of the step's timeout. Never with no known timeout — unknowable, not uncapped, as for Deadline.

func (Step) Trajectory

func (s Step) Trajectory() []Call

Trajectory is the tool calls a step's node recorded, for the steps whose conversation never reached the event bus.

A CLI-backed agent (source.model: "@claude/sonnet") owns its own tool loop in a subprocess, so it publishes no turns — but the calls it made are parsed out of the CLI's stream and stored in the node's result either way. The page showed the final answer and nothing about how it got there, while the record of exactly that sat one field away in a map it had already decoded.

Empty when the step DID publish turns: those are the same calls, live and in order, and rendering both would show every tool call twice.

func (Step) Unanswered

func (s Step) Unanswered() Turn

Unanswered is the request a failed step ended waiting on, as a turn whose Took is how long it had been in flight — the number a timed-out step is read for, which no recorded turn carries because the reply never came. The zero Turn when the step did not fail, or was not waiting on the model. Failed only: a step that passed was not cut off.

ponytail: a tool result is taken as the next request going out, so a step failed by what the loop decided right after one (loop detection, a required tool never satisfied, an expired question) is drawn as waiting on a request never sent. The events carry no "request sent" marker; the agent already times each request in nextResponse, and publishing that is the fix.

func (Step) Unreported

func (s Step) Unreported() bool

Unreported reports a step the run ended without hearing the end of. Neither running nor the run's outcome: it never said how it did, and claiming "failed" for it would be as much a guess as the clock that kept ticking.

func (Step) Verdict

func (s Step) Verdict() string

Verdict pulls the routing verdict out of an agent step's result.

func (Step) WrappedUp

func (s Step) WrappedUp() bool

WrappedUp reports a step whose conversation ran out of turns and was asked to answer from what it had already gathered.

Worth its own marker for the reason the runner records it at all: the answer is degraded, and afterwards it is indistinguishable from a confident one. It is the counterpart of Truncated() on the spend panel — that one is the model's output being cut off mid-sentence, this one is the step's turn budget running out — and reading them together is how an author tells "the model had nothing more to say" from "the model was stopped".

type Tally

type Tally struct {
	Cells      int
	Passed     int
	Failed     int
	Running    int
	Skipped    int
	Unreported int
}

Tally counts how a container's subtree came out, for the row itself. A folded block still has to answer "where does this stand", and the rows that would otherwise answer are folded away with it.

func (Tally) Empty

func (r Tally) Empty() bool

Empty reports a Tally with nothing to say, which is not rendered.

A container holding ONE step says nothing a reader cannot read off that step's own row — a try: wrapping a task would otherwise carry a permanent "1 step · 1 passed" that is pure furniture. Unless something inside went wrong: a failure has to survive the fold, however few steps it took.

type Transcript

type Transcript struct {
	Run store.RunRow
	// Steps is every step of the run in start order, flat. The page renders
	// Roots instead; this is what the run-level questions (what changed, what
	// was cached) are still asked of, because they are about the run and not
	// about its shape.
	Steps []*Step
	// Roots are the steps at the top of the plan, each holding its subtree.
	Roots    []*Step
	JobError string
	// Notes are the run's notes that belong to no step it recorded: said at the job level, or by a step whose start never reached the log.
	Notes []Note
	// LastSeq is the highest event sequence this view already renders, and it
	// is what the live stream must resume AFTER.
	//
	// Without it the stream opened at ?after=0 and replayed events the page
	// had already drawn, appending a second copy of every turn and every line
	// of output a run had produced before the tab was opened — visible on
	// exactly the page a person opens while a run is in flight.
	LastSeq int64
}

Transcript is the part of a run the event log alone determines.

func Build

func Build(run store.RunRow, rows []store.RunEventRow, results map[string]store.NodeRow) Transcript

Build folds a run's ordered events into steps.

Steps are keyed by (index, name) rather than index alone: an across: cell and its siblings all report their parent's plan index, and collapsing them onto it would render a fan-out as one flickering step instead of the several concurrent ones it is.

func (Transcript) HasSkipped

func (r Transcript) HasSkipped() bool

HasSkipped reports whether any step replayed from cache. The page explains folding only when there is something folded — an explanation of a mechanism the reader cannot see on the page is noise.

func (Transcript) InnermostFailure

func (r Transcript) InnermostFailure() *Step

InnermostFailure is the step that actually broke: the first failed step, in plan order, with no failed step inside it. Every failed ancestor is red only because of it, which is why the page's f key and its header both point here rather than at the outermost block. Nil on a run with no failure.

Hooks bend that in two directions. A failed step's hooks only reacted to its failure, so a hook that broke too is not the cause. A green step whose on_success or ensure failed was turned red by that hook, while its own row stays green: there, the hook IS the cause.

func (Transcript) Running

func (r Transcript) Running() bool

Running reports a run still in flight, which is what decides whether the page opens a live event stream.

type Turn

type Turn struct {
	// Seq is the event this turn was folded from, which is how the run page
	// fetches a result's body only when a reader opens it.
	Seq    int64
	Type   string
	Text   string
	Name   string
	Detail string
	Depth  int
	At     time.Time
	// Took is how long the model request this turn answered was in flight,
	// set by the fold on the first model turn after the request was sent;
	// zero on every other turn and whenever a timestamp is missing.
	Took time.Duration
}

Turn is one piece of agent conversation traffic.

func (Turn) IsCompaction

func (t Turn) IsCompaction() bool

IsCompaction reports the point older turns were replaced by a summary — drawn as a boundary, since from there on the summary is what the model had.

func (Turn) IsMessage

func (t Turn) IsMessage() bool

IsMessage reports a turn that was SENT to the model — a system prompt or a user message — as opposed to one it wrote. The template branches on this rather than on a third copy of the type-string list, so run.html and node.html can agree on which turns are shown literally and coloured versus rendered as prose (see prose.go's file comment).

func (Turn) IsModelText

func (t Turn) IsModelText() bool

IsModelText reports the model's own running commentary or final answer.

func (Turn) IsResult

func (t Turn) IsResult() bool

IsResult reports a tool's answer, the turn the run page draws lazily.

func (Turn) Nested

func (t Turn) Nested() bool

Nested reports a turn belonging to a delegated sub-agent rather than the step's own conversation.

func (Turn) Sent

func (t Turn) Sent() time.Time

Sent is when the request this turn answered went out.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL