state.go

  1// Package engine runs a workflow: it executes actions, reads their results,
  2// routes between actions (DESIGN.md §8), and records everything as
  3// events.ndjson and state.json in the run directory. The same engine runs on
  4// the dev machine and, inside a sandboxed unit, on the runner.
  5package engine
  6
  7import (
  8	"encoding/json"
  9	"fmt"
 10	"os"
 11	"path/filepath"
 12	"time"
 13)
 14
 15// Run statuses (state.json and kind=run events).
 16const (
 17	StatusRunning   = "running"
 18	StatusPaused    = "paused"
 19	StatusSuccess   = "success"
 20	StatusFailure   = "failure"
 21	StatusCancelled = "cancelled"
 22)
 23
 24// Terminal reports whether status ends a run.
 25func Terminal(status string) bool {
 26	return status == StatusSuccess || status == StatusFailure || status == StatusCancelled
 27}
 28
 29// StateVersion versions state.json.
 30const StateVersion = 1
 31
 32// Dir is a run directory (DESIGN.md §9).
 33type Dir string
 34
 35func (d Dir) Path() string         { return string(d) }
 36func (d Dir) Base() string         { return filepath.Base(string(d)) }
 37func (d Dir) StateFile() string    { return filepath.Join(string(d), "state.json") }
 38func (d Dir) Events() string       { return filepath.Join(string(d), "events.ndjson") }
 39func (d Dir) ManifestFile() string { return filepath.Join(string(d), "manifest.yml") }
 40func (d Dir) State() string        { return filepath.Join(string(d), "state") }
 41func (d Dir) Artifacts() string    { return filepath.Join(string(d), "artifacts") }
 42func (d Dir) Results() string      { return filepath.Join(string(d), "results") }
 43func (d Dir) Outputs() string      { return filepath.Join(d.State(), "outputs.json") }
 44
 45// Create makes the run directory and its subdirectories.
 46func (d Dir) Create() error {
 47	for _, p := range []string{d.Path(), d.State(), d.Artifacts(), d.Results()} {
 48		if err := os.MkdirAll(p, 0o755); err != nil {
 49			return err
 50		}
 51	}
 52	return nil
 53}
 54
 55// Meta describes what a run is running. It is fixed when the run is created.
 56type Meta struct {
 57	Run           string `json:"run"`
 58	Workflow      string `json:"workflow"`
 59	Repo          string `json:"repo"`              // repository name
 60	RepoDid       string `json:"repoDid,omitempty"` // did:web:<forge>:<repo> on the runner
 61	Knot          string `json:"knot,omitempty"`
 62	Sha           string `json:"sha,omitempty"`
 63	Ref           string `json:"ref,omitempty"`
 64	DefaultBranch string `json:"defaultBranch,omitempty"`
 65	Kind          string `json:"kind"`               // push | manual
 66	Pipeline      string `json:"pipeline,omitempty"` // AT-URI on the runner
 67	Host          string `json:"host"`               // local | runner
 68	Root          string `json:"root"`               // main checkout
 69	Dirty         bool   `json:"dirty,omitempty"`    // local working copy with changes
 70	// Parent is the run directory a manual action run belongs to (ci action
 71	// --run); it shares that run's checkouts and $CI_STATE.
 72	Parent string `json:"parent,omitempty"`
 73	// Checkouts are the repos of the run, main first.
 74	Checkouts []Checkout `json:"checkouts,omitempty"`
 75}
 76
 77// Checkout is an extra repository checked out for a run.
 78type Checkout struct {
 79	Repo string `json:"repo"`
 80	Path string `json:"path"` // absolute
 81	Rev  string `json:"rev"`
 82	Env  string `json:"env,omitempty"`
 83}
 84
 85// Exec records one execution of an action.
 86type Exec struct {
 87	N        int    `json:"n"`
 88	Action   string `json:"action"`
 89	Then     string `json:"then"`
 90	Reason   string `json:"reason"`
 91	Status   string `json:"status"`
 92	ExitCode int    `json:"exitCode"`
 93	Result   string `json:"result"` // path relative to the run dir
 94	Stubbed  bool   `json:"stubbed,omitempty"`
 95	Started  string `json:"started"`
 96	Finished string `json:"finished,omitempty"`
 97}
 98
 99// State is state.json: everything needed to resume or inspect a run.
100type State struct {
101	Version int    `json:"version"`
102	Meta    Meta   `json:"meta"`
103	Status  string `json:"status"`
104	// Cursor is the index in steps of the next happy-path step.
105	Cursor int `json:"cursor"`
106	// Seq counts transitions; Execs counts executions.
107	Seq     int    `json:"seq"`
108	Execs   int    `json:"execs"`
109	History []Exec `json:"history,omitempty"`
110	// Gate is set while the run is paused at a gate.
111	Gate *PendingGate `json:"gate,omitempty"`
112	// End is set when the run has finished.
113	End     *End   `json:"end,omitempty"`
114	Created string `json:"created"`
115	Updated string `json:"updated"`
116}
117
118// LastExec returns the most recent execution, if any.
119func (s *State) LastExec() *Exec {
120	if len(s.History) == 0 {
121		return nil
122	}
123	return &s.History[len(s.History)-1]
124}
125
126// Load reads a run's state.json.
127func Load(d Dir) (*State, error) {
128	b, err := os.ReadFile(d.StateFile())
129	if err != nil {
130		return nil, err
131	}
132	var s State
133	if err := json.Unmarshal(b, &s); err != nil {
134		return nil, fmt.Errorf("%s: %w", d.StateFile(), err)
135	}
136	if s.Version != StateVersion {
137		return nil, fmt.Errorf("%s: state version %d, this engine reads %d", d.StateFile(), s.Version, StateVersion)
138	}
139	return &s, nil
140}
141
142// Save writes state.json atomically.
143func (s *State) Save(d Dir) error {
144	s.Updated = now()
145	b, err := json.MarshalIndent(s, "", "  ")
146	if err != nil {
147		return err
148	}
149	tmp := d.StateFile() + ".tmp"
150	if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
151		return err
152	}
153	return os.Rename(tmp, d.StateFile())
154}
155
156func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }