Help us shape the future of Go! Take the 2026 Go Developer Survey

flight

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package flight is the flight screen: shown once a promotion starts driving through engine.AllSteps (branch, commit, push, PR, CI green, approved, merged, then (M5) Argo refresh, Argo sync, rollout), it lists every step with a glyph for its current state, the active step's own human detail text, a stopwatch since the promotion started, and a togglable scrollback of PromotionState.History. rows.go derives the step rows from a PromotionState and the ordered per-step statuses engine.Status produces, with no terminal dependency (AGENTS.md §4.8); model.go lays that data out.

Index

Constants

View Source
const (
	GlyphDone       = "✓"
	GlyphActive     = "▶"
	GlyphWaiting    = "…"
	GlyphBlocked    = "✗"
	GlyphNotReached = "·"
)

The glyph set every Row.Glyph renders as: done, active (Drive/Status is currently sitting on this one, with nothing external to wait on — it either just acted or is about to), waiting (active, but blocked on something external: CI running, an approval comment not posted yet), blocked (a real conflict, terminal until an operator resolves it), and not yet reached (every step after the active one — Status never got far enough to observe it this pass, or did not need to, because of its own short-circuit; see engine.Status's doc comment).

Variables

DirectStepOrder is StepOrder's counterpart for a direct-mode promotion (engine.AllDirectSteps): the gate and a push straight to the base branch instead of push/PR/CI/approval/merge, then the same three convergence steps. A direct promotion rendered against StepOrder draws four steps it will never run (PR, CI, approval, merge) and hides the two it actually does, which is a screen lying about what is happening.

StepOrder is the fixed order flight renders steps in — engine.AllSteps' own order, branch/commit/push/PR then CI green/approved/merged, then (M5) Argo refresh/sync and rollout. It is a plain literal rather than derived from engine.AllSteps(nil, nil, nil, nil, nil) at runtime (technically possible: nil satisfies every interface parameter without this package importing pkg/git/pkg/forge/pkg/argo/pkg/rollout, and every Step.Name() implementation ignores its receiver's fields) because a literal is more legible here and does not depend on every future Step implementation continuing to ignore its own fields in Name(). TestStepOrderMatchesAllSteps in model_test.go is what keeps this from silently drifting if AllSteps' own order ever changes.

Functions

func ActiveStep

func ActiveStep(rows []Row) (engine.StepName, bool)

ActiveStep is the row DeriveRows marked Active, if any — "" when every row is done or (defensively) when none is marked active at all.

func ApprovalCopy added in v0.2.0

func ApprovalCopy(s Summary) (text, command string)

ApprovalCopy is Action() with one wording fix (v2·04a, UX-H10): a promotion blocked on the approval step reads "waiting for an approver to comment `hoist approve <id>` on PR #N" instead of Action()'s own "blocked on you — comment ... to release it:", which reads like an accusal rather than a status (AGENTS.md §9's own regression note on this fix). Exported so this package's own flight.Model and the matrix's in-flight pane render the identical sentence from one place, rather than each keeping its own copy of the fix in step by hand — the matrix carried it alone until this package had its own PR to land it in too.

func BlockedStep

func BlockedStep(rows []Row) (engine.StepName, bool)

BlockedStep is the row DeriveRows rendered with GlyphBlocked, if any. A blocked row is always the one Active row too (deriveRow's own Blocked case sets both), so this is really "ActiveStep, but only when that step is Blocked rather than merely Waiting or not-yet-acted — the distinction Model.onDriveResult needs to decide whether to keep polling: a Blocked step is terminal until an operator resolves the conflict (engine.BlockedError's own doc comment: "retrying will not help"), unlike a Waiting or not-yet-acted one, which is exactly what the poll loop exists to keep re-observing.

func DoneWord added in v0.2.0

func DoneWord(rows []Row) string

DoneWord is how a done promotion names its state: "done", or "done · superseded" when a step was satisfied because a later change replaced what this promotion wrote (#168) — so a promotion that finished that way never reads as one whose own images rolled out.

func FirstLogAt added in v0.2.0

func FirstLogAt(log []session.LogLine) time.Time

FirstLogAt is firstLogAt for the root, which builds the matrix's in-flight summary from a Snapshot's log and must agree with the flight header about when a promotion started.

func Label

func Label(name engine.StepName) string

Label is the human-readable name for a step; falls back to the raw StepName for anything stepLabels doesn't know (defensive — every step engine.AllSteps returns today is listed above, and TestStepOrderMatchesAllSteps would catch a new one silently falling back).

func OrderFor

func OrderFor(s engine.PromotionState) []engine.StepName

OrderFor picks the row order matching how this promotion is actually being driven. Keyed on the state's own Direct field rather than on a parameter, so a resumed promotion renders the shape it really is even when whatever opened the screen has forgotten.

func PRURL

func PRURL(s engine.PromotionState) (string, bool)

PRURL is s.PR's URL, when a PR has been observed at all — what the 'o' key opens.

func RowState added in v0.2.0

func RowState(r Row) ui.StepState

RowState maps a Row's own Glyph string to the shared ui.StepState enum (internal/ui/glyph.go) — the one place that translation happens, so every step strip in this app (this package's own compact StepStrip, and the matrix's in-flight pane) reads the same five states off the same Row data rather than each drawing its own glyph set (AGENTS.md §9's "one glyph set" note).

func StartedAt

func StartedAt(s engine.PromotionState) time.Time

StartedAt is the promotion's start time: History[0].At, the first entry Drive ever appends (BranchedStep's own first Observe/Act). PromotionState carries no separate started-at field (state.go) — the audit trail is the only place this is recorded (AGENTS.md §4.1: "the state file... is an index of what to look at"). The zero time when History is empty, i.e. a promotion that has not been driven even once yet.

func StartedAtBefore added in v0.2.0

func StartedAtBefore(s engine.PromotionState, logStart time.Time) time.Time

StartedAtBefore is StartedAt for a promotion this session is driving: the earlier of the first History entry and logStart (the first progress line's time, zero when there is none), because preflight (checking the checkout, claiming the env) runs and is logged before any History exists. It is the ONE origin for "started" — the flight header and the matrix's in-flight pane both read it, so the two never disagree about how old a promotion is. It takes a plain time so this file stays free of the terminal (AGENTS.md §4.8); model.go extracts it from the session's log. A promotion known only from a listing has no log: pass the zero time.

Types

type AbandonMsg added in v0.2.0

type AbandonMsg struct{ ID string }

AbandonMsg asks whatever composes screens to retire promotion ID for good: `hoist abandon`'s own write — release the state file, and close the PR / delete the branch if it opened either. A distinct type from BackMsg on purpose: BackMsg's own doc comment and parity row describe purely-local, non-destructive semantics (leaving the branch, PR and state file exactly as they are, the drive itself still running) — giving that identical, harmless meaning a destructive second sense would silently invalidate the description without anything able to catch the drift. Emitted only after the shift+x gesture's own huh.Confirm answers yes (AGENTS.md invariant 5's keypress-then-confirm shape), and never for a promotion this screen itself believes has already landed — the root's real handler re-observes and refuses authoritatively regardless (this screen's own guard is UI politeness only, the same relation OverrideCINoneMsg has to CIGreenStep's own re-check).

type BackMsg

type BackMsg struct{}

BackMsg asks whatever composes screens to close every screen above the matrix at once — esc, even when a plan or deploy confirm screen sits underneath this one (the screen that started the drive: m.start's own doc comment, app.go). It never pops back to that confirm screen instead: doing so used to leave it still ticked and ready, so Enter there started the very drive esc just left watching (audit UX-H6/FB-H2, the operator's own decision, a follow-up to the original "leaving flight never cancels anything"). The drive keeps running exactly as it was; the root only stops mirroring it onto a screen. enter/r on the matrix's in-flight pane re-attaches a fresh flight screen to the same running entry later (session.Controller.Running's own dedup, already in Start/Resume, means that never starts a second one).

type Model

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

Model is the flight screen: a mirror of one session.Controller entry, never a driver of its own. It keeps no ctx, no Driver, no tick chain — session.Controller owns every background command; this package's own doc comment on Update covers why. Update handles only this screen's own keys, its spinner, and the two confirm dialogs (c/X); the state it renders is set exclusively by NewAttached and Mirror.

func NewAttached added in v0.2.0

func NewAttached(s session.Snapshot, poll PollDurations) Model

NewAttached builds the flight screen already attached to one session.Controller entry — the TUI's only way to construct this screen: the root calls it once, right after session.Controller.Start/Resume hands back a BuildID, and every later change reaches this same instance through Mirror rather than a fresh construction. It replaces New/NewBuilding/ AdoptBuilt: whichever phase s names (Building included — s.State is zero then, and s.Source/ Target/Direct carry what the confirmed plan already knew, mirrored the same way the old NewBuilding's own stub state did) is rendered directly, with nothing left to "adopt" later — mirroring a fresher snapshot onto the same instance already does that.

func (Model) Attached added in v0.2.0

func (m Model) Attached() (id string, build session.BuildID)

Attached reports which session.Controller entry this screen mirrors — the root's apply() uses Build (stable across the whole attachment, including the id-less Building window) to decide which on-stack flightScreen a Change belongs to; id is handed back too since some callers (a notice naming "this promotion") want it directly.

func (Model) CapturesText added in v0.2.0

func (m Model) CapturesText() bool

CapturesText reports whether the shift+c/shift+x/w gesture's dialog is up: while it is, the root must hand every key to this screen rather than treat q as quit, or an operator deciding whether to treat no checks as green (or to abandon, or which family to watch) can quit the program mid-decision.

func (Model) ID added in v0.2.0

func (m Model) ID() string

ID is the promotion this screen shows — "" during Building, before session.Controller's own backend has produced a real deterministic id.

func (Model) Init

func (m Model) Init() tea.Cmd

Init starts whichever tick chain this screen needs to animate on its own: the spinner's while Building or a Step is outstanding (unchanged), or the 1s countdown redraw while m.waiting() — a mirrored screen with nothing in flight and nothing counting down has nothing to animate, so this returns nil rather than a permanent, invisible tick loop (the loop this guards is Update's own reschedule, not a poll this screen drives).

func (Model) KeyScreen added in v0.2.0

func (m Model) KeyScreen() keys.Screen

KeyScreen implements the root's keyed interface (internal/app/screen.go): the root's generic ?/l handling now covers this screen, rather than flight owning its own help text or its own l-toggles-the-log gesture.

func (Model) Mirror added in v0.2.0

func (m Model) Mirror(s session.Snapshot) Model

Mirror replaces this screen's displayed state with s — the root calls it after every session.Controller change whose Build matches Attached()'s own. A snapshot for a different build is a caller bug (mirrorAttached in app.go only ever mirrors a matching one) and is applied as-is rather than defended against here a second time (AGENTS.md §8, the deletion test: the one real guard belongs where the routing decision is made).

func (Model) SetSize

func (m Model) SetSize(width, height int) Model

SetSize records the terminal size. The log is a viewport sized to what the frame leaves (layout) and scrolls with the unmatched keys handleKey forwards; the step list has no scrolling and degrades to the one-line strip on a short terminal (stepsSection).

func (Model) SetStyles

func (m Model) SetStyles(s ui.Styles) Model

SetStyles applies the palette (and re-themes whichever dialog is up).

func (Model) Update

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

Update handles the screen's own keys and its two tick chains. Every async result this screen used to process directly (a drive's own Tick, a progress line) now arrives only as a fresher Mirror call from the root — this package issues no tea.Cmd that talks to a Driver or a channel at all (TestFlightNeverCallsDriver pins it). The spinner and the countdown chains hand off to each other at whichever end delivers next (their own doc comments): a mirrorAttached call from the root updates this screen's state directly, with no Init and no message through here, so each chain's own delivery is what notices the OTHER condition has become true meanwhile and starts that chain instead — the only gap is a screen sitting fully idle (neither chain alive) when a background Mirror alone moves it back into m.waiting(), which self-heals on the next keypress or Mirror-carried spinner/countdown delivery regardless.

func (Model) View

func (m Model) View() string

View renders the frame: the header (what and how long), the step list, what it is waiting for and what to type, the history, notices, and the footer. The whole assembled string passes through redact.Strings once here at the final boundary, matching plan.Model's own belt-and-suspenders convention.

func (Model) WithNow

func (m Model) WithNow(now func() time.Time) Model

WithNow fixes the clock (tests).

type OpenPRMsg

type OpenPRMsg struct{ URL string }

OpenPRMsg asks whatever composes screens to open s.PR's URL in the operator's browser — the actual open mechanism (exec.Command("open", …) or equivalent) is out of scope for this screen (AGENTS.md §4.8: a screen requests navigation by emitting its own concrete type; mirrors matrix.OpenPlanMsg and plan.BackMsg).

type OverrideCINoneMsg added in v0.2.0

type OverrideCINoneMsg struct{ ID string }

OverrideCINoneMsg is the operator's confirmed answer to the one Blocked reason that has an in-band override: CIGreenStep's "no checks reported after the grace period; ci.none=prompt" (engine.IsCINonePromptBlock). It asks whatever composes screens to re-drive promotion ID with engine.PromotionState.CINoneOverride set — the TUI's `hoist resume <id> --override-ci-none` (#103). Emitted only after `c` and a huh.Confirm answered yes, never on the keypress alone, and only for the promotion this screen is showing: the override is a per-promotion, one-shot operator instruction, never a launch default and never config (AGENTS.md §4.5 — a default may not weaken a gate). The root answers it by calling session.Controller.OverrideCINone for this promotion's own entry.

type PollDurations

type PollDurations struct {
	CI, Approval, Argo, Rollout, Deadline time.Duration
}

PollDurations is the plain-value slice of internal/config.PollConfig this screen actually needs, in place of importing internal/config itself. AGENTS.md §4.8: a screen never imports config/registry policy, only the plain values or function types cmd/hoist (the one place allowed to know both sides) translates for it. The root maps these same values into session.Config for the controller that actually drives; this screen keeps its own copy only for anything it still renders directly (today: nothing computed from it, but NewAttached's signature keeps the type so a poll-driven display timer has somewhere to live without a signature change).

type ReobserveMsg added in v0.2.0

type ReobserveMsg struct{ ID string }

ReobserveMsg is r's own request: re-observe promotion ID now, rather than waiting for the next scheduled poll. The flight screen no longer drives anything itself — it only asks, and the root answers by calling session.Controller.Poke, whose own busy/not-found refusal is authoritative regardless of what this screen's own guard already believed. Plain "r" (keys.Refresh, Verb class) rather than a Write binding (the v2 keymap): asking to re-observe now writes nothing itself, unlike shift+x/shift+c below.

type Row

type Row struct {
	Step  engine.StepName
	Glyph string
	// Superseded mirrors the step's Observation.Superseded: done, because a later change
	// replaced what this promotion wrote (#168).
	Superseded bool
	// Active is true for exactly the one row DeriveRows considers "current" — the step
	// Status stopped at (Blocked, Waiting, or plainly not yet Satisfied). Never true when
	// done is true: every row is Done then.
	Active bool
	// Detail is the human string shown under an active row: Observation.Detail for a
	// waiting or not-yet-acted step, Observation.Blocked for a blocked one. It is never
	// invented here — always exactly what the engine step itself produced (AGENTS.md §4.1).
	// It is NOT already redacted by the time it reaches this package: engine.Status calls
	// each Step's own Observe directly and returns its Observation as-is (engine.go's
	// doc comment on Status/ObserveAll) — it never goes through engine.go's appendHistory,
	// which only wraps Drive's own history-writing path, a different call this package's
	// data never travels through. No Step.Observe in internal/engine/steps*.go redacts its
	// own Detail/Blocked text either. The actual guarantee here is model.go's View(), which
	// passes its whole assembled output (this Detail included) through redact.Strings once
	// at the final boundary before anything reaches the terminal — the same
	// belt-and-suspenders convention as plan.Model's own View(). That is the one place, not
	// two, where this package's data is scrubbed; DeriveRows and this struct carry it
	// unredacted up to that point, same as engine.Status hands it over.
	Detail string
}

Row is one step in the flight list, derived with no terminal dependency.

func DeriveRows

func DeriveRows(order []engine.StepName, done bool, statuses []engine.StepStatus) []Row

DeriveRows turns (done, statuses) — engine.Status's own return shape for a promotion's steps, in engine.AllSteps' order — into the rows the step list renders, one per name in order.

When done is true every row renders Glyph done: engine.Status's own short-circuit (see its doc comment) means statuses then carries only the final step's own StepStatus, not one per earlier step, so there is nothing to derive an individual glyph from for the steps before it — and nothing needs to be, since done already answers "is every step satisfied" for all of them at once. The last row's Detail is filled from that single entry (e.g. "merged as <sha>; branch deleted").

Otherwise, order is walked once: a step present in statuses is Satisfied, Waiting, Blocked or "not yet acted on", read straight off its own Observation; a step absent from statuses has not been reached this pass (engine.Status stops at the first step that is not cleanly Satisfied, so nothing after it was observed) and renders not-yet-reached. Exactly one row is Active — whichever one Status actually stopped at — since every entry in statuses before the last one is, by construction, Satisfied.

type Summary

type Summary struct {
	ID             string
	Source, Target string
	Direct         bool
	StartedAt      time.Time
	PR             *forge.PR
	Rows           []Row
	Done           bool
	// Err is why this promotion could not be re-observed (a forge scope gap, a repo no
	// longer in config), redacted at the render boundary like every other upstream string.
	// The Rows are then every step not-reached and the pane says so rather than guessing.
	Err string
	// Live is true when this Summary was built from this session's own session.Controller entry
	// (app.go's summaryForSnapshot) rather than purely from a fresh listing (summaryFor) — the
	// pane's own way to say "hoist itself is driving this, right now" versus "re-observed, last
	// seen at this state" — the pane marks drives running here.
	Live bool
	// Build identifies the session.Controller entry this Summary was built from
	// (summaryForSnapshot, app.go) — the only handle a still-Building entry has, since Summarize
	// leaves ID empty until a real promotion id exists. Zero (session.BuildID's own zero value)
	// for anything built purely from a listing (summaryFor), which never has a live Controller
	// entry to point at.
	Build session.BuildID
	// NextPoll mirrors session.Snapshot.NextPoll — when this session will next re-observe this
	// promotion on its own, the same field flight.Model's own waiting() reads. Zero for anything
	// not Live (a plain listing has no controller entry telling it when it will next be asked
	// for again), and zero for a Live entry that isn't in the plain "waiting on the next
	// scheduled check" state either (building, busy, done, stopped — session.Controller only
	// ever sets it for a Waiting entry). The matrix's own in-flight pane is this field's one
	// consumer today (commit 1 of that redesign): each Waiting entry's "next check in Ns" text
	// is worded straight off it, never recomputed from a duration this package would have to
	// keep in step with session.Controller's own poll interval by hand.
	NextPoll time.Time
}

Summary is one promotion as the matrix's in-flight pane shows it (M10, #85 screen 05): the identity, the step rows engine.Status produced for it, and what it is waiting for — all re-observed from the forge and the cluster the moment it was listed, never read from the state file's recorded phase (AGENTS.md §4.1). Built by Summarize from the same (done, statuses) shape DeriveRows takes, so the pane and the flight screen agree.

func Summarize

func Summarize(s engine.PromotionState, done bool, statuses []engine.StepStatus, err error) Summary

Summarize builds a Summary from a state and engine.Status's result for it.

func (Summary) Action

func (s Summary) Action() (text, command string)

Action is what the operator can do about it, when it is theirs to do: the approval command for a promotion parked on approval (the id was previously only in the PR body, which is how a healthy promotion sat indistinguishable from a hang), the conflict text for a blocked step, "" when there is nothing to type. Second return is the command itself, for the accent style, "" when the action is prose only.

func (Summary) StepStrip

func (s Summary) StepStrip() string

StepStrip is the whole pipeline on one line: "✓ branch ✓ commit ✓ push ✓ PR #103 ◐ CI ⏸ approval · merge · argo refresh · argo sync · rollout" — ui.StepGlyph's own set, retiring this package's former ●/◍/✗/○ strip glyphs, which duplicated rows.go's own Glyph characters under a third, inconsistent set. The PR step names its number once one exists.

func (Summary) Verdict

func (s Summary) Verdict() string

Verdict is the one phrase that survives every width (docs/tui/mockups.html: "degrade by dropping evidence, never the verdict"): "done", "blocked on approval", "waiting on CI", "blocked: <reason>" for a real conflict, or "cannot re-observe" when Err is set; "done · superseded" for a promotion a later change replaced (DoneWord).

type WatchMsg added in v0.2.0

type WatchMsg struct{ Family, Target string }

WatchMsg asks whatever composes screens to open the read-only watch screen for Family in Target — w's own request, the same navigation-by-message-of-its-own-type convention every other screen transition uses (AGENTS.md §4.8: matrix.OpenPlanMsg, plan.BackMsg). Family is resolved by this screen from its own PromotionState.Edits (families, below) before this is ever emitted — never left for the root to guess, and never the root's job to derive from a promotion (the root's openWatch, app.go, already takes a plain (family, target) pair for exactly this reason: the matrix's identical w gesture resolves its own family from the cursor, not from what it asks the root to open).

Jump to

Keyboard shortcuts

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