watch

package
v0.0.0-...-606a6a1 Latest Latest
Warning

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

Go to latest
Published: Aug 30, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package watch is the TUI `flow watch` draws while a run is going, and the state machine both the live view and the plain, line-per-change shape fold their answers into.

Split out of cmd/flow by #410: the two files here used to sit in `package main` at 1,318 lines between them, which is where a bubbletea model's inputs stay implicit — a test could only drive it by importing the whole CLI. Everything this package needs from cmd/flow that it cannot get for itself — how a status maps to a colour, how a run's position and its retries render into prose — arrives through Deps, set once by the caller. What stays in cmd/flow is the cobra command, the flags, and the transport: building a client, classifying a refusal against `--address`, and writing the run document `--output` asked for are all decisions only the CLI's own flags can make, and none of them belongs to a state machine that a test should be able to drive with a fake poller and nothing else.

The state machine, folded into by both shapes

State is the run as a watch has seen it, and the decision of when to stop. One state machine, folded into by the live view and the plain lines alike, so "has anything changed", "is this over", and "has the server been quiet too long" cannot get two answers — see State.Absorb.

Index

Constants

View Source
const MaxVisibleSteps = 12

maxVisibleSteps caps the list on a terminal tall enough not to need capping.

A run with two hundred completed steps has nothing to say in lines one to one hundred and eighty that the count does not say better, and the interesting end of the list is the recent end.

View Source
const OutageAllowance = 30 * time.Second

OutageAllowance is how long the server may be unable to answer before a watch gives up on it.

Some allowance is not indulgence: the reason to watch rather than to loop `flow get` is that a watch lasts as long as the run, and over an hour a server restart or a dropped connection is close to certain. A watch that dies on the first one sends people back to the shell loop, which retries by construction.

Measured as elapsed time, from the clock, and not as a number of attempts. Attempts were the first attempt at this and they were wrong twice over: an interval of ten seconds gave up after twenty while reporting thirty, and a server that accepted a connection and then said nothing produced no attempt at all, which left the allowance never starting and the watch hanging until somebody killed it. Whenever a bound is stated in one unit and enforced in another, the difference is where the peer gets to live.

Variables

This section is empty.

Functions

func CompletedSteps

func CompletedSteps(response *v1.GetResponse) []string

CompletedSteps lists the ids of steps that have produced outputs, in order.

Sorted because the outputs arrive in a protobuf map, which has no iteration order — an unsorted list would reshuffle itself on every redraw and read as though the run were going backwards.

func TerminalStatus

func TerminalStatus(status v1.RunResponse_Status) bool

TerminalStatus reports whether a run has stopped moving.

UNSPECIFIED is deliberately absent: it is not a run in progress, it is a server that has not answered the question, and Absorb refuses it rather than waiting on it.

Types

type Deps

type Deps struct {
	// StatusTone maps a status onto the palette's outcome roles.
	StatusTone func(v1.RunResponse_Status) ui.Tone

	// StatusLabel renders a status the way a column or a pill wants it.
	StatusLabel func(v1.RunResponse_Status) string

	// PositionPath renders a run's progress as bare text, with no styling —
	// what [State] compares across polls to decide a run has moved, and what
	// the live view puts on a line of its own.
	PositionPath func(*v1.RunProgress) string

	// RunPosition renders a run's progress as the themed, sentence-shaped
	// form the plain, line-per-change shape appends to its status line.
	RunPosition func(ui.Theme, *v1.RunProgress) string

	// PendingActivityLines renders what Temporal is retrying, one sentence
	// each, against the moment the answer was observed.
	PendingActivityLines func(*v1.GetResponse, time.Time) []string

	// PendingWaitLines renders the gates a run is parked on, one sentence
	// each, against the moment the answer was observed.
	PendingWaitLines func(*v1.RunProgress, time.Time) []string
}

Deps are the rendering functions this package borrows from cmd/flow rather than owning a second copy of.

All five are pure functions of a status, a progress, or a slice of pending work — no flags, no client, no I/O — so passing them in is only naming which package keeps the single implementation. `flow get` and `flow watch` share exactly these, on purpose: see positionPath's package doc in cmd/flow/get.go for why a position rendered twice is how a watch and a `flow get` come to disagree about where a run is. Every field is required; a nil field panics on first use rather than rendering silently wrong.

type Model

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

Model draws a run as it goes.

The state is a pointer to the shared machine rather than a copy of its fields, which is the one place this departs from the value-model convention bubbletea invites. The alternative is a second implementation of "what changed and when do we stop", and the whole reason both shapes are correct is that there is only one.

func NewModel

func NewModel(
	ctx context.Context,
	surface *ui.UI,
	deps Deps,
	poller Poller,
	interval time.Duration,
	workflowID string,
	known *v1.GetResponse,
	options ...Option,
) Model

NewModel builds the live view.

func Run

func Run(
	ctx context.Context,
	surface *ui.UI,
	deps Deps,
	poller Poller,
	interval time.Duration,
	workflowID string,
	known *v1.GetResponse,
	options ...Option,
) (Model, error)

Run draws a run until it finishes or the person quits, and returns the model it ended with.

Model.Quit is true when the program ended without an outcome to report: the person pressed q/esc/ctrl+c, or the context was cancelled from outside (ctrl+c on the process, or a caller with its own reason to stop). Nothing about the run went wrong in that case, so nothing here is a failure — the caller decides what, if anything, it owes its own caller about a walk that stopped before the run did; see cmd/flow's watchEnding.

func (Model) Fetch

func (m Model) Fetch() tea.Cmd

Fetch performs one request, returning the StateMsg the answer folds into. Exported so a test can drive Model.Update with the message a real poll would have produced, without a program to run one.

func (Model) Init

func (m Model) Init() tea.Cmd

Init asks immediately rather than after one interval, so the first frame says something about the run instead of saying nothing for a second.

func (Model) Key

func (m Model) Key(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)

Key handles the keyboard.

Three spellings of stop and nothing else. A watch has no state to navigate — the step list is short and it is already all on screen — so every additional binding would be a thing to discover that does nothing. `q` because it is what every full-screen terminal program uses, `esc` because it is what people who do not know that press, and `ctrl+c` because a program that ignores it is a program people have to kill.

Exported for the same reason Model.Fetch is: a test drives the keyboard the way a real terminal would, through Model.Update, but a test asserting *only* that a key produces no command has no message loop to read one back from.

func (Model) Quit

func (m Model) Quit() bool

Quit reports that the person asked to stop watching, so the walk's ending is not the run's outcome.

func (Model) State

func (m Model) State() *State

State is the run this model has drawn, as of its last folded message.

func (Model) Update

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update folds one message in.

func (Model) View

func (m Model) View() tea.View

View draws the run.

Everything here survives its own removal: the status is a word before it is a colour, a step is named before it is marked, and the elapsed time is prose. Strip every escape sequence and the screen still says what is happening — which is what a screen reader receives, and what a `script(1)` capture of a CI job contains.

Nothing on screen may be wider than the terminal, and there are two ways to arrange that. Identifiers and marks are *trimmed*: an id too long to fit is looked up rather than read, a reader who wants the whole of one has `flow get`, and what matters here is that the line below it does not move. Prose is *wrapped*, by [Model.note] — see there for why that exception is the important one.

func (Model) ViewWidth

func (m Model) ViewWidth() int

ViewWidth is the columns this view may use.

Two sizes reach the model — the one detected for the stream and the one bubbletea reports on a resize — and both are recorded as given. The bound is applied here, at the single point of use, rather than at each of them: one clamp and one fallback, which is what stops a resize from quietly escaping a rule the initial size obeyed.

ui.ClampWidth is the same answer every surface that prints uses. A repainting view wider than a printed table is two answers to "how wide is the text", in one program.

func (Model) VisibleSteps

func (m Model) VisibleSteps() int

VisibleSteps is how many step lines fit, given the terminal's height and what the rest of the view occupies.

The chrome is eight-ish lines plus whatever the run itself added above the list — the position, and a sentence per activity being retried. Counted rather than assumed, because those lines appear exactly when a run is in trouble, which is when the screen is most worth reading.

A floor of three rather than zero: a terminal too short to show the list is better served by a short list that scrolls the header off than by a view that silently stops reporting progress at all.

type Option

type Option func(*State)

Option adjusts how a walk describes the run it is following.

Variadic and applied last, rather than a further string parameter on NewState and NewModel: those already take a workflow id, and two adjacent strings whose meanings differ is a call site that can be wrong while compiling. A named option cannot be passed in the wrong position.

func Named

func Named(subject string) Option

Named says what to call the run in prose, for a caller that knows the workflow's own name.

Only [State.subject] moves. The workflow id stays what every message about addressing the run uses — the `flow watch` hint, State.WorkflowID, the error a failed run exits with — because a name is not something another command can be pointed at.

An empty or blank name is ignored rather than accepted, so a workflow with no usable name falls back to the id instead of being narrated as `workflow ` with nothing after it.

type PollMsg

type PollMsg struct{ At time.Time }

PollMsg is the clock: its time is what elapsed is measured against.

type Poller

type Poller interface {
	Poll(ctx context.Context) (*v1.GetResponse, error)
}

Poller is the run state a follow renders, behind an interface so both shapes can be driven without a server.

The parts most likely to be wrong are the ones a fake can exercise: an off-by-one in a step list, a terminal status that does not stop the loop, a transient error that ends a watch it should have survived. What a fake cannot tell us is whether a real `Get` returns what this package thinks, which is why cmd/flow's implementation issues the same request `flow get` does rather than a second opinion about it.

type Progress

type Progress struct {
	// Changed reports that a reader has something new to be told. False for
	// a poll that found the run exactly where it was, which is most of them.
	Changed bool

	// Done reports that the walk is over.
	Done bool

	// Err is why it ended, when it ended badly. A Done with no Err is the
	// run having reached a terminal status.
	Err error
}

Progress is what one poll means for a reader.

type State

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

State is the run as a watch has seen it, and the decision of when to stop.

One state machine, folded into by both shapes, because "has anything changed", "is this over", and "has the server been quiet too long" must not get two answers. The live view and the plain lines then differ only in how they render it — which is what keeps a bug fixed in one from surviving in the other.

func NewState

func NewState(deps Deps, workflowID string, known *v1.GetResponse, options ...Option) *State

NewState begins a walk, optionally already knowing something about the run.

`flow run` knows the run exists and what its ids are before it starts following, and seeding that matters: a machine-readable caller interrupted before the first poll would otherwise be given nothing at all, while a durable workload it can no longer name goes on running.

A seeded run id is queued as owed to the reader rather than treated as already told: it has been learned, not reported, and nothing has been written yet. Marking it reported here would make the first line about a run the one line that omits its identity.

func (*State) Absorb

func (s *State) Absorb(at time.Time, response *v1.GetResponse, err error) Progress

Absorb folds one poll result into the state.

at is when the result was observed, taken by the caller rather than read here. Both shapes already have it — the plain loop reads the clock, the live view has the time on the tick that scheduled the poll — and taking it makes the whole state machine a function of its inputs, so a test can state exactly when it should give up rather than wait to find out.

func (*State) Failure

func (s *State) Failure() string

Failure is a failed run's message, empty until there is one.

func (*State) GaveUp

func (s *State) GaveUp() bool

GaveUp reports that the walk ended because the server stopped answering, rather than because the run reached a terminal status.

func (*State) LastError

func (s *State) LastError() error

LastError is the most recent failure the walk observed.

func (*State) Line

func (s *State) Line(theme ui.Theme) string

Line renders the state as one line of prose, for the shape that prints a line per change.

The sentence is `<status> workflow <subject>`, which is the same sentence `flow run local` writes for a finished local run, so the two drivers describe themselves the same way and a person moving between them has nothing to relearn.

The run ids this walk has seen and not yet written down follow, per [State.runsClause]. Every identifier is therefore said once and then assumed, which is what leaves room for the part a reader is actually waiting for: where the run has got to, what is being retried, which gate it is parked on, and what it failed with.

This writes to the state, which a renderer normally should not: it drains the ledger of run ids owed. That is deliberate and is the correction picatz/flowstate#836 found — a ledger of what a reader has been told can only be kept where the telling happens, and every caller of this function is writing the result to a stream that keeps it. Calling it twice for one line would spend the ids on the copy that is thrown away.

func (*State) OutageSince

func (s *State) OutageSince() time.Time

OutageSince is when the server was first observed unable to answer, zero once it answers again.

func (*State) Pending

func (s *State) Pending() []string

Pending is what Temporal is retrying, already rendered.

func (*State) Position

func (s *State) Position() string

Position is where the run has got to, as bare text.

func (*State) Response

func (s *State) Response() *v1.GetResponse

Response is the last answer the server gave.

func (*State) RunID

func (s *State) RunID() string

RunID is the run id of the last poll folded in.

func (*State) Status

func (s *State) Status() v1.RunResponse_Status

Status is the run's status as of the last poll folded in.

func (*State) Steps

func (s *State) Steps() []string

Steps are the ids of steps that have produced outputs, sorted.

func (*State) Waits

func (s *State) Waits() []string

Waits are the gates the run is parked on, already rendered.

func (*State) WorkflowID

func (s *State) WorkflowID() string

WorkflowID is the workflow this walk is following.

type StateMsg

type StateMsg struct {
	// At is when the answer was observed, so the outage allowance is
	// measured against the clock rather than against a number of polls.
	At time.Time

	Response *v1.GetResponse
	Err      error
}

StateMsg is one answer from the server, or one refusal.

type TransientError

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

TransientError marks a poll failure worth asking about again.

cmd/flow classifies a refusal against the connect code and wraps it in this before handing it to State.Absorb, which is the only place the distinction matters: everything else here treats a poll failure as one thing.

func NewTransientError

func NewTransientError(err error) TransientError

NewTransientError wraps a poll failure as one worth asking about again.

A constructor rather than a bare composite literal, because the wrapped error sits in an unexported field even on the exported type — embedding the built-in error interface anonymously names the field "error", and a field named after a predeclared identifier is still unexported like any other lowercase name, so only this package can set it directly.

func (TransientError) Unwrap

func (e TransientError) Unwrap() error

Unwrap keeps the refusal underneath reachable, so a remedy printed from further up the chain — cmd/flow's nextCommandsFor, for one — can still find it inside a "gave up after 30s" sentence.

Jump to

Keyboard shortcuts

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