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) }