updateflow

package
v0.4.3 Latest Latest
Warning

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

Go to latest
Published: May 13, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package updateflow implements the cinematic 5-stage `bonsai update` flow.

Rail:

探 DISCOVER  択 SELECT  同 SYNC  衝 CONFLICT  結 YIELD

Stages are either on-rail (DISCOVER / SELECT / SYNC / YIELD) or off-rail (CONFLICT — chromeless, spliced lazily when the Sync step surfaces a conflict list). Every chrome primitive is imported from initflow — the package never reimplements header/footer/rail. Plan 31 Phase F.

Index

Constants

View Source
const (
	StageIdxDiscover = 0
	StageIdxSelect   = 1
	StageIdxSync     = 2
	StageIdxYield    = 3

	// StageIdxOffRail matches addflow's sentinel — off-rail stages suppress
	// the rail row in renderFrame (negative rail index).
	StageIdxOffRail = -1
)

Stage indices in the update-flow rail. Kept as named constants so splicer logic references them by name rather than magic integers. The Conflict stage renders off-rail (StageIdxOffRail) but still lives as a real step in the harness list when wr.HasConflicts() is true.

Variables

View Source
var StageLabels = []initflow.StageLabel{
	{Kanji: "探", Kana: "さがす", English: "DISCOVER"},
	{Kanji: "択", Kana: "えらぶ", English: "SELECT"},
	{Kanji: "同", Kana: "どう", English: "SYNC"},
	{Kanji: "結", Kana: "むすぶ", English: "YIELD"},
}

StageLabels holds the four on-rail update-flow labels in order. The Conflict stage has its own off-rail label rendered inside its body (see conflicts.go).

Functions

func NewSyncStage

NewSyncStage wraps initflow.GenerateStage with the update-flow rail labels and body title. Renders on-rail at StageIdxSync (rail position 2) so the user sees the active SYNC checkpoint while the generator pipeline runs.

action follows the standard GenerateAction contract — a one-shot func() error that performs the write pipeline. The update-flow caller passes a closure that applies user-selected discoveries, runs the AgentWorkspace / PathScopedRules / WorkflowSkills / SettingsJSON / WriteCatalogSnapshot pipeline, and returns errors.Join of any failures.

Types

type AgentDiscoveries

type AgentDiscoveries struct {
	AgentName  string                    // machine name (cfg.Agents key)
	AgentLabel string                    // display name (falls back to AgentName)
	Installed  *config.InstalledAgent    // pointer — mutated downstream when user accepts
	Valid      []generate.DiscoveredFile // user-selectable rows
	Invalid    []generate.DiscoveredFile // surfaced as warnings, not user-selectable
}

AgentDiscoveries bundles the scan result for a single installed agent. Valid files are user-promotable (clean frontmatter); Invalid files are surfaced as warnings inside the Discover stage panel.

type ConflictsStage

type ConflictsStage struct {
	initflow.Stage
	// contains filtered or unexported fields
}

ConflictsStage is the chromeless full-screen conflict-resolution surface (Plan 27 PR2 §C1-C5). Rendered only when the Grow action produced at least one ActionConflict file — the harness gates construction on wr.HasConflicts(), so this stage never instantiates for clean writes.

Layout: one row per conflict file in a vertical list. Each row carries:

[focus glyph] [action glyph · coloured by pick] [relative path] [action label]

Below the list a batch-resolve row renders three cells (Keep all / Overwrite all / Backup all) that apply the chosen action to every row via uppercase K/O/B.

The stage is chromeless — View() returns the full AltScreen frame without the header/enso-rail/footer chrome used by the four on-rail stages. The rail visible to the user (anchored on OBSERVE) stays unchanged while the conflict picker runs — no rail churn between Observe and Conflict.

Keystrokes (plan-27 PR2 §C2 + §C4):

  • ↑ ↓ / j k move focus row (no wrap)
  • 1 / 2 / 3 set focused row's action to Keep / Overwrite / Backup
  • ␣ cycle focused row's action (Keep → Overwrite → Backup → Keep)
  • K / O / B batch-resolve — apply action to every row
  • ↵ advance (complete stage)
  • shift+tab / esc back to Observe (harness pops cursor)

Result: map[string]config.ConflictAction keyed by FileResult.RelPath — one entry per conflict file. applyCinematicConflictPicks in cmd/add.go reads the map and dispatches per-file mutations.

func NewConflictsStage

func NewConflictsStage(ctx initflow.StageContext, wr *generate.WriteResult) *ConflictsStage

NewConflictsStage constructs the stage over the conflict entries present in wr. When wr has zero conflicts the ctor still returns a usable stage — it simply renders an empty body with a single "nothing to reconcile" line and completes on Enter. Callers should gate on wr.HasConflicts() before splicing.

func (*ConflictsStage) Chromeless

func (s *ConflictsStage) Chromeless() bool

Chromeless reports true so the harness yields View() verbatim without its default header/footer. Embedded initflow.Stage.Chromeless() already returns true, but ConflictsStage declares the method explicitly so the contract is obvious at call-sites that type-scan for Chromeless.

func (*ConflictsStage) Init

func (s *ConflictsStage) Init() tea.Cmd

Init implements tea.Model — no cmd on entry.

func (*ConflictsStage) Reset

func (s *ConflictsStage) Reset() tea.Cmd

Reset clears the completion flag but preserves per-file picks and focus so Esc-back → re-entry reads exactly where the user left off.

func (*ConflictsStage) Result

func (s *ConflictsStage) Result() any

Result returns the per-file ConflictAction map. Keys are RelPath values; every conflict entry is guaranteed to appear in the map (default Keep if untouched).

func (*ConflictsStage) Update

func (s *ConflictsStage) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update handles focus movement + per-row action + batch-resolve + advance + back.

func (*ConflictsStage) View

func (s *ConflictsStage) View() string

View returns the full AltScreen frame. Chromeless — no header/rail/footer. Mirrors initflow.PlantedStage.View for layout parity: centre vertically, compose title + divider + list + batch row + inline key hints.

type DiscoverStage

type DiscoverStage struct {
	initflow.Stage
	// contains filtered or unexported fields
}

DiscoverStage is the first on-rail stage — it runs a read-only custom-file scan across every installed agent, splits the hits into valid (promotable) and invalid (frontmatter-missing) groups, and renders a preview panel so the user sees exactly what the flow is about to offer before committing.

The stdout-warning pre-harness code from the legacy cmd/update.go:68-75 is moved into this stage's "warnings" body row — no stdout churn before the AltScreen paint, and the user gets the context inside the cinematic frame.

Result: []AgentDiscoveries — one entry per agent with any discovery (valid or invalid). Empty slice when the workspace is in sync with the catalog; the downstream Sync stage handles that as a no-op.

func NewDiscoverStage

func NewDiscoverStage(ctx initflow.StageContext, cwd string, cfg *config.ProjectConfig, cat *catalog.Catalog, lock *config.LockFile) *DiscoverStage

NewDiscoverStage constructs the Discover stage. The scan is deferred to the first Init call so the stage's ctor remains cheap.

func (*DiscoverStage) Discoveries

func (s *DiscoverStage) Discoveries() []AgentDiscoveries

Discoveries returns the scan result. Exposed for tests + downstream stages that consume the payload without going through the harness Result() path (e.g. the Sync stage reads this pointer directly).

func (*DiscoverStage) HasValidDiscoveries

func (s *DiscoverStage) HasValidDiscoveries() bool

HasValidDiscoveries reports whether any agent has at least one valid (user-selectable) custom file. Used by the splicer to gate the Select stage — no selections ⇒ jump straight to Sync.

func (*DiscoverStage) Init

func (s *DiscoverStage) Init() tea.Cmd

Init implements tea.Model — no cmd on entry.

func (*DiscoverStage) Reset

func (s *DiscoverStage) Reset() tea.Cmd

Reset preserves the scan result; Esc-back redisplays the same findings without re-running the filesystem walk.

func (*DiscoverStage) Result

func (s *DiscoverStage) Result() any

Result returns the discovery bundle. The downstream Select stage reads this through a SetDiscoveries shim rather than the harness prev[] slot to avoid the type-erasure cost; Result() is implemented for tests.

func (*DiscoverStage) Update

func (s *DiscoverStage) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update handles Enter-to-advance. Esc is consumed by the harness root.

func (*DiscoverStage) View

func (s *DiscoverStage) View() string

View composes the Discover stage body inside the shared frame.

type FlowInputs

type FlowInputs struct {
	Cwd     string
	Version string
	Cfg     *config.ProjectConfig
	Cat     *catalog.Catalog
	Lock    *config.LockFile
}

FlowInputs carries the shared dependencies runUpdate passes into the flow. Kept as a struct so the splicer closures can capture by pointer once rather than threading individual args across every stage ctor.

type Result

type Result struct {
	// ConfigChanged is true when at least one user-accepted discovery
	// mutated the project config (installed.Skills/Workflows/... grew).
	// Triggers a cfg.Save post-run.
	ConfigChanged bool

	// WriteResult is the filesystem snapshot populated by the Sync
	// action's generator calls. Post-run consumers read it for conflict
	// dispatch and the YIELD success panel's counts.
	WriteResult *generate.WriteResult

	// SyncErr, when non-nil, is the aggregated error surfaced by Sync.
	// The YIELD stage renders an error panel instead of the normal
	// success card.
	SyncErr error

	// Cancelled is true when the user Ctrl-C'd. Caller skips persistence.
	Cancelled bool
}

Result is the outcome payload returned from Run. Caller applies any post-flow persistence (cfg.Save / lock.Save) — the flow itself writes through generate.* pathways but leaves final config serialization to the command layer so `cmd/update.go` keeps full control.

func Run

func Run(cwd string, cfg *config.ProjectConfig, cat *catalog.Catalog, lock *config.LockFile, version string) (Result, error)

Run drives the cinematic 5-stage `bonsai update` flow end-to-end. The caller (cmd/update.go) retains responsibility for config/lock persistence — Run just orchestrates the rail and reports the outcome.

Stage order:

Discover (rail 0) → Select (rail 1, chromeless, gated on valid
discoveries) → Sync (rail 2) → Conflict (off-rail, gated on
wr.HasConflicts()) → Yield (rail 3)

Returns a populated Result even on Ctrl-C so callers can decide on persistence based on Cancelled and SyncErr fields.

func RunStatic

func RunStatic(cwd string, cfg *config.ProjectConfig, cat *catalog.Catalog, lock *config.LockFile, version string) (Result, error)

RunStatic is the non-TTY fallback. Auto-accepts every valid discovery, runs the sync pipeline, and surfaces conflicts as a returned error (no interactive picker). The caller handles persistence identically to the interactive path based on the returned Result.

Invalid discoveries (frontmatter missing/malformed) are surfaced as `warning: <relPath> — <Error>` lines on stderr — they don't fail the run, but CI/non-TTY callers get the signal that's otherwise dropped silently from RunStatic's filter loop.

type SelectStage

type SelectStage struct {
	initflow.Stage
	// contains filtered or unexported fields
}

SelectStage is a chromeless per-agent multi-select. Each agent with valid discoveries becomes a tab; within a tab each row is a file that can be toggled on/off. All rows default to selected (mirrors legacy huh.Selected(true) default from the pre-cinematic flow).

Keystrokes:

←→/h/l      cycle agent tabs
↑↓/j/k      move focus within the current tab
␣           toggle the focused file
a           toggle all files in current tab
↵           advance

Result: map[string][]string — keyed by AgentName, value is the list of selected "type:name" keys (matches legacy buildCustomFileOptions Value shape so applyCustomFileSelection can consume the output unchanged).

func NewSelectStage

func NewSelectStage(ctx initflow.StageContext, agents []AgentDiscoveries) *SelectStage

NewSelectStage constructs the chromeless per-agent multi-select. agents should contain ONLY entries with at least one valid discovery — the caller filters out invalid-only agents upstream.

func (*SelectStage) Chromeless

func (s *SelectStage) Chromeless() bool

Chromeless reports true so the harness yields the stage's View verbatim. Plan 31 §F §4 — chromeless per-agent tab strip.

func (*SelectStage) Init

func (s *SelectStage) Init() tea.Cmd

Init implements tea.Model — no cmd on entry.

func (*SelectStage) Reset

func (s *SelectStage) Reset() tea.Cmd

Reset preserves focus + selection across Esc-back / re-entry.

func (*SelectStage) Result

func (s *SelectStage) Result() any

Result returns the SelectedKeys map so the harness can expose the picks to downstream stages / post-harness callers via prev[].

func (*SelectStage) SelectedKeys

func (s *SelectStage) SelectedKeys() map[string][]string

SelectedKeys returns the per-agent list of selected "type:name" keys. Caller-side applyCustomFileSelection consumes this directly.

func (*SelectStage) Update

func (s *SelectStage) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update handles tab cycling + in-tab focus + space toggle + enter advance.

func (*SelectStage) View

func (s *SelectStage) View() string

View renders the chromeless body — vertically-centred inside AltScreen, same pattern as addflow.ConflictsStage.

type YieldInputs

type YieldInputs struct {
	WriteResult   *generate.WriteResult
	ConfigChanged bool
	SyncErr       error
	HintBlock     hints.Block
}

YieldInputs carries the data every yield variant needs at ctor time.

type YieldStage

type YieldStage struct {
	initflow.Stage
	// contains filtered or unexported fields
}

YieldStage is the terminal completion card at rail position 3 (結 YIELD). Three variants, all chromeless — matches addflow.YieldStage presentation so the update flow lands its exit with the same typographic beat.

func NewYieldStage

func NewYieldStage(ctx initflow.StageContext, inputs YieldInputs) *YieldStage

NewYieldStage constructs the Yield stage, auto-selecting the variant based on the inputs.

  • SyncErr != nil → yieldModeError
  • no changes + no conflicts → yieldModeUpToDate
  • otherwise → yieldModeSynced

Caller builds inputs from the cross-stage flow state — Sync's WriteResult + configChanged flag, any error bubbled up, and the pre-computed hints block.

func (*YieldStage) Chromeless

func (s *YieldStage) Chromeless() bool

Chromeless reports true — YieldStage renders its own centred exit card without the enso rail.

func (*YieldStage) Init

func (s *YieldStage) Init() tea.Cmd

Init implements tea.Model — no cmd on entry.

func (*YieldStage) Reset

func (s *YieldStage) Reset() tea.Cmd

Reset clears the completion flag so re-entry renders fresh.

func (*YieldStage) Result

func (s *YieldStage) Result() any

Result returns nil — terminal stage.

func (*YieldStage) Update

func (s *YieldStage) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update waits for ↵ / q / esc acknowledgement.

func (*YieldStage) View

func (s *YieldStage) View() string

View renders the exit card. Mirrors addflow.YieldStage — chromeless, vertically-centred body with inline hint row below.

Jump to

Keyboard shortcuts

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