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

session

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: 8 Imported by: 0

Documentation

Overview

Package session is the controller that owns every drive a promotion or deploy goes through once the operator confirms it: starting it (internal/service.Service.StartPromotion), resuming one already on disk (Resume), stepping it to completion one poll at a time, abandoning it, and keeping the matrix's in-flight pane listed — all of it re-observed, never trusted from a screen's own memory (AGENTS.md §4.1).

internal/app's root model holds one Controller and routes every command and Event through it (app.go's Update, apply), and the flight screen mirrors one entry rather than driving a promotion itself — so a background drive no longer dies with the screen that started it.

Design

Controller is a value type: every state change happens inside Update or one of Controller's own methods, each returning a new Controller rather than mutating one in place, exactly as internal/app/matrix.Model and internal/app/plan.Model already do (AGENTS.md §4.8). Bubble Tea keeps only whatever a model's Update returns, so a value stored there survives the copy; nothing here needs a mutex, and View has nothing to race with. Every map Controller holds (its own tracked entries, and the id index onto them) is copied with maps.Clone before a mutating method writes to it — the same discipline internal/app/matrix.Model.stack already follows — so an older Controller value a caller is still holding (a screen's own stale copy, a test's "before" snapshot) never observes a write a newer copy made.

Background work — a StartPromotion/Resume round trip, one Driver.Step poll, a List call, an Abandon call, or draining one progress line off a build's channel — is always a plain tea.Cmd that returns one of this package's own unexported Event values; nothing here ever writes Controller state from a goroutine. Every Event these commands can produce is listed in events.go.

Event is deliberately not exported with a Msg suffix and is never itself treated as a navigation message: internal/parity's own parser only ever looks for `case pkg.XMsg` in internal/app/app.go, and this package's internal plumbing has nothing for the operator to trigger directly the way a screen's own *Msg does. The wiring PR that follows this one adds exactly one `case session.Event:` to that switch.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoBackend is returned when Controller was built with a nil Backend — the same
	// "feature not wired" convention app.Model.svc == nil already uses.
	ErrNoBackend = errors.New("session: no backend configured")
	// ErrNotFound is returned when an operation names a promotion id Controller is not
	// tracking.
	ErrNotFound = errors.New("session: no such promotion")
	// ErrBusy is returned when Poke/OverrideCINone is asked for while a Step is already in
	// flight for that entry, or while it is Abandoning.
	ErrBusy = errors.New("session: this promotion is busy")
	// ErrTargetBusy is returned by Start when another entry is already tracked for the same
	// target env — AGENTS.md invariant 5, enforced here at the same layer service.claimTarget
	// enforces it against the forge/state file.
	ErrTargetBusy = errors.New("session: a promotion is already running for this target env")
	// ErrNoDriver is returned by Poke/OverrideCINone when the tracked entry has no live Driver
	// to step — a Resume whose Backend.Resume call itself failed (Building never reached
	// onBuilt's success path) and was then abandoned, or any other entry that reached Stopped
	// without ever getting a driver. Poke/OverrideCINone would otherwise call Step on a nil
	// interface value and panic (onAbandoned's error path could otherwise leave exactly
	// such an entry behind, driver-less and Poke-able). Enforced twice, deliberately (AGENTS.md
	// §8's layered-checks rule): onAbandoned also refuses to leave a driver-less entry behind at
	// all (see its own doc comment) — deleting either check would make a new state possible
	// (Poke reaching a nil driver), so both are real enforcement, not one enforcement plus
	// politeness.
	ErrNoDriver = errors.New("session: this promotion has no driver to step")
)

Errors Start/Resume/Poke/OverrideCINone can refuse with — never panics, mirroring every other nil-safe convention in internal/app (a caller degrades to a notice).

Functions

This section is empty.

Types

type Backend

type Backend interface {
	StartPromotion(ctx context.Context, req service.StartRequest, h service.Hooks) (service.Drive, error)
	Resume(ctx context.Context, id string, o service.ResumeOpts) (service.Drive, error)
	Abandon(ctx context.Context, id string) ([]string, error)
	List(ctx context.Context, o service.ListOpts) ([]service.Listed, error)
}

Backend is the subset of internal/app.Service (and so of internal/service.Service) Controller actually calls — narrow and consumer-side for the same reason app.Service already is: a test fakes it with a plain struct, never a real Service backed by git/forge/Argo/the cluster.

type BuildID

type BuildID uint64

BuildID names one Start/Resume attempt Controller is tracking, stable for that attempt's whole life (through Building, Stepping, Waiting, Stopped, Abandoning) even before its real promotion id is known — StartPromotion's own preflight (the claim, the in-flight scan, the initial save) has to finish before engine.DeriveID's answer comes back at all. Comparable and cheap to carry in a message, the way flight.Model's own gen already is.

type Change

type Change struct {
	Kind  ChangeKind
	Build BuildID
	ID    string
	Err   error
	List  []service.Listed
	Lines []string
	// Snap is the entry's own last snapshot, populated only for a Change whose entry Update
	// removes from Controller in the very same call (ChangeDone, ChangeAbandoned) — every other
	// kind leaves the entry live, so a caller can always get a fresher one straight from
	// Controller.Snapshot/BuildSnapshot instead. Without this, a mirroring screen (the flight
	// screen) would have nothing left to mirror the instant a promotion
	// finishes: BuildSnapshot(build) already returns false by the time the Change reaches it,
	// since withoutEntry has already run, and the screen would be frozen one step short of the
	// true final state forever.
	Snap Snapshot
}

Change is one thing Update decided happened, for the caller (the wiring PR's own root Update) to react to — re-key a building screen to its real id, mirror a snapshot onto the flight screen showing it, refresh the matrix's in-flight pane, add an activity-log entry. Zero or more are returned from a single Update call; most produce exactly one.

type ChangeKind

type ChangeKind int

ChangeKind is what kind of thing happened to one entry (or to the listing) during an Update call — prefixed Change- to avoid colliding with Phase's own bare names (Done in particular) in this same package.

const (
	ChangeBuilt ChangeKind = iota
	ChangeBuildFailed
	ChangeStepped
	ChangeLanded
	ChangeDone
	ChangeBlocked
	ChangeFailed
	ChangeAbandoned
	ChangeAbandonFailed
	ChangeListed
	ChangeRefused
	// ChangeProgress: a preflight line or a live History report landed on the entry; the screen
	// showing it should re-mirror the snapshot, and nothing else about the entry changed.
	ChangeProgress
)

The ChangeKind values a Change can carry — see Change's own doc comment for what each means to a caller.

type Config

type Config struct {
	// Deadline bounds one build+drive's whole ctx, shared between the StartPromotion/Resume call
	// and every Step call that follows it (one ctx per drive — the time the build
	// itself took counts against the same budget the drive polls against, never a fresh window
	// per Step call, mirroring flight.Model's own deadlineAt one layer up).
	Deadline time.Duration
	// ListEvery is the in-flight listing's own poll cadence (mirrors app.go's old inFlightTick).
	ListEvery time.Duration
	// MinTick floors a Step's own requested wait (service.Tick.Wait) — the same floor
	// flight.Model.minTick already applies, one layer down.
	MinTick time.Duration
	// AbandonWait is the interval between abandon-wait rechecks (mirrors app.go's own
	// abandonWaitInterval).
	AbandonWait time.Duration
	// AbandonTimeout bounds the whole abandon wait for a busy Step to notice its ctx was
	// cancelled (mirrors app.go's own abandonWaitMaxAttempts, expressed as a duration since
	// Config carries durations, not attempt counts) — it does NOT bound Backend.Abandon itself;
	// see AbandonCallTimeout for that. A short value here just means the wait gives up sooner
	// and calls Backend.Abandon anyway (re-observation makes that safe — see Abandon's own doc
	// comment), it never shortens the call itself.
	AbandonTimeout time.Duration
	// AbandonCallTimeout bounds one Backend.Abandon call — a real re-observation (ObserveAll:
	// git ls-remote plus forge PR/check lookups) followed by ClosePR and a DeleteRemoteBranch
	// push, none of which is instant. This is deliberately generous and independent of
	// AbandonTimeout/AbandonWait, which only bound the short busy-Step wait that precedes the
	// call: conflating the two would give a real abandon a 1s deadline and routinely fail
	// it with "context deadline exceeded", sometimes after ClosePR had already run but before
	// DeleteRemoteBranch had a chance to.
	AbandonCallTimeout time.Duration
	// ListTimeout bounds one Backend.List call. Kept equal to internal/app/scope.List (60s, P3
	// #14) — that constant's own doc comment is what actually documents the value;
	// this default exists so a caller that builds a Config directly (every test) still gets it.
	ListTimeout time.Duration
	// Now is the clock. Defaults to time.Now; every test injects a fixed one.
	Now func() time.Time
	// After schedules a message after d — tea.Tick by default. Tests inject a version that
	// fires on request (or immediately) so nothing here ever actually sleeps.
	After func(d time.Duration, f func(time.Time) tea.Msg) tea.Cmd
}

Config is Controller's own tuning knobs. New applies usable defaults to every zero field (normalize), so a caller only sets what it actually wants to change — and every test sets Now and After so nothing here ever sleeps or races real wall-clock time.

type Controller

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

Controller is the value type that owns every drive this session package tracks — see doc.go's own "Design" section: every state change happens inside Update or one of Controller's own methods, each of which returns a new Controller. Its two maps (entries, byID) are always replaced with a maps.Clone'd copy before either is written to, so an older Controller value — a stale screen copy, a test's "before" snapshot — never observes a write a newer one made (TestControllerIsCopyOnWrite pins this).

func New

func New(backend Backend, cfg Config) Controller

New builds a Controller over backend, with cfg's zero fields defaulted (normalize). backend nil is a valid, if useless, Controller: every method that would call it instead returns ErrNoBackend, mirroring app.Model's own nil-svc convention.

func (Controller) Abandon

func (c Controller) Abandon(id string) (Controller, tea.Cmd)

Abandon cancels id's ctx and, once its current Step (if any) actually notices and returns, calls Backend.Abandon — a plain context cancel is not a join, so a Step already in flight is waited for rather than raced (TestAbandonWaitsForBusyStepThenAbandonsOnce): nothing here calls Step again once Abandon has been asked for (the phase==Abandoning guard on Poke/pollMsg/ stepMsg's own re-arm path), and Backend.Abandon itself is called at most once (TestAbandonWaitsForBusyStepThenAbandonsOnce's own ordered call log). The wait gives up after Config.AbandonTimeout and proceeds anyway (TestAbandonTimeoutProceeds) — Backend.Abandon's own re-observation is what keeps that safe even if the wait gave up too early (see internal/service.Abandon's own doc comment). The call itself is bounded separately, and far more generously, by Config.AbandonCallTimeout.

func (Controller) AnyRunning

func (c Controller) AnyRunning() bool

AnyRunning reports whether at least one tracked entry is actually in progress — Building, Stepping, or Waiting, or an Abandoning entry whose Backend.Abandon call has actually been dispatched (abandonIssued) — the root's own q-with-drives-running gate. A Stopped entry (a Blocked step or a terminal, non-retryable error — R/Poke re-arms it) or one still Abandoning but not yet abandonIssued (waiting on a busy Step to notice its ctx was cancelled — nothing has actually been dispatched to the forge/cluster yet) does not, by itself, need a confirm before the program quits: StopAll's own ctx cancel is all there would be to interrupt. An abandonIssued entry is different — a real Backend.Abandon call (re-observe, close the PR, delete the branch) is in flight, and quitting out from under it is exactly the "kills a running service.Abandon mid-way" case a confirm exists for (so only an Abandoning entry whose abandonIssued is false is excluded).

func (Controller) BuildSnapshot

func (c Controller) BuildSnapshot(build BuildID) (Snapshot, bool)

BuildSnapshot is Snapshot's BuildID-keyed twin — the id-less window before a build's real promotion id is known.

func (Controller) CancelBuild

func (c Controller) CancelBuild(build BuildID) Controller

CancelBuild drops one build by id without calling Backend.Abandon (Stop's own BuildID-keyed twin — used while a build has no promotion id yet to key Stop by).

func (Controller) Init

func (c Controller) Init() tea.Cmd

Init starts Controller's own background loop: an immediate listing and the recurring listTickMsg chain at Config.ListEvery. Returns nil when backend is nil — nothing to list.

func (Controller) Live

func (c Controller) Live() []Snapshot

Live returns every entry Controller currently tracks, in no particular order — the matrix's in-flight pane's own source, merged with a fresh listing (Relist) by whatever wires this in.

func (Controller) OverrideCINone

func (c Controller) OverrideCINone(id string) (Controller, tea.Cmd, error)

OverrideCINone re-arms and steps promotion id with Driver.OverrideCINone called first — the operator's `c` gesture answer (OverrideCINoneMsg, one layer up). Refused (ErrBusy) while already busy or abandoning, exactly like Poke.

func (Controller) Poke

func (c Controller) Poke(id string) (Controller, tea.Cmd, error)

Poke re-arms and steps promotion id at once — the operator's R. Refused (ErrBusy) while a Step is already outstanding or while abandoning; refused (ErrNotFound) for an untracked id.

func (Controller) Relist

func (c Controller) Relist() (Controller, tea.Cmd)

Relist issues a fresh listing right away, superseding any listTickMsg chain still in flight from before (its gen no longer matches, so Update drops it once it fires).

func (Controller) Resume

func (c Controller) Resume(id string) (Controller, BuildID, tea.Cmd, error)

Resume re-attaches to (or starts resuming) promotion id. If id is already tracked — running or itself mid-resume — this starts nothing and returns that entry's own BuildID (TestResumeOfRunningIDStartsNothing: Backend.Resume must be called zero times).

func (Controller) Running

func (c Controller) Running(id string) bool

Running reports whether id names an entry Controller is currently tracking, in any phase — running, waiting, stopped, or itself mid-build/resume.

func (Controller) Snapshot

func (c Controller) Snapshot(id string) (Snapshot, bool)

Snapshot returns promotion id's current Snapshot, or false when it is not tracked.

func (Controller) Start

func (c Controller) Start(req service.StartRequest, source, target string) (Controller, BuildID, tea.Cmd, error)

Start begins a fresh StartPromotion for req, tracked under a new BuildID — refused outright (ErrTargetBusy) when another entry already targets the same env (AGENTS.md invariant 5, TestSecondStartSameTargetRefused), and (ErrNoBackend) when this Controller has none.

func (Controller) Stop

func (c Controller) Stop(id string) Controller

Stop drops id from Controller without calling Backend.Abandon — the operator backing out of watching it (esc), leaving the real branch/PR/state file exactly as they are. Its ctx is cancelled first, so a Step in flight for it does not keep running unwatched.

func (Controller) StopAll

func (c Controller) StopAll() Controller

StopAll cancels and drops every tracked entry — the operator's q-with-drives-running confirm, answered yes.

func (Controller) Update

func (c Controller) Update(ev Event) (Controller, tea.Cmd, []Change)

Update is Controller's own message handler: every background command this package issues eventually delivers one Event here, and every state change Controller ever makes happens inside this method or the request methods above (Start, Resume, Poke, OverrideCINone, Abandon, Stop, StopAll, CancelBuild, Relist) — never anywhere else. Returns the changes a caller should react to (re-key a building screen, mirror a snapshot, refresh the matrix, log an activity entry) — most events produce exactly one, some (a stepMsg that both lands and finishes) two.

type Driver

type Driver interface {
	Step(ctx context.Context) (service.Tick, error)
	State() engine.PromotionState
	OverrideCINone()
}

Driver is what Controller drives one entry's promotion through — internal/service.Drive's own consumer-side interface, narrowed to exactly what a poll needs. *service.Driver, returned by Backend.StartPromotion/Resume as a service.Drive, satisfies it directly (a superset interface always does); a test fakes it without building a real service.Driver.

type Event

type Event interface {
	// contains filtered or unexported methods
}

Event is the private message shape every Controller-issued tea.Cmd produces. It satisfies tea.Msg like any Go value, but is deliberately not exported with a Msg suffix and carries an unexported method — see doc.go's own "Design" section for why: internal/parity's own parser only ever looks for `case pkg.XMsg` in internal/app/app.go, and treating this package's internal plumbing as a navigation message the operator can trigger would need a parity row naming something that plumbs nowhere. internal/app/app.go's Update has exactly one `case session.Event:` for it; nothing outside this package ever needs to construct or type-switch on one of the concrete types below.

type LogLine

type LogLine struct {
	At   time.Time
	Text string
}

LogLine is one progress line Controller has seen for a build, oldest first — the Snapshot's own equivalent of flight.Model's buildLog, now owned here so a mirroring screen (the flight screen) never has to keep its own copy in sync with a channel it does not own.

type Phase

type Phase int

Phase is one entry's own coarse status, independent of the step-by-step engine.StepStatus rows Snapshot.Statuses carries.

const (
	// Building is the preflight window before a real PromotionState exists — StartPromotion or
	// Resume is still running.
	Building Phase = iota
	// Stepping is a Driver.Step call actually in flight.
	Stepping
	// Waiting is between polls — the last Step reported Tick.Waiting and the next pollMsg has
	// not fired yet.
	Waiting
	// Stopped is a Blocked step or a terminal (non-retryable) error — R (Poke) can re-arm it.
	Stopped
	// Done means the promotion finished; the entry is removed the same Update call this phase
	// would otherwise be observed in (see Update's own stepMsg case).
	Done
	// Abandoning is between an Abandon call and Backend.Abandon actually running — waiting for
	// a busy Step to notice its context was cancelled and return.
	Abandoning
)

func (Phase) String

func (p Phase) String() string

String names a Phase for logging/rendering — never used to decide anything here.

type Snapshot

type Snapshot struct {
	Build          BuildID
	ID             string
	Phase          Phase
	Source, Target string
	Direct         bool

	State    engine.PromotionState
	Statuses []engine.StepStatus
	Done     bool
	Blocked  *engine.BlockedError
	Err      error
	Retry    bool
	Busy     bool

	NextPoll, DeadlineAt time.Time
	Log                  []LogLine
}

Snapshot is the read-only view of one tracked entry a caller (a mirrored flight screen, the matrix's in-flight pane, a test) actually gets — never the entry itself, and never a live reference into anything Controller still owns: every slice is copied.

Jump to

Keyboard shortcuts

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