flight

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 6, 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).

View Source
const (
	StripDone       = "●"
	StripActive     = "◍"
	StripBlocked    = "✗"
	StripNotReached = "○"
)

Compact glyphs for the one-line step row the pane draws: done, active or waiting, blocked, not reached — the same states Row.Glyph carries, in a form that reads as a strip.

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

Types

type AbortMsg

type AbortMsg struct{ ID string }

AbortMsg asks whatever composes screens to abort promotion ID — closing the PR, deleting the branch, or whatever "abort" means operationally is out of scope for this screen; it only requests it (same convention as OpenPRMsg above).

type BackMsg

type BackMsg struct{}

BackMsg pops this screen back to whatever was underneath it (mirrors plan.BackMsg).

type DriveFunc

type DriveFunc func(ctx context.Context, s engine.PromotionState) (next engine.PromotionState, done bool, statuses []engine.StepStatus, err error)

DriveFunc advances a promotion by one poll iteration: it runs engine.Drive once (Drive itself calls Act on whichever steps are not yet satisfied, in order, then returns at the first step that is Waiting, Blocked, or erroring — see engine.Drive's own doc comment) and re-derives every step's own standing with engine.Status, so the screen can render the full step list rather than only wherever Drive stopped. err is non-nil only for a genuine plumbing failure (Known bug classes: a 404/permissions hiccup on Checks or Comments, mirroring cmd/hoist/drive.go's driveToCompletion) — Waiting, Blocked and "not yet acted on" are never errors, they are read from statuses instead.

cmd/hoist supplies the concrete function, closing over the real git.Git/forge.Forge adaptors and whatever state-save path the CLI's own promote/resume commands already use — the same shape plan.ResolveFunc uses to keep the plan screen ignorant of cluster/registry adaptors (AGENTS.md §4.8's "cmd/hoist owns the adapter" rule). This package therefore never imports pkg/git, pkg/forge, or a state-persistence path; it takes and returns plain engine.PromotionState values (not a pointer) so a tea.Cmd's goroutine never races the model's own copy — see driveCmd. done and statuses mirror engine.Status's own return shape exactly (a real implementation is expected to call engine.Drive then engine.Status in turn) rather than making this package re-derive "is the promotion finished" from the statuses slice by, say, checking whether the last entry names StepMerged — engine.Status already answers that question and its short-circuit's own reasoning (see its doc comment) lives in exactly one place.

type Model

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

Model is the flight screen. It is a value: Update, SetSize and SetStyles return the updated model, matching internal/app/plan and internal/app/matrix's convention.

func New

func New(state engine.PromotionState, poll PollDurations, driveFn DriveFunc) Model

New builds the flight screen for a promotion already at least identified (state.ID, SourceEnv, TargetEnv — whatever the caller already has, typically fresh off the plan screen's "start" flow or engine.LoadState on hoist resume). driveFn is nil in a read-only context with nothing to drive: the screen still renders state and never ticks or schedules a poll, and R shows a notice instead of calling nil.

func (Model) Cancel

func (m Model) Cancel()

Cancel interrupts this screen's shared drive context immediately, rather than waiting for m.deadlineAt or for driveFn to notice at its own next Observe/Act that nobody is watching anymore. app.go calls this on the current flight screen before popping it for AbortMsg or BackMsg (see their own doc comments in app.go): without it, a driveCmd already in flight kept running to completion after the operator had already stopped watching it — free to keep committing, pushing, opening a PR, or merging, and a later reconfirmation of the same deterministic promotion id could then start a second driver racing the first, since the original's claim was already released (Copilot review, PR #50 round 11). A nil cancel — a read-only screen, driveFn nil, New never builds one — makes this a no-op.

func (Model) Init

func (m Model) Init() tea.Cmd

Init starts the spinner and the first poll, but only when there is something to drive — a read-only screen (driveFn nil) has nothing to animate or observe, so Init returns nil rather than starting a spinner tick chain that would otherwise run forever with nothing ever rendering it (PR #39 review finding #5). The first poll runs immediately rather than waiting a full pollInterval, so the screen shows real status as soon as it opens instead of a screenful of "not yet reached" dots.

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; this screen has no huh fields or other themed components beyond the shared status bar and notice styles.

func (Model) Update

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

Update handles the screen's own keys, the spinner, and the drive/tick loop.

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 log when toggled, 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. This is not defense in depth on top of an earlier redaction: engine.Status hands Row.Detail over unredacted (see rows.go's Row.Detail comment for why appendHistory's redaction does not apply to this path) — this call is the one place that text is actually scrubbed before reaching the terminal.

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 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. Zero values are valid — New/pollInterval already fall back to a fixed default for anything left unset.

type Row

type Row struct {
	Step  engine.StepName
	Glyph string
	// 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
}

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

Jump to

Keyboard shortcuts

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