runner

package
v0.0.0-...-7186417 Latest Latest
Warning

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

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

Documentation

Overview

The shared "how does a live generation actually run" mechanism - draining its stdout, waiting for it, recording its outcome, and starting a fresh one on request (Rerun.md). Originally lived entirely inline in main()'s own body; pulled out here once design-docs/ Revisit.md's Phase 3 needed the identical mechanism for rerun-from- within-"revisit" (revisit.go) - matching the same "one shared funnel" philosophy SpawnGeneration/AppendInvocation/FinalizeInvocation already established for the start/record side of a generation's life, extended here to the run/rerun side. main.go's own run/rerun/role session is still the only caller with a whole-process exit status to decide (see its own tail, after app.Run() returns) - that part is deliberately not pulled in here, since revisit's rerun has no equivalent: a failed rerun while browsing history shouldn't take the whole "revisit" session down, only main.go's genuinely top-level session has that authority.

Package runner is the "how does one ansible-playbook generation actually run" mechanism - spawning the subprocess, streaming its stdout/stderr, tracking its progress against a --list-tasks skeleton, and recording its outcome. Used identically by main.go's own run/rerun/role session and by revisit.go's rerun-from-within-"revisit" (see generation.go's own doc comment, folded into this package rather than kept with a future "revisit" package - it shares nothing with revisit.go/revisitresolve.go beyond both calling into this package).

Implements a first-prototype "Task x/y" progress indicator (top bar, tui.go's TopBarText). There is no event in the jsonl stream that tells us upfront how many tasks a run will execute (CLAUDE.md's own Aggregation section: plays/tasks are only ever discovered as they start), so this predicts a task sequence ahead of time from a second, throwaway `ansible-playbook ... --list-tasks --list-hosts` invocation (confirmed empirically that both flags can be given together in one call - ansible merges each play's pattern/hosts/tasks into a single block regardless of which order the two flags are given in, so this needs only one subprocess, not two), then matches each real task-start event against it live.

This is deliberately approximate, not exact - confirmed empirically (a handful of small probe playbooks, not kept in the repo) before building any of this:

  • neither flag ever lists a handler at all, even a notified one that will genuinely run - so a handler's own task-start event can never have a static counterpart to match.
  • --list-tasks does not expand a dynamic `include_tasks:` (a Jinja-templated path, or one used inside a `loop:`) into its real children at all - it shows one opaque line for the include statement itself, which then never appears as its own event at runtime (only its expanded children's task-start events do; a loop over include_tasks fires that many real events for what --list-tasks counted as one).
  • a `when: false` task, and ordinary --tags/--skip-tags filtering, do NOT cause a mismatch: the task is still listed and still fires a real task-start event either way.
  • --list-tasks always lists every play's tasks regardless of whether any host will ever run them, but --list-hosts DOES apply -l/ --limit and reports "hosts (0):" for a play matching nothing in this run's inventory - real events never fire for such a play's tasks at all (confirmed empirically: the play itself still gets a v2_playbook_on_play_start, but none of its tasks ever start). Any play reporting zero hosts has its tasks excluded from the skeleton entirely (ParseListTasksOutput below), rather than inflating the total with tasks that can never execute in this run.

Given that, matching by plain "have we seen this task before" is wrong: task names are not unique across a real playbook (the same role, or the same included file, commonly runs more than once), so a naive lookup can match a real event to the wrong occurrence and make the indicator jump - which is worse than not moving at all. See ProgressTracker for the bounded, forward-only matching this uses instead, and its own accepted failure mode (undercounting, never overcounting).

Index

Constants

View Source
const AnsibleUserInterruptedExitCode = 99

AnsibleUserInterruptedExitCode is ansible-playbook's documented exit code for "user interrupted execution" (its own CLI exit-code table). In Tangsible specifically, the only source of SIGINT to the child is our own SetInputCapture handler (tcell's raw mode disables the OS's normal Ctrl-C-to-SIGINT delivery) - so this code unambiguously means "the user asked us to stop this run," never a signal from anywhere else.

View Source
const ProgressBaseLookahead = 25

ProgressBaseLookahead bounds how far ahead of the tracker's own cursor a match is trusted while things are going normally (missStreak == 0) - see ProgressTracker's doc comment for why a match has to be bounded at all once a task name can repeat, and for how this bound grows instead of staying fixed once misses start piling up.

View Source
const ProgressMaxMissShift = 12

ProgressMaxMissShift caps how many times ProgressBaseLookahead doubles as missStreak grows (see window below) - 12 already gives a window of 25*4096 = 102400, far larger than any playbook this project targets, so this only exists to keep the shift itself well-defined rather than to meaningfully limit real recovery.

Variables

View Source
var (
	ProgressPlayLine       = regexp.MustCompile(`^  play #\d+ \([^)]*\): (.+)\tTAGS: `)
	ProgressTaskLine       = regexp.MustCompile(`^      (.+)\tTAGS: `)
	ProgressHostsCountLine = regexp.MustCompile(`^    hosts \((\d+)\):`)
)

ProgressPlayLine/ProgressTaskLine/ProgressHostsCountLine match "ansible-playbook ... --list-tasks --list-hosts"' own combined output, confirmed empirically against a real ansible-core install (pinned ANSIBLE_JSON_INDENT/callback env vars don't affect this - both flags bypass the callback system entirely, per CLAUDE.md, and always print this same plain-text format regardless). Confirmed too that both flags can be given in the same invocation - ansible merges each play's own pattern/hosts/tasks into one combined block, in either flag order, rather than needing two separate subprocess calls:

play #1 (nogroup): Targets a group not in this inventory	TAGS: []
  pattern: ['nogroup']
  hosts (0):

play #2 (all): Real play	TAGS: []
  pattern: ['all']
  hosts (1):
    localhost
  tasks:
    unique finisher after unreachable play	TAGS: []

This is a human-readable summary, not a documented machine format, so treat it the same as this project's other "documented heuristic, not chased to 100%" text-scraping (e.g. ColorizeYAML) - a line that doesn't match any recognized pattern (a future ansible-core version's reformatted output) is silently skipped, never an error.

Functions

func ExitCodeOf

func ExitCodeOf(err error) int

ExitCodeOf extracts ansible-playbook's process exit code from the error returned by cmd.Wait(), or 0 if it exited cleanly. Returns -1 for a non-ExitError failure from Wait() itself (e.g. an I/O error) - not a real exit code, but distinct from every real one (0-255), so it never accidentally matches AnsibleUserInterruptedExitCode or 0.

func NewRequestRerun

func NewRequestRerun(playbook, roleDisplayName string, originalRest []string, state *pb.PlaybookState, procH *ProcHandle, processDone *atomic.Bool, exitCode *atomic.Int32, progH *atomic.Pointer[ProgressTracker], apply func(StreamItem), recordOutcome func(GenerationOutcome)) func(startAtTask, tags, skipTags, hosts string)

NewRequestRerun builds tui.go's requestRerun hook (Rerun.md) - starting a new generation mid-session, called once the re-run dialog is confirmed. Every parameter is exactly what this one mechanism needs from its own enclosing session; nothing else is assumed about who's calling it, which is what makes it reusable identically by main.go's own run/rerun/role session and revisit.go's rerun-from-within-"revisit" (Phase 3).

startAtTask, if non-empty, is prepended as --start-at-task; tags/hosts replace the original invocation's own (originalRest is always carried forward unedited alongside them - see ParsedPassthroughArgs.Reassemble).

func RunOneGeneration

func RunOneGeneration(cmd *exec.Cmd, stdoutCh <-chan StreamItem, stderrLines <-chan []string, runID string, playbook, roleDisplayName string, apply func(StreamItem), exitCode *atomic.Int32, processDone *atomic.Bool, recordOutcome func(GenerationOutcome), peeked ...StreamItem)

RunOneGeneration drains one generation's stdout to completion - from whatever's already been peeked off it (peeked, "run"'s own pre-flight gate only), through channel close - waits for its process, and records its outcome: exitCode/processDone (both read live by tui.go's rebuild()), recordOutcome (the caller's own accumulator, for whatever it does with a finished generation's stderr - main.go prints it once app.Run() finally returns; revisit.go's openRevisitEntry does the same, just scoped to one entry-viewing session), the saved run-log's stderr file (WriteRunStderr), and this generation's own invocation-history entry (FinalizeInvocation). playbook/roleDisplayName decide which of those an entry belongs under - a session's role-ness/playbook never changes mid-session (a rerun reuses the same stub/playbook throughout - see StartRoleSession), so whichever was true for this generation's own AppendInvocation call (in NewRequestRerun, or the caller's own first- generation recording) is still true now.

func ScanEvents

func ScanEvents(r io.Reader, logFile *os.File) <-chan StreamItem

ScanEvents reads one JSON object per line from r, decoding each into a StreamItem sent on the returned channel; the channel is closed once r hits EOF or a scan error occurs. Runs on its own goroutine so a caller can observe "did anything ever arrive at all" (via the ok value of a channel receive) before deciding whether to show the TUI - see main's gate, added because ansible-playbook produces zero stdout output for pre-flight failures (bad playbook path, parse errors, missing inventory, ...), reporting those solely via stderr + a nonzero exit code.

logFile, if non-nil (see runlog.go's CreateRunLog), gets every raw line teed into it verbatim, byte-identical to what ansible-playbook actually emitted - before trimming/decoding, so a malformed or blank line is saved too, same as a real one. This is design-docs/Revisit.md's own save mechanism: byte-identical means "revisit" can later replay a saved file through this exact same scan-and-decode logic, just pointed at a file instead of a live pipe, rather than needing a second, parallel serialization format to stay in sync with this one. Writes are best-effort (errors ignored) - same tolerance every other piece of this feature has for its own I/O failures, never worth disrupting the live event stream over. Closed once scanning ends, right alongside the channel.

func SpawnGeneration

func SpawnGeneration(playbook string, args []string, procH *ProcHandle) (cmd *exec.Cmd, stdoutCh <-chan StreamItem, stderrLines <-chan []string, runID string, err error)

SpawnGeneration starts one ansible-playbook invocation for playbook+args, wiring up its stdout/stderr exactly as every generation needs (see ScanEvents/StreamStderr) and pointing procH at the new child so Ctrl-C/q forwarding targets it. Shared by the first invocation and every rerun since - the only thing that differs between them is what main does with the first item off the returned channel (see main's pre-flight gate, which only ever applies to the first invocation - a rerun's own pre-flight failure has nowhere to hide the already-visible TUI from, so it just renders as a failed generation like any other, no gate needed).

runID names this generation's own saved run data (design-docs/ Revisit.md, runlog.go) - "" if CreateRunLog couldn't actually open anything to save it to, so a caller never records a RunID (via FinalizeInvocation) that no file backs.

func StreamStderr

func StreamStderr(r io.Reader) []string

StreamStderr collects the child's stderr lines instead of printing them live — printing directly to the terminal while the TUI's alternate screen is active would corrupt the display. main prints them after app.Run() returns and the real terminal is restored.

Types

type GenerationOutcome

type GenerationOutcome struct {
	ExitCode    int
	WaitErr     error
	ChildStderr []string
}

GenerationOutcome is one ansible-playbook invocation's result. main accumulates one per generation - the first invocation, plus every rerun since (Rerun.md) - so every generation's stderr still gets printed once Tangsible finally exits, not just the last one, even though only the LAST generation's exit code decides Tangsible's own exit status.

type PendingGeneration

type PendingGeneration struct {
	Cmd         *exec.Cmd
	StdoutCh    <-chan StreamItem
	StderrLines <-chan []string
	First       StreamItem
	RunID       string
}

PendingGeneration is the "run" Verb's own first generation - already spawned and past the pre-flight gate by the time the TUI exists, unlike every rerun since (including, for the "rerun" Verb, its very first one - see requestRerun) which only ever starts once a re-run dialog is confirmed. nil for the "rerun" Verb: nothing has been spawned yet when the TUI is constructed for it.

func StartFirstGeneration

func StartFirstGeneration(playbook string, rest []string, procH *ProcHandle, histPlaybook, histRole string, cleanup func()) (pending *PendingGeneration, showTUI bool)

StartFirstGeneration spawns playbook+rest as this session's first generation and runs the same pre-flight gate "run" has always had - a bad playbook path, a parse error, a missing inventory, or (for "tangsible role") a role ansible itself can't resolve either all fail before any real event ever fires, writing zero bytes to stdout. showTUI is false when nothing ever arrived and the run turned out to need no TUI at all (a clean pre-flight failure already reported to stderr, or a genuinely empty-but-successful run, e.g. --list-tasks) - the caller should just return immediately in that case, exactly as "run" always has. cleanup, if non-nil, is called before every os.Exit path this function can take, and is expected to also be wired by the caller (via defer) to run on its own eventual return - "run" passes nil (nothing to clean up), "role" passes a func that removes its own stub playbook.

histPlaybook/histRole (exactly one non-empty, mirroring AppendInvocation's own playbook/role parameters) are what this generation's invocation history entry was recorded under - needed here only for the pre-flight- failure branch below, which finalizes that entry itself (exitCode, and a RunID if anything was actually saved) since it bypasses main's own runGeneration entirely.

type ProcHandle

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

ProcHandle holds the *os.Process that Ctrl-C/q should signal to interrupt a running invocation (tui.go's SetInputCapture). Mutable - unlike a plain *os.Process passed once at startup - so a rerun (Rerun.md) can point it at a freshly spawned child without tui.go needing to know a restart ever happened. Store is called from whichever goroutine just spawned a new generation (see SpawnGeneration); Load from SetInputCapture, on tview's own event-loop goroutine - atomic.Pointer makes that safe with no separate lock. Never observed nil once SpawnGeneration has run at least once: the very first call happens before the TUI (and so before SetInputCapture) exists at all, and every later one only replaces an already-non-nil value.

func (*ProcHandle) Load

func (h *ProcHandle) Load() *os.Process

func (*ProcHandle) Store

func (h *ProcHandle) Store(p *os.Process)

type ProgressEntry

type ProgressEntry struct {
	Play string
	Task string
}

ProgressEntry is one predicted task, keyed the same way a real v2_playbook_on_task_start event's own play.Name/task.Name pair is - confirmed empirically that a role-qualified task name ("myrole : task name") renders identically in both --list-tasks' own output and a real event's task.name, so no separate un-qualifying step is needed.

func BuildProgressSkeleton

func BuildProgressSkeleton(playbook string, passthroughArgs []string) []ProgressEntry

BuildProgressSkeleton shells out to a single, throwaway "ansible-playbook <playbook> <passthroughArgs> --list-tasks --list-hosts" invocation - always best-effort: any failure (ansible-playbook missing, the same real playbook error the actual run's own pre-flight gate would separately catch, an ansible-core version whose output this parser doesn't recognize) just means no progress indicator at all, never a fatal error, since this sits entirely on top of a run that already works without it.

passthroughArgs must be the exact args the corresponding real generation is itself about to run with - confirmed empirically that --list-tasks' own task list shrinks under --tags/--skip-tags, and --list-hosts' own per-play host counts shrink under -l/--limit, exactly like the real run's own scope does, so a mismatch here would make the predicted and real sequences disagree about what's even in scope before either one starts.

Known, accepted gap for this prototype: --ask-vault-pass/ --ask-become-pass (CLAUDE.md's own "Current scope constraints") would make this throwaway invocation prompt for a password on the real terminal too, in addition to the real run's own first generation doing the same - narrow enough (only playbooks actually using those flags) to leave as a known limitation rather than solve up front.

func ParseListTasksOutput

func ParseListTasksOutput(output string) []ProgressEntry

ParseListTasksOutput turns the combined "--list-tasks --list-hosts" stdout into a flat, execution-order sequence of ProgressEntry - flat because --list-tasks itself already merges a play's pre_tasks/roles/ tasks/post_tasks into one single, correctly-ordered "tasks:" section (confirmed empirically), so no separate section-tracking is needed beyond which play a task line currently falls under.

A play's own "hosts (0):" line - present because --list-hosts, unlike --list-tasks alone, DOES apply -l/--limit - drops every task under it from the skeleton entirely: such a play's tasks are structurally guaranteed to never fire a single real event in this run (confirmed empirically: the play itself still gets a v2_playbook_on_play_start, but none of its tasks ever start), so counting them would only ever inflate the total, never be matched.

type ProgressTracker

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

ProgressTracker matches real, in-order task-start events against a static skeleton (see BuildProgressSkeleton) to produce an approximate "position / total" - approximate because dynamic content (a notified handler, a dynamically-included task) has no static counterpart to match at all, in which case Advance leaves the tracker's own state exactly as it was, rather than treating a miss as a regression.

Matching walks forward from a monotonic cursor rather than doing a plain (play, task) lookup, and only within a bounded window ahead of it, for a reason confirmed empirically, not assumed: a task name is not unique across a real playbook - the same included file, or the same role, commonly runs more than once - so a fully unbounded lookup can match a real event to the WRONG occurrence (one already passed, or one much further ahead than what's actually running) and make the indicator jump incorrectly.

The window itself is adaptive, not fixed - a real, live-tested gap this design missed on its first real-world run: a single block of dynamic content the skeleton couldn't predict at all (a sizeable include_role/include_tasks tree is entirely plausible in a large playbook) can easily be wider than any fixed window, and once the cursor falls behind by more than the window, a *fixed* window can never recover - not even for a later, perfectly unique task name, since the window is measured from the stuck cursor, not from where that task actually sits. missStreak (consecutive Advance calls that found nothing) doubles the window each time, capped at ProgressMaxMissShift, and resets to zero the instant something matches again - so an isolated, ordinary gap stays tightly bounded (protecting against a coincidental collision, the original concern), while a long unmatched stretch progressively widens the search until it bridges back to the next thing that actually is in the skeleton. Undercounting during that widening (the bar stalls while catching up) is still the deliberately preferred failure mode over ever jumping backward or overcounting.

func NewProgressTracker

func NewProgressTracker(skeleton []ProgressEntry) *ProgressTracker

NewProgressTracker never returns nil - an empty/nil skeleton (e.g. BuildProgressSkeleton failed) just makes Position() always report (0, 0), which callers already treat as "nothing to show" identically to a genuinely absent tracker.

func (*ProgressTracker) Advance

func (t *ProgressTracker) Advance(play, task string)

Advance looks for (play, task) among the next currently-in-effect window of unconsumed skeleton entries (see ProgressTracker's own doc comment for how that window grows on repeated misses). On a match, the cursor moves just past it, that match's own 1-based position becomes the tracker's new Position(), and missStreak resets to zero; on a miss, the tracker's cursor/matched are left completely untouched and missStreak grows by one, widening the next call's own window. Safe to call on a nil *ProgressTracker (a no-op) - the state before this session's very first skeleton has ever been built, or hasn't been built for this particular generation yet.

func (*ProgressTracker) AdvanceToPlay

func (t *ProgressTracker) AdvanceToPlay(play string)

AdvanceToPlay resyncs the tracker directly to the start of playName's own section of the skeleton, searching the *entire* remainder rather than any bounded window - justified because a play boundary is a much stronger, less ambiguous signal than a single task name (see aggregate.go's OnPlayStarted: it's the one event confirmed, empirically, to fire even for a play whose hosts: pattern matches zero hosts in this run's inventory, whose tasks then never produce a single event of their own - a real, not hypothetical, gap Advance's own per-task matching structurally cannot recover from by itself, since there's nothing to call Advance with for a task that never starts at all).

On a match, the cursor moves to that play's own first entry - not past it - so the play's real first task-start event can still claim it via a normal Advance call afterward; Position() in the meantime already credits everything strictly before this play as done, which is accurate (an earlier play, or several, were skipped over entirely to get here). missStreak resets to zero, same as a normal Advance hit - this is a confident resync, not evidence to stay cautious about. A miss (this exact play name never appears anywhere ahead of the cursor - e.g. the throwaway --list-tasks probe somehow used different scope than the real run) leaves the tracker completely untouched, same "undercount, never guess" rule Advance itself follows.

func (*ProgressTracker) Position

func (t *ProgressTracker) Position() (position, total int)

Position reports the most recent match's 1-based position and the skeleton's own total size - (0, 0) for a nil tracker, or one whose skeleton is empty, both meaning the same thing to callers: no progress data available, show nothing rather than a misleading "0/0".

type StreamItem

type StreamItem struct {
	Ev      pb.RawEvent
	IsEvent bool
}

StreamItem is one unit of stdout output. isEvent is true whenever the line decoded successfully as JSON; a malformed line is still sent (with isEvent false) so main's pre-flight gate - which only cares whether anything ever arrived on stdout at all - sees it.

Jump to

Keyboard shortcuts

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