Engine core: manifest, actions, result contract, routing, events, local CLI
- internal/manifest: YAML manifests normalised (string-for-list, gate: true, concurrency: <group>) and decoded strictly through the lexicon types; validation; upstream-compatible trigger matching (branch/tag/paths globs) - internal/engine: run dir, state.json, router (DESIGN.md §8: cursor, next, then, monotonic gates, final status rules, loop guard), action execution in the main checkout's devenv shell with the CI_*/TANGLED_* contract, result contract checks (strict JSON, declared actions, attachments confined to $CI_ARTIFACTS, outputs merged), stubs, --until, --no-follow - internal/runlog: events.ndjson writer/reader shared by every consumer - internal/render: terminal rendering for ci run and ci view - cmd/ci: run, action, validate, view [-f], runs, result (ci-result) - .tangled/workflows/ci.yml: this repo's own CI (vet, test, build) - flake: vendorHash for the new dependencies Gates stop the run with an error until the next issue. Bug de85a99.
31 files changed,  +3733, -77
A .golangci.yml
+14, -0
 1@@ -0,0 +1,14 @@
 2+version: "2"
 3+linters:
 4+  settings:
 5+    errcheck:
 6+      # Terminal output and deferred closes of read-only files: nothing useful
 7+      # to do with the error.
 8+      exclude-functions:
 9+        - fmt.Fprint
10+        - fmt.Fprintf
11+        - fmt.Fprintln
12+        - (*os.File).Close
13+        - (*os.File).Write
14+        - (io.Closer).Close
15+        - (*git.kilimanjaro.io/ci/internal/runlog.Writer).Close
A .tangled/workflows/ci.yml
+10, -0
 1@@ -0,0 +1,10 @@
 2+# This repo's own CI: `ci run ci` locally, or on the runner for pushes to main.
 3+when:
 4+  - event: [push, manual]
 5+    branch: [main]
 6+engine: devenv
 7+steps: [vet, test, build]
 8+actions:
 9+  vet:   { command: go vet ./... }
10+  test:  { command: go test ./... }
11+  build: { command: nix build .#ci --no-link --print-out-paths }
M DESIGN.md
+24, -6
 1@@ -283,9 +283,19 @@ Rules
 2 3. The chosen action receives `$CI_TRIGGER_RESULT` (the result that routed to
 3    it), `$CI_INPUTS`, and the shared `$CI_STATE`.
 4 4. Loop guard: at most 50 transitions per run (configurable).
 5-5. Final status: `success` only if every happy-path step completed; otherwise
 6-   `failure`, annotated with the off-path actions that ran (e.g. "rolled back
 7-   via rollback"). This maps onto `sh.tangled.pipeline.status`.
 8+5. Final status, by how the run ends:
 9+   - it runs off the end of the happy path → `success`;
10+   - a result says `next: "stop"` → `success`, or `failure` if that result is
11+     a failure (the way for an action to end a run early on purpose);
12+   - an action's `then: stop` → `success` only if the cursor is past the last
13+     step, otherwise `failure` ("stopped after rollback, before record");
14+   - a failure with no `next` → `failure`; a contract or engine error →
15+     `failure`; the loop guard → `failure`;
16+   - dismissing a declared gate reached by default routing → `cancelled`;
17+     dismissing a gate raised by a result → that result's status (`failure`
18+     stays `failure`, `success`/`warning` → `success`); an expired gate,
19+     `--until` or Ctrl-C → `cancelled`.
20+   These map onto `sh.tangled.pipeline.status` (`cancelled` and `failed`).
21 6. Gates always notify, and runs that end unsuccessfully always notify
22    (runner config, not code).
23 7. Approving a gate or running a `danger` action requires the owner's logged-in
24@@ -310,10 +320,17 @@ ci action <name> --no-follow        # run one action, print result + what routin
25 ci resume <run> [--choose rollback] # answer a gate / continue a paused run
26 ci action rollback --run <run>      # manual action against an earlier run's state
27 ci view <run>                       # render events.ndjson
28-ci trigger <repo> <wf> [--rev]      # ask the bridge for a manual pipeline
29+ci runs                             # list local runs ($CI_RUNS_DIR, default ~/.local/state/ci/runs)
30+ci trigger <repo> <wf> [--rev]      # ask the runner for a manual pipeline
31 ci wait <repo> <sha>                # block until done; print failing step's logs
32 ```
33 
34+Actions run as `devenv shell bash -euo pipefail -c 'cd "$CI_ACTION_DIR"; <command>'`
35+started in the **main checkout**, so the workflow's repo pins the toolchain
36+and the engine even when an action's `dir` is another repo (`../servers`).
37+`--shell bash` skips devenv, for debugging only. Exit codes: 0 success, 1
38+failure, 2 usage, 3 cancelled, 75 paused at a gate.
39+
40 A local `ci run deploy` deploys production for real, behind the same gates.
41 `--until` and `--stub` are the rehearsal tools.
42 
43@@ -325,8 +342,9 @@ Environment contract (versioned with the result schema)
44 | `CI_RESULT` | Where this action writes its result |
45 | `CI_STATE` | Shared state for the run's actions |
46 | `CI_ARTIFACTS` | Attachment directory |
47-| `CI_INPUTS` | Data from the `next` entry that routed here |
48-| `CI_TRIGGER_RESULT` | The result that routed here |
49+| `CI_INPUTS` | Path of a JSON file: the `inputs` of the `next` entry that routed here (`{}` if none) |
50+| `CI_TRIGGER_RESULT` | Path of the result file that routed here (empty if none) |
51+| `CI_ROOT`, `CI_WORKFLOW`, `CI_ATTEMPT` | Main checkout; workflow name; execution number in the run |
52 | `CI_SHA`, `CI_REF`, `CI_REPO` | What's being run |
53 | `CI_RUN_ID`, `CI_ACTION` | Identity of the run and action |
54 | `CI_HOST` | `local` or `runner`. Informational; recipes must not branch on it |
A cmd/ci/engine.go
+400, -0
  1@@ -0,0 +1,400 @@
  2+package main
  3+
  4+import (
  5+	"bytes"
  6+	"context"
  7+	"errors"
  8+	"flag"
  9+	"fmt"
 10+	"io"
 11+	"os"
 12+	"os/signal"
 13+	"path/filepath"
 14+	"sort"
 15+	"strings"
 16+	"syscall"
 17+	"time"
 18+
 19+	"git.kilimanjaro.io/ci/internal/engine"
 20+	"git.kilimanjaro.io/ci/internal/lex"
 21+	"git.kilimanjaro.io/ci/internal/manifest"
 22+	"git.kilimanjaro.io/ci/internal/render"
 23+	"git.kilimanjaro.io/ci/internal/runlog"
 24+	"git.kilimanjaro.io/ci/internal/tid"
 25+	"git.kilimanjaro.io/ci/internal/vcs"
 26+)
 27+
 28+// Exit codes of run, action and resume.
 29+const (
 30+	exitSuccess   = 0
 31+	exitFailure   = 1
 32+	exitUsage     = 2
 33+	exitCancelled = 3
 34+	exitPaused    = 75 // EX_TEMPFAIL: waiting at a gate
 35+)
 36+
 37+func init() {
 38+	commands["run"] = command{"run a workflow from its first step", cmdRun}
 39+	commands["action"] = command{"run a workflow starting at one action", cmdAction}
 40+	commands["validate"] = command{"check workflow manifests", cmdValidate}
 41+	commands["view"] = command{"render a run's events", cmdView}
 42+	commands["runs"] = command{"list local runs", cmdRuns}
 43+}
 44+
 45+// runsDir is where local runs live: $CI_RUNS_DIR, else
 46+// $XDG_STATE_HOME/ci/runs, else ~/.local/state/ci/runs.
 47+func runsDir() string {
 48+	if d := os.Getenv("CI_RUNS_DIR"); d != "" {
 49+		return d
 50+	}
 51+	if d := os.Getenv("XDG_STATE_HOME"); d != "" {
 52+		return filepath.Join(d, "ci", "runs")
 53+	}
 54+	home, _ := os.UserHomeDir()
 55+	return filepath.Join(home, ".local", "state", "ci", "runs")
 56+}
 57+
 58+type stubFlag map[string]string
 59+
 60+func (s stubFlag) String() string { return "" }
 61+func (s stubFlag) Set(v string) error {
 62+	a, f, ok := strings.Cut(v, "=")
 63+	if !ok || a == "" || f == "" {
 64+		return fmt.Errorf("want <action>=<result.json>, got %q", v)
 65+	}
 66+	abs, err := filepath.Abs(f)
 67+	if err != nil {
 68+		return err
 69+	}
 70+	s[a] = abs
 71+	return nil
 72+}
 73+
 74+// runFlags are shared by run and action.
 75+type runFlags struct {
 76+	fs       *flag.FlagSet
 77+	stubs    stubFlag
 78+	until    string
 79+	shell    string
 80+	ref      string
 81+	maxTrans int
 82+	quiet    bool
 83+}
 84+
 85+func newRunFlags(name string) *runFlags {
 86+	f := &runFlags{fs: flag.NewFlagSet("ci "+name, flag.ContinueOnError), stubs: stubFlag{}}
 87+	f.fs.Var(f.stubs, "stub", "use `action=result.json` instead of running the action (repeatable)")
 88+	f.fs.StringVar(&f.until, "until", "", "stop before this `action` runs")
 89+	f.fs.StringVar(&f.shell, "shell", "devenv", "how actions run: devenv (default) or bash (no devenv; debugging)")
 90+	f.fs.StringVar(&f.ref, "ref", "", "ref to report as CI_REF/TANGLED_REF, e.g. refs/heads/main")
 91+	f.fs.IntVar(&f.maxTrans, "max-transitions", 0, "loop guard (default 50)")
 92+	f.fs.BoolVar(&f.quiet, "q", false, "don't render events (they are still written to the run)")
 93+	return f
 94+}
 95+
 96+// parse parses flags and positionals in any order.
 97+func (f *runFlags) parse(args []string) ([]string, error) {
 98+	var pos []string
 99+	for {
100+		if err := f.fs.Parse(args); err != nil {
101+			return nil, err
102+		}
103+		args = f.fs.Args()
104+		if len(args) == 0 {
105+			return pos, nil
106+		}
107+		pos = append(pos, args[0])
108+		args = args[1:]
109+	}
110+}
111+
112+func (f *runFlags) executor() (engine.Executor, error) {
113+	switch f.shell {
114+	case "devenv":
115+		return engine.Devenv{}, nil
116+	case "bash":
117+		return engine.Bash{}, nil
118+	}
119+	return nil, fmt.Errorf("--shell: %q (want devenv or bash)", f.shell)
120+}
121+
122+// loadWorkflow resolves a workflow argument: a name under .tangled/workflows
123+// of the repository containing the working directory, or a file path.
124+func loadWorkflow(arg string) (*manifest.Manifest, string, error) {
125+	root, err := vcs.Root(".")
126+	if err != nil {
127+		return nil, "", err
128+	}
129+	p := arg
130+	if !strings.ContainsRune(arg, os.PathSeparator) && !strings.HasSuffix(arg, ".yml") && !strings.HasSuffix(arg, ".yaml") {
131+		if p, err = manifest.Find(root, arg); err != nil {
132+			return nil, "", err
133+		}
134+	}
135+	m, err := manifest.Load(p)
136+	return m, root, err
137+}
138+
139+func cmdRun(args []string) int    { return runCommon("run", args) }
140+func cmdAction(args []string) int { return runCommon("action", args) }
141+
142+func runCommon(name string, args []string) int {
143+	f := newRunFlags(name)
144+	noFollow := false
145+	usage := "ci run [flags] <workflow>"
146+	if name == "action" {
147+		f.fs.BoolVar(&noFollow, "no-follow", false, "run only this action; report what routing would do next")
148+		usage = "ci action [flags] <workflow> <action>"
149+	}
150+	f.fs.Usage = func() {
151+		fmt.Fprintf(os.Stderr, "usage: %s\n", usage)
152+		f.fs.PrintDefaults()
153+	}
154+	pos, err := f.parse(args)
155+	if err != nil {
156+		return exitUsage
157+	}
158+	want := 1
159+	if name == "action" {
160+		want = 2
161+	}
162+	if len(pos) != want {
163+		f.fs.Usage()
164+		return exitUsage
165+	}
166+	ex, err := f.executor()
167+	if err != nil {
168+		fmt.Fprintln(os.Stderr, "ci:", err)
169+		return exitUsage
170+	}
171+	m, root, err := loadWorkflow(pos[0])
172+	if err != nil {
173+		fmt.Fprintln(os.Stderr, "ci:", err)
174+		return exitFailure
175+	}
176+	for a := range f.stubs {
177+		if _, ok := m.Actions[a]; !ok {
178+			fmt.Fprintf(os.Stderr, "ci: --stub %s: no such action in %s\n", a, m.Name)
179+			return exitUsage
180+		}
181+	}
182+	sha, dirty, err := vcs.Head(root)
183+	if err != nil {
184+		msg, _, _ := strings.Cut(err.Error(), "\n")
185+		fmt.Fprintln(os.Stderr, "ci: warning: no commit for the working copy:", msg)
186+	}
187+	id := tid.New()
188+	meta := engine.Meta{
189+		Run: id, Workflow: m.Name, Repo: filepath.Base(root), Sha: sha, Ref: f.ref,
190+		Kind: "manual", Host: "local", Root: root, Dirty: dirty,
191+	}
192+	dir := engine.Dir(filepath.Join(runsDir(), id))
193+	e, err := engine.Create(m, dir, meta, ex, engine.Options{
194+		Stubs: f.stubs, Until: f.until, NoFollow: noFollow, MaxTransitions: f.maxTrans,
195+	})
196+	if err != nil {
197+		fmt.Fprintln(os.Stderr, "ci:", err)
198+		return exitFailure
199+	}
200+	defer e.Log.Close()
201+	if !f.quiet {
202+		r := render.New(os.Stdout)
203+		// replay what Create already wrote, then render live
204+		if fh, err := os.Open(dir.Events()); err == nil {
205+			_ = runlog.Scan(fh, func(ev lex.RunEvent) error { r.Event(ev); return nil })
206+			fh.Close()
207+		}
208+		e.Log.AddSink(r.Event)
209+	}
210+
211+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
212+	defer stop()
213+	var st *engine.State
214+	if name == "action" {
215+		st, err = e.RunFrom(ctx, pos[1])
216+	} else {
217+		st, err = e.Run(ctx)
218+	}
219+	if err != nil {
220+		fmt.Fprintln(os.Stderr, "ci:", err)
221+		return exitFailure
222+	}
223+	fmt.Fprintf(os.Stderr, "run %s: %s\n", id, dir)
224+	return exitCode(st.Status)
225+}
226+
227+func exitCode(status string) int {
228+	switch status {
229+	case engine.StatusSuccess:
230+		return exitSuccess
231+	case engine.StatusCancelled:
232+		return exitCancelled
233+	case engine.StatusPaused:
234+		return exitPaused
235+	}
236+	return exitFailure
237+}
238+
239+func cmdValidate(args []string) int {
240+	fs := flag.NewFlagSet("ci validate", flag.ContinueOnError)
241+	fs.Usage = func() {
242+		fmt.Fprintln(os.Stderr, "usage: ci validate [workflow|file ...]  (default: every workflow in the repo)")
243+	}
244+	if err := fs.Parse(args); err != nil {
245+		return exitUsage
246+	}
247+	paths := fs.Args()
248+	if len(paths) == 0 {
249+		root, err := vcs.Root(".")
250+		if err != nil {
251+			fmt.Fprintln(os.Stderr, "ci:", err)
252+			return exitFailure
253+		}
254+		if paths, err = manifest.List(root); err != nil {
255+			fmt.Fprintln(os.Stderr, "ci:", err)
256+			return exitFailure
257+		}
258+		if len(paths) == 0 {
259+			fmt.Fprintf(os.Stderr, "ci: no workflows in %s\n", filepath.Join(root, manifest.Dir))
260+			return exitFailure
261+		}
262+	}
263+	code := exitSuccess
264+	for _, p := range paths {
265+		m, _, err := loadWorkflow(p)
266+		switch {
267+		case errors.Is(err, manifest.ErrOtherEngine):
268+			fmt.Printf("skip %s: %v\n", p, err)
269+		case err != nil:
270+			fmt.Printf("FAIL %v\n", err)
271+			code = exitFailure
272+		default:
273+			fmt.Printf("ok   %s: %s\n", m.Name, strings.Join(m.Steps, " → "))
274+		}
275+	}
276+	return code
277+}
278+
279+// runDir resolves a run argument: a run directory, a run id under runsDir,
280+// or "last".
281+func runDir(arg string) (engine.Dir, error) {
282+	if arg == "last" {
283+		runs, err := listRuns()
284+		if err != nil {
285+			return "", err
286+		}
287+		if len(runs) == 0 {
288+			return "", fmt.Errorf("no runs in %s", runsDir())
289+		}
290+		return runs[len(runs)-1], nil
291+	}
292+	for _, p := range []string{arg, filepath.Join(runsDir(), arg)} {
293+		if _, err := os.Stat(filepath.Join(p, "state.json")); err == nil {
294+			return engine.Dir(p), nil
295+		}
296+	}
297+	return "", fmt.Errorf("%w: %s", engine.ErrNoRun, arg)
298+}
299+
300+// listRuns returns run directories under runsDir, oldest first (run ids are
301+// TIDs, which sort by time).
302+func listRuns() ([]engine.Dir, error) {
303+	ents, err := os.ReadDir(runsDir())
304+	if errors.Is(err, os.ErrNotExist) {
305+		return nil, nil
306+	}
307+	if err != nil {
308+		return nil, err
309+	}
310+	var out []engine.Dir
311+	for _, e := range ents {
312+		if _, err := os.Stat(filepath.Join(runsDir(), e.Name(), "state.json")); e.IsDir() && err == nil {
313+			out = append(out, engine.Dir(filepath.Join(runsDir(), e.Name())))
314+		}
315+	}
316+	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
317+	return out, nil
318+}
319+
320+func cmdView(args []string) int {
321+	fs := flag.NewFlagSet("ci view", flag.ContinueOnError)
322+	follow := fs.Bool("f", false, "follow a running run until it ends or pauses")
323+	fs.Usage = func() {
324+		fmt.Fprintln(os.Stderr, "usage: ci view [-f] <run-id|run-dir|last>")
325+		fs.PrintDefaults()
326+	}
327+	if err := fs.Parse(args); err != nil || fs.NArg() != 1 {
328+		fs.Usage()
329+		return exitUsage
330+	}
331+	dir, err := runDir(fs.Arg(0))
332+	if err != nil {
333+		fmt.Fprintln(os.Stderr, "ci:", err)
334+		return exitFailure
335+	}
336+	r := render.New(os.Stdout)
337+	fh, err := os.Open(dir.Events())
338+	if err != nil {
339+		fmt.Fprintln(os.Stderr, "ci:", err)
340+		return exitFailure
341+	}
342+	defer fh.Close()
343+	var off int64
344+	for {
345+		n, err := renderFrom(fh, off, r.Event)
346+		if err != nil {
347+			fmt.Fprintln(os.Stderr, "ci:", err)
348+			return exitFailure
349+		}
350+		off += n
351+		st, err := engine.Load(dir)
352+		if err != nil {
353+			fmt.Fprintln(os.Stderr, "ci:", err)
354+			return exitFailure
355+		}
356+		if !*follow || st.Status != engine.StatusRunning {
357+			return exitCode(st.Status)
358+		}
359+		time.Sleep(250 * time.Millisecond)
360+	}
361+}
362+
363+// renderFrom renders the complete lines of f after offset off and returns
364+// how many bytes they took, so `view -f` never renders a half-written line.
365+func renderFrom(f *os.File, off int64, render func(lex.RunEvent)) (int64, error) {
366+	if _, err := f.Seek(off, io.SeekStart); err != nil {
367+		return 0, err
368+	}
369+	b, err := io.ReadAll(f)
370+	if err != nil {
371+		return 0, err
372+	}
373+	end := bytes.LastIndexByte(b, '\n')
374+	if end < 0 {
375+		return 0, nil
376+	}
377+	err = runlog.Scan(bytes.NewReader(b[:end+1]), func(ev lex.RunEvent) error { render(ev); return nil })
378+	return int64(end + 1), err
379+}
380+
381+func cmdRuns(args []string) int {
382+	runs, err := listRuns()
383+	if err != nil {
384+		fmt.Fprintln(os.Stderr, "ci:", err)
385+		return exitFailure
386+	}
387+	for _, d := range runs {
388+		st, err := engine.Load(d)
389+		if err != nil {
390+			continue
391+		}
392+		detail := ""
393+		if st.End != nil {
394+			detail = st.End.Detail
395+		} else if st.Gate != nil {
396+			detail = "waiting at gate: " + st.Gate.Prompt
397+		}
398+		fmt.Printf("%s  %-9s %-12s %-10s %s\n", filepath.Base(string(d)), st.Status, st.Meta.Repo, st.Meta.Workflow, detail)
399+	}
400+	return exitSuccess
401+}
M cmd/ci/main.go
+1, -0
1@@ -36,6 +36,7 @@ func usage() {
2 }
3 
4 func main() {
5+	addStubs()
6 	args := os.Args[1:]
7 	if filepath.Base(os.Args[0]) == "ci-result" {
8 		args = append([]string{"result"}, args...)
A cmd/ci/result.go
+299, -0
  1@@ -0,0 +1,299 @@
  2+package main
  3+
  4+import (
  5+	"encoding/json"
  6+	"fmt"
  7+	"io"
  8+	"os"
  9+	"path/filepath"
 10+	"strings"
 11+
 12+	"git.kilimanjaro.io/ci/internal/lex"
 13+)
 14+
 15+func init() {
 16+	commands["result"] = command{"write an action result to $CI_RESULT (alias: ci-result)", cmdResult}
 17+}
 18+
 19+const resultUsage = `usage: ci-result <ok|warn|fail> [flags]
 20+
 21+Writes a run.rtw.ci.step.result to $CI_RESULT (DESIGN.md §7).
 22+
 23+  --summary TEXT          markdown summary (repeatable; joined by newlines)
 24+  --summary-file FILE     read the summary from FILE ("-" for stdin)
 25+  --next ACTION           route to ACTION; repeat for a choice. The flags
 26+                          below apply to the most recent --next:
 27+    --then continue|stop    after ACTION succeeds
 28+    --label TEXT            button label
 29+    --prompt TEXT           question for the person
 30+    --input KEY=VALUE       string passed in $CI_INPUTS (repeatable)
 31+    --inputs-json JSON      object passed in $CI_INPUTS
 32+  --stop                  end the run after this action
 33+  --gated                 ask a person before taking --next
 34+  --attach [LABEL=]PATH   attach a file; copied into $CI_ARTIFACTS if outside
 35+                          it; type from the extension (.md .ndjson .json, else text)
 36+  --output KEY=VALUE      string merged into $CI_STATE/outputs.json (repeatable)
 37+  --outputs-json JSON     object merged into $CI_STATE/outputs.json
 38+  --stdout                print the result instead of writing $CI_RESULT
 39+`
 40+
 41+func cmdResult(args []string) int {
 42+	r, toStdout, err := parseResult(args)
 43+	if err != nil {
 44+		fmt.Fprintf(os.Stderr, "ci-result: %v\n\n%s", err, resultUsage)
 45+		return exitUsage
 46+	}
 47+	b, err := json.MarshalIndent(r, "", "  ")
 48+	if err != nil {
 49+		fmt.Fprintln(os.Stderr, "ci-result:", err)
 50+		return exitFailure
 51+	}
 52+	b = append(b, '\n')
 53+	if toStdout {
 54+		os.Stdout.Write(b)
 55+		return exitSuccess
 56+	}
 57+	path := os.Getenv("CI_RESULT")
 58+	if path == "" {
 59+		fmt.Fprintln(os.Stderr, "ci-result: $CI_RESULT is not set (not inside a ci action? use --stdout)")
 60+		return exitFailure
 61+	}
 62+	if err := os.WriteFile(path, b, 0o644); err != nil {
 63+		fmt.Fprintln(os.Stderr, "ci-result:", err)
 64+		return exitFailure
 65+	}
 66+	return exitSuccess
 67+}
 68+
 69+func parseResult(args []string) (lex.Result, bool, error) {
 70+	r := lex.Result{Version: lex.ResultVersion}
 71+	if len(args) == 0 {
 72+		return r, false, fmt.Errorf("missing status")
 73+	}
 74+	switch args[0] {
 75+	case "ok", "success":
 76+		r.Status = lex.Success
 77+	case "warn", "warning":
 78+		r.Status = lex.Warning
 79+	case "fail", "failure":
 80+		r.Status = lex.Failure
 81+	case "-h", "--help", "help":
 82+		return r, false, fmt.Errorf("help")
 83+	default:
 84+		return r, false, fmt.Errorf("status %q (want ok, warn or fail)", args[0])
 85+	}
 86+	args = args[1:]
 87+
 88+	var (
 89+		summary  []string
 90+		toStdout bool
 91+		stop     bool
 92+		opts     []lex.Option
 93+		inputs   []map[string]any
 94+		outputs  = map[string]any{}
 95+	)
 96+	value := func(i int, flag string) (string, error) {
 97+		if i+1 >= len(args) {
 98+			return "", fmt.Errorf("%s needs a value", flag)
 99+		}
100+		return args[i+1], nil
101+	}
102+	lastOpt := func(flag string) (*lex.Option, error) {
103+		if len(opts) == 0 {
104+			return nil, fmt.Errorf("%s must follow --next", flag)
105+		}
106+		return &opts[len(opts)-1], nil
107+	}
108+	for i := 0; i < len(args); i++ {
109+		flag := args[i]
110+		if k, v, ok := strings.Cut(flag, "="); ok && strings.HasPrefix(k, "--") {
111+			args = append(args[:i], append([]string{k, v}, args[i+1:]...)...)
112+			flag = k
113+		}
114+		switch flag {
115+		case "--stdout":
116+			toStdout = true
117+			continue
118+		case "--stop":
119+			stop = true
120+			continue
121+		case "--gated":
122+			r.Gated = true
123+			continue
124+		}
125+		v, err := value(i, flag)
126+		if err != nil {
127+			return r, false, err
128+		}
129+		i++
130+		switch flag {
131+		case "--summary":
132+			summary = append(summary, v)
133+		case "--summary-file":
134+			var b []byte
135+			if v == "-" {
136+				b, err = io.ReadAll(os.Stdin)
137+			} else {
138+				b, err = os.ReadFile(v)
139+			}
140+			if err != nil {
141+				return r, false, err
142+			}
143+			summary = append(summary, strings.TrimRight(string(b), "\n"))
144+		case "--next":
145+			opts = append(opts, lex.Option{Action: v})
146+			inputs = append(inputs, nil)
147+		case "--then":
148+			o, err := lastOpt(flag)
149+			if err != nil {
150+				return r, false, err
151+			}
152+			if v != lex.ThenContinue && v != lex.ThenStop {
153+				return r, false, fmt.Errorf("--then %q (want continue or stop)", v)
154+			}
155+			o.Then = v
156+		case "--label":
157+			o, err := lastOpt(flag)
158+			if err != nil {
159+				return r, false, err
160+			}
161+			o.Label = v
162+		case "--prompt":
163+			o, err := lastOpt(flag)
164+			if err != nil {
165+				return r, false, err
166+			}
167+			o.Prompt = v
168+		case "--input":
169+			if _, err := lastOpt(flag); err != nil {
170+				return r, false, err
171+			}
172+			k, val, ok := strings.Cut(v, "=")
173+			if !ok || k == "" {
174+				return r, false, fmt.Errorf("--input wants KEY=VALUE, got %q", v)
175+			}
176+			if inputs[len(inputs)-1] == nil {
177+				inputs[len(inputs)-1] = map[string]any{}
178+			}
179+			inputs[len(inputs)-1][k] = val
180+		case "--inputs-json":
181+			if _, err := lastOpt(flag); err != nil {
182+				return r, false, err
183+			}
184+			var m map[string]any
185+			if err := json.Unmarshal([]byte(v), &m); err != nil {
186+				return r, false, fmt.Errorf("--inputs-json: want an object: %v", err)
187+			}
188+			if inputs[len(inputs)-1] == nil {
189+				inputs[len(inputs)-1] = map[string]any{}
190+			}
191+			for k, x := range m {
192+				inputs[len(inputs)-1][k] = x
193+			}
194+		case "--attach":
195+			a, err := attach(v)
196+			if err != nil {
197+				return r, false, err
198+			}
199+			r.Attachments = append(r.Attachments, a)
200+		case "--output":
201+			k, val, ok := strings.Cut(v, "=")
202+			if !ok || k == "" {
203+				return r, false, fmt.Errorf("--output wants KEY=VALUE, got %q", v)
204+			}
205+			outputs[k] = val
206+		case "--outputs-json":
207+			var m map[string]any
208+			if err := json.Unmarshal([]byte(v), &m); err != nil {
209+				return r, false, fmt.Errorf("--outputs-json: want an object: %v", err)
210+			}
211+			for k, x := range m {
212+				outputs[k] = x
213+			}
214+		default:
215+			return r, false, fmt.Errorf("unknown flag %q", flag)
216+		}
217+	}
218+	if stop && len(opts) > 0 {
219+		return r, false, fmt.Errorf("--stop and --next are exclusive")
220+	}
221+	r.Summary = strings.Join(summary, "\n")
222+	for i, in := range inputs {
223+		if in != nil {
224+			b, _ := json.Marshal(in)
225+			opts[i].Inputs = b
226+		}
227+	}
228+	switch {
229+	case stop:
230+		r.Next = &lex.Next{Stop: true}
231+	case len(opts) > 0:
232+		r.Next = &lex.Next{Options: opts}
233+	}
234+	if len(outputs) > 0 {
235+		b, _ := json.Marshal(outputs)
236+		r.Outputs = b
237+	}
238+	return r, toStdout, nil
239+}
240+
241+// attach turns "[LABEL=]PATH" into an attachment relative to $CI_ARTIFACTS,
242+// copying the file in when it lives elsewhere (each action has a private
243+// /tmp that disappears when it ends).
244+func attach(spec string) (lex.Attachment, error) {
245+	label, p, ok := strings.Cut(spec, "=")
246+	if !ok {
247+		label, p = "", spec
248+	}
249+	a := lex.Attachment{Label: label, Type: typeOf(p)}
250+	art := os.Getenv("CI_ARTIFACTS")
251+	if art == "" {
252+		return a, fmt.Errorf("--attach: $CI_ARTIFACTS is not set")
253+	}
254+	abs := p
255+	if !filepath.IsAbs(abs) {
256+		// relative paths are relative to $CI_ARTIFACTS if the file is there,
257+		// else to the working directory
258+		if _, err := os.Stat(filepath.Join(art, p)); err == nil {
259+			a.Path = filepath.Clean(p)
260+			return a, nil
261+		}
262+		var err error
263+		if abs, err = filepath.Abs(p); err != nil {
264+			return a, err
265+		}
266+	}
267+	if rel, err := filepath.Rel(art, abs); err == nil && !strings.HasPrefix(rel, "..") {
268+		a.Path = rel
269+		return a, nil
270+	}
271+	b, err := os.ReadFile(abs)
272+	if err != nil {
273+		return a, fmt.Errorf("--attach: %w", err)
274+	}
275+	name := filepath.Base(abs)
276+	for n := 2; ; n++ {
277+		if _, err := os.Stat(filepath.Join(art, name)); os.IsNotExist(err) {
278+			break
279+		}
280+		ext := filepath.Ext(abs)
281+		name = fmt.Sprintf("%s-%d%s", strings.TrimSuffix(filepath.Base(abs), ext), n, ext)
282+	}
283+	if err := os.WriteFile(filepath.Join(art, name), b, 0o644); err != nil {
284+		return a, err
285+	}
286+	a.Path = name
287+	return a, nil
288+}
289+
290+func typeOf(p string) string {
291+	switch strings.ToLower(filepath.Ext(p)) {
292+	case ".md", ".markdown":
293+		return "markdown"
294+	case ".ndjson", ".jsonl":
295+		return "ndjson"
296+	case ".json":
297+		return "json"
298+	}
299+	return "text"
300+}
A cmd/ci/result_test.go
+68, -0
 1@@ -0,0 +1,68 @@
 2+package main
 3+
 4+import (
 5+	"encoding/json"
 6+	"os"
 7+	"path/filepath"
 8+	"testing"
 9+
10+	"git.kilimanjaro.io/ci/internal/lex"
11+)
12+
13+func TestParseResult(t *testing.T) {
14+	art := t.TempDir()
15+	t.Setenv("CI_ARTIFACTS", art)
16+	outside := filepath.Join(t.TempDir(), "probes.ndjson")
17+	if err := os.WriteFile(outside, []byte("{}\n"), 0o644); err != nil {
18+		t.Fatal(err)
19+	}
20+	if err := os.WriteFile(filepath.Join(art, "diff.md"), []byte("x"), 0o644); err != nil {
21+		t.Fatal(err)
22+	}
23+
24+	r, _, err := parseResult([]string{"fail",
25+		"--summary", "a: 503", "--summary=line two", "--gated",
26+		"--next", "rollback", "--then", "stop", "--prompt", "Roll back?", "--input", "host=a",
27+		"--next", "health-check", "--label", "Check again",
28+		"--attach", "probes=" + outside, "--attach", "diff.md",
29+		"--output", "k=v", "--outputs-json", `{"n":2}`,
30+	})
31+	if err != nil {
32+		t.Fatal(err)
33+	}
34+	b, _ := json.Marshal(r)
35+	var back lex.Result
36+	if err := lex.StrictUnmarshal(b, &back); err != nil {
37+		t.Fatalf("%s: %v", b, err)
38+	}
39+	if r.Status != lex.Failure || r.Summary != "a: 503\nline two" || !r.Gated {
40+		t.Errorf("%+v", r)
41+	}
42+	o := r.Next.Options
43+	if len(o) != 2 || o[0].Then != "stop" || o[0].Prompt != "Roll back?" || string(o[0].Inputs) != `{"host":"a"}` || o[1].Label != "Check again" {
44+		t.Errorf("options %+v", o)
45+	}
46+	if r.Attachments[0].Path != "probes.ndjson" || r.Attachments[0].Type != "ndjson" || r.Attachments[1].Path != "diff.md" || r.Attachments[1].Type != "markdown" {
47+		t.Errorf("attachments %+v", r.Attachments)
48+	}
49+	if _, err := os.Stat(filepath.Join(art, "probes.ndjson")); err != nil {
50+		t.Error("outside attachment not copied in")
51+	}
52+	if string(r.Outputs) != `{"k":"v","n":2}` {
53+		t.Errorf("outputs %s", r.Outputs)
54+	}
55+
56+	r, _, err = parseResult([]string{"ok", "--stop"})
57+	if err != nil || !r.Next.Stop || r.Status != lex.Success {
58+		t.Errorf("--stop: %+v %v", r, err)
59+	}
60+
61+	for _, bad := range [][]string{
62+		{}, {"maybe"}, {"ok", "--then", "stop"}, {"ok", "--next"}, {"ok", "--next", "a", "--then", "later"},
63+		{"ok", "--stop", "--next", "a"}, {"ok", "--output", "novalue"}, {"ok", "--bogus", "x"},
64+	} {
65+		if _, _, err := parseResult(bad); err == nil {
66+			t.Errorf("%v: accepted", bad)
67+		}
68+	}
69+}
M cmd/ci/stubs.go
+10, -13
 1@@ -5,20 +5,17 @@ import (
 2 	"os"
 3 )
 4 
 5-// Commands not implemented yet; each issue in the tracker replaces its stub.
 6-func init() {
 7+// addStubs registers the commands not implemented yet; each issue in the
 8+// tracker replaces its stub. Called from main after every init has run, so a
 9+// real command always wins.
10+func addStubs() {
11 	for name, summary := range map[string]string{
12-		"run":      "run a workflow (engine)",
13-		"resume":   "continue a paused run (engine)",
14-		"action":   "run one action (engine)",
15-		"validate": "check a workflow manifest (engine)",
16-		"view":     "render a run's events (engine)",
17-		"result":   "write an action result to $CI_RESULT (alias: ci-result)",
18-		"trigger":  "ask the runner for a manual pipeline",
19-		"wait":     "wait for a pipeline and print failing logs",
20-		"bridge":   "forge bridge (knot-compatible event stream)",
21-		"runner":   "runner daemon (spindle-compatible)",
22-		"appview":  "web UI",
23+		"resume":  "continue a paused run (engine)",
24+		"trigger": "ask the runner for a manual pipeline",
25+		"wait":    "wait for a pipeline and print failing logs",
26+		"bridge":  "forge bridge (knot-compatible event stream)",
27+		"runner":  "runner daemon (spindle-compatible)",
28+		"appview": "web UI",
29 	} {
30 		if _, ok := commands[name]; ok {
31 			continue
M flake.nix
+1, -1
1@@ -34,7 +34,7 @@
2               ./lexicons
3             ];
4           };
5-          vendorHash = null;
6+          vendorHash = "sha256-xWBzri5Qza/1abQHZtOhnRzRjw57lOrCZOhIQqoftOE=";
7           subPackages = [ "cmd/ci" ];
8           env.CGO_ENABLED = "0";
9           ldflags = [ "-X main.version=${version}" ];
M go.mod
+5, -0
1@@ -1,3 +1,8 @@
2 module git.kilimanjaro.io/ci
3 
4 go 1.26
5+
6+require (
7+	github.com/bmatcuk/doublestar/v4 v4.10.3 // indirect
8+	gopkg.in/yaml.v3 v3.0.1 // indirect
9+)
A go.sum
+5, -0
1@@ -0,0 +1,5 @@
2+github.com/bmatcuk/doublestar/v4 v4.10.3 h1:1UIv1+fqH3++ViVkpnaPTB+Pi1UHycnPFAq14zgK+gI=
3+github.com/bmatcuk/doublestar/v4 v4.10.3/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
4+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
6+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A internal/engine/engine.go
+295, -0
  1@@ -0,0 +1,295 @@
  2+package engine
  3+
  4+import (
  5+	"context"
  6+	"encoding/json"
  7+	"errors"
  8+	"fmt"
  9+	"os"
 10+	"path/filepath"
 11+
 12+	"git.kilimanjaro.io/ci/internal/lex"
 13+	"git.kilimanjaro.io/ci/internal/manifest"
 14+	"git.kilimanjaro.io/ci/internal/runlog"
 15+)
 16+
 17+// DefaultMaxTransitions is the loop guard (DESIGN.md §8 rule 4).
 18+const DefaultMaxTransitions = 50
 19+
 20+// Options change how a run executes.
 21+type Options struct {
 22+	// Stubs maps an action to a result file used instead of running it.
 23+	Stubs map[string]string
 24+	// Until stops the run before this action would run.
 25+	Until string
 26+	// NoFollow runs the first action only and reports what would follow.
 27+	NoFollow bool
 28+	// MaxTransitions overrides DefaultMaxTransitions.
 29+	MaxTransitions int
 30+}
 31+
 32+// Engine runs one workflow run.
 33+type Engine struct {
 34+	M       *manifest.Manifest
 35+	Dir     Dir
 36+	Exec    Executor
 37+	Log     *runlog.Writer
 38+	BaseEnv []string
 39+	Opts    Options
 40+
 41+	router *Router
 42+	st     *State
 43+}
 44+
 45+// Create starts a new run in dir: it writes the manifest copy, state.json and
 46+// the first event. Close the engine's Log when done.
 47+func Create(m *manifest.Manifest, dir Dir, meta Meta, ex Executor, opts Options) (*Engine, error) {
 48+	if err := dir.Create(); err != nil {
 49+		return nil, err
 50+	}
 51+	if _, err := os.Stat(dir.StateFile()); err == nil {
 52+		return nil, fmt.Errorf("run %s already exists", dir)
 53+	}
 54+	if err := os.WriteFile(dir.ManifestFile(), m.Raw, 0o644); err != nil {
 55+		return nil, err
 56+	}
 57+	log, err := runlog.Open(dir.Events(), meta.Run)
 58+	if err != nil {
 59+		return nil, err
 60+	}
 61+	t := now()
 62+	e := &Engine{
 63+		M: m, Dir: dir, Exec: ex, Log: log, BaseEnv: osEnviron(), Opts: opts,
 64+		router: &Router{M: m},
 65+		st:     &State{Version: StateVersion, Meta: meta, Status: StatusRunning, Created: t},
 66+	}
 67+	for _, c := range meta.Checkouts {
 68+		e.emit(lex.RunEvent{Kind: lex.KindCheckout, Repo: c.Repo, Rev: c.Rev, Path: c.Path})
 69+	}
 70+	detail := fmt.Sprintf("%s %s", m.Name, meta.Repo)
 71+	if meta.Sha != "" {
 72+		detail += "@" + short(meta.Sha)
 73+	}
 74+	if meta.Dirty {
 75+		detail += " (working copy with changes)"
 76+	}
 77+	e.emit(lex.RunEvent{Kind: lex.KindRun, Status: StatusRunning, Content: detail})
 78+	if err := e.st.Save(dir); err != nil {
 79+		return nil, err
 80+	}
 81+	return e, nil
 82+}
 83+
 84+func short(sha string) string {
 85+	if len(sha) > 12 {
 86+		return sha[:12]
 87+	}
 88+	return sha
 89+}
 90+
 91+// State returns the run's current state.
 92+func (e *Engine) State() *State { return e.st }
 93+
 94+// Run runs the workflow from its first step.
 95+func (e *Engine) Run(ctx context.Context) (*State, error) {
 96+	return e.drive(ctx, e.router.Start())
 97+}
 98+
 99+// RunFrom runs the workflow starting at action, as if a person had chosen
100+// it; routing then follows that action's result.
101+func (e *Engine) RunFrom(ctx context.Context, action string) (*State, error) {
102+	if _, ok := e.M.Actions[action]; !ok {
103+		return nil, fmt.Errorf("no action %q in %s", action, e.M.Name)
104+	}
105+	return e.drive(ctx, e.router.Begin(action))
106+}
107+
108+func (e *Engine) maxTransitions() int {
109+	if e.Opts.MaxTransitions > 0 {
110+		return e.Opts.MaxTransitions
111+	}
112+	return DefaultMaxTransitions
113+}
114+
115+// drive follows decisions until the run ends.
116+func (e *Engine) drive(ctx context.Context, d Decision) (*State, error) {
117+	for {
118+		if ctx.Err() != nil {
119+			return e.finish(End{StatusCancelled, lex.ReasonCancel, "interrupted"}, "")
120+		}
121+		switch {
122+		case d.End != nil:
123+			return e.finish(*d.End, e.lastAction())
124+		case d.Gate != nil:
125+			return e.finish(End{StatusFailure, lex.ReasonError, "reached a gate (" + optionNames(d.Gate) + "); gates are not implemented yet"}, e.lastAction())
126+		case d.Run != nil:
127+			p := *d.Run
128+			if e.st.Seq >= e.maxTransitions() {
129+				return e.finish(End{StatusFailure, lex.ReasonLimit,
130+					fmt.Sprintf("more than %d transitions; is a result routing in a loop?", e.maxTransitions())}, p.From)
131+			}
132+			if p.Action == e.Opts.Until {
133+				return e.finish(End{StatusCancelled, lex.ReasonUntil, "stopped before " + p.Action + " (--until)"}, p.From)
134+			}
135+			e.transition(lex.Transition{From: p.From, To: p.Action, Then: p.Then, Reason: p.Reason})
136+			res, file, err := e.execute(ctx, p)
137+			if err != nil {
138+				if ctx.Err() != nil {
139+					return e.finish(End{StatusCancelled, lex.ReasonCancel, "interrupted during " + p.Action}, p.Action)
140+				}
141+				return e.finish(End{StatusFailure, lex.ReasonError, err.Error()}, p.Action)
142+			}
143+			next := e.router.After(e.st, p, res, file)
144+			if e.Opts.NoFollow {
145+				status := StatusSuccess
146+				if res.Status == lex.Failure {
147+					status = StatusFailure
148+				}
149+				return e.finish(End{status, lex.ReasonStop, "not followed (--no-follow): would " + next.String()}, p.Action)
150+			}
151+			d = next
152+		default:
153+			return e.finish(End{StatusFailure, lex.ReasonError, "router returned no decision"}, e.lastAction())
154+		}
155+	}
156+}
157+
158+func (e *Engine) lastAction() string {
159+	if x := e.st.LastExec(); x != nil {
160+		return x.Action
161+	}
162+	return ""
163+}
164+
165+func (e *Engine) transition(t lex.Transition) {
166+	e.st.Seq++
167+	t.Pipeline, t.Run, t.Workflow, t.Seq, t.CreatedAt = e.st.Meta.Pipeline, e.st.Meta.Run, e.st.Meta.Workflow, e.st.Seq, now()
168+	e.emit(lex.RunEvent{Kind: lex.KindTransition, Transition: &t})
169+}
170+
171+func (e *Engine) emit(ev lex.RunEvent) {
172+	if err := e.Log.Emit(ev); err != nil {
173+		fmt.Fprintf(os.Stderr, "ci: writing %s: %v\n", e.Dir.Events(), err)
174+	}
175+}
176+
177+// finish ends the run.
178+func (e *Engine) finish(end End, from string) (*State, error) {
179+	e.transition(lex.Transition{From: from, Reason: end.Reason, Detail: end.Detail})
180+	e.st.End = &end
181+	e.st.Status = end.Status
182+	e.st.Gate = nil
183+	e.emit(lex.RunEvent{Kind: lex.KindRun, Status: end.Status, Content: end.Detail})
184+	return e.st, e.st.Save(e.Dir)
185+}
186+
187+// execute runs one action and returns its (validated) result and the
188+// result file's path relative to the run dir. An error means the run can't
189+// go on (cancellation, an engine fault); a failing action is a result.
190+func (e *Engine) execute(ctx context.Context, p Pending) (lex.Result, string, error) {
191+	e.st.Execs++
192+	n := e.st.Execs
193+	if i, ok := e.M.IsStep(p.Action); ok {
194+		e.st.Cursor = i + 1
195+	}
196+	a := e.M.Actions[p.Action]
197+	root := e.st.Meta.Root
198+	dir := filepath.Clean(filepath.Join(root, a.Dir))
199+	base := fmt.Sprintf("%03d-%s", n, p.Action)
200+	resultRel := filepath.Join("results", base+".json")
201+	resultFile := filepath.Join(e.Dir.Path(), resultRel)
202+	inputsFile := filepath.Join(e.Dir.Results(), base+".inputs.json")
203+	inputs := p.Inputs
204+	if len(inputs) == 0 {
205+		inputs = json.RawMessage(`{}`)
206+	}
207+	if err := os.WriteFile(inputsFile, append(inputs, '\n'), 0o644); err != nil {
208+		return lex.Result{}, "", err
209+	}
210+	triggerFile := ""
211+	if p.TriggerResult != "" {
212+		triggerFile = filepath.Join(e.Dir.Path(), p.TriggerResult)
213+	}
214+	_ = os.Remove(resultFile)
215+
216+	x := Exec{N: n, Action: p.Action, Then: p.Then, Reason: p.Reason, Result: resultRel, Started: now()}
217+	e.st.History = append(e.st.History, x)
218+	if err := e.st.Save(e.Dir); err != nil {
219+		return lex.Result{}, "", err
220+	}
221+	e.emit(lex.RunEvent{Kind: lex.KindStart, Action: p.Action, Attempt: n, Command: a.Command})
222+
223+	code, runErr := 0, error(nil)
224+	if stub, ok := e.Opts.Stubs[p.Action]; ok {
225+		x.Stubbed = true
226+		code, runErr = e.stub(stub, resultFile, p.Action, n)
227+	} else {
228+		stdout := e.Log.Lines(p.Action, n, "stdout")
229+		stderr := e.Log.Lines(p.Action, n, "stderr")
230+		code, runErr = e.Exec.Exec(ctx, Spec{
231+			Root: root, Dir: dir, Command: a.Command,
232+			Env:    e.actionEnv(p, n, resultFile, inputsFile, triggerFile),
233+			Stdout: stdout, Stderr: stderr,
234+		})
235+		stdout.Close()
236+		stderr.Close()
237+	}
238+	if runErr != nil && ctx.Err() != nil {
239+		if err := e.recordExec(x, StatusCancelled, -1); err != nil {
240+			return lex.Result{}, "", errors.Join(runErr, err)
241+		}
242+		return lex.Result{}, "", runErr
243+	}
244+
245+	var res lex.Result
246+	if runErr != nil {
247+		res = lex.Result{Version: lex.ResultVersion, Status: lex.Failure, Summary: "could not run: " + runErr.Error()}
248+		code = -1
249+	} else {
250+		var err error
251+		res, err = ReadResult(e.M, resultFile, code, e.Dir.Artifacts())
252+		if err != nil {
253+			res = ContractFailure(err)
254+		}
255+	}
256+	if err := mergeOutputs(e.Dir.Outputs(), res.Outputs); err != nil {
257+		res = ContractFailure(fmt.Errorf("outputs: %w", err))
258+	}
259+	exit := code
260+	e.emit(lex.RunEvent{Kind: lex.KindEnd, Action: p.Action, Attempt: n, ExitCode: &exit, Status: res.Status})
261+	e.emit(lex.RunEvent{Kind: lex.KindResult, Action: p.Action, Attempt: n, Result: &res})
262+	if err := e.recordExec(x, res.Status, code); err != nil {
263+		return lex.Result{}, "", err
264+	}
265+	return res, resultRel, nil
266+}
267+
268+func (e *Engine) recordExec(x Exec, status string, code int) error {
269+	x.Status, x.ExitCode, x.Finished = status, code, now()
270+	e.st.History[len(e.st.History)-1] = x
271+	return e.st.Save(e.Dir)
272+}
273+
274+// stub stands in for an action: the fixture becomes its result, and its
275+// exit code follows the fixture's status.
276+func (e *Engine) stub(fixture, resultFile, action string, n int) (int, error) {
277+	b, err := os.ReadFile(fixture)
278+	if err != nil {
279+		return -1, fmt.Errorf("stub for %s: %w", action, err)
280+	}
281+	if err := os.WriteFile(resultFile, b, 0o644); err != nil {
282+		return -1, err
283+	}
284+	e.emit(lex.RunEvent{Kind: lex.KindLog, Action: action, Attempt: n, Stream: "stderr",
285+		Content: "ci: stubbed with " + fixture})
286+	var probe struct {
287+		Status string `json:"status"`
288+	}
289+	if err := json.Unmarshal(b, &probe); err == nil && probe.Status == lex.Failure {
290+		return 1, nil
291+	}
292+	return 0, nil
293+}
294+
295+// ErrNoRun is returned when a run directory has no state.
296+var ErrNoRun = errors.New("no such run")
A internal/engine/engine_test.go
+346, -0
  1@@ -0,0 +1,346 @@
  2+package engine
  3+
  4+import (
  5+	"context"
  6+	"encoding/json"
  7+	"os"
  8+	"path/filepath"
  9+	"slices"
 10+	"strings"
 11+	"testing"
 12+	"time"
 13+
 14+	"git.kilimanjaro.io/ci/internal/lex"
 15+	"git.kilimanjaro.io/ci/internal/manifest"
 16+	"git.kilimanjaro.io/ci/internal/runlog"
 17+)
 18+
 19+type fixture struct {
 20+	t    *testing.T
 21+	root string
 22+	dir  Dir
 23+	m    *manifest.Manifest
 24+	opts Options
 25+}
 26+
 27+func newFixture(t *testing.T, wf string) *fixture {
 28+	t.Helper()
 29+	m, err := manifest.Parse("w", []byte(wf))
 30+	if err != nil {
 31+		t.Fatal(err)
 32+	}
 33+	tmp := t.TempDir()
 34+	root := filepath.Join(tmp, "repo")
 35+	if err := os.MkdirAll(root, 0o755); err != nil {
 36+		t.Fatal(err)
 37+	}
 38+	return &fixture{t: t, root: root, dir: Dir(filepath.Join(tmp, "run")), m: m}
 39+}
 40+
 41+func (f *fixture) run(from string) (*State, []lex.RunEvent) {
 42+	f.t.Helper()
 43+	e, err := Create(f.m, f.dir, Meta{Run: "r1", Workflow: "w", Repo: "repo", Sha: "abc", Ref: "refs/heads/main", Kind: "manual", Host: "local", Root: f.root}, Bash{}, f.opts)
 44+	if err != nil {
 45+		f.t.Fatal(err)
 46+	}
 47+	var st *State
 48+	if from == "" {
 49+		st, err = e.Run(context.Background())
 50+	} else {
 51+		st, err = e.RunFrom(context.Background(), from)
 52+	}
 53+	e.Log.Close()
 54+	if err != nil {
 55+		f.t.Fatal(err)
 56+	}
 57+	b, err := os.Open(f.dir.Events())
 58+	if err != nil {
 59+		f.t.Fatal(err)
 60+	}
 61+	defer b.Close()
 62+	evs, err := runlog.Read(b)
 63+	if err != nil {
 64+		f.t.Fatal(err)
 65+	}
 66+	return st, evs
 67+}
 68+
 69+func executed(st *State) []string {
 70+	var out []string
 71+	for _, x := range st.History {
 72+		out = append(out, x.Action)
 73+	}
 74+	return out
 75+}
 76+
 77+// wf builds a manifest with block-scalar commands (YAML folds newlines in
 78+// quoted strings). Each action is "name: command"; extra is appended to the
 79+// action's mapping (e.g. "dir: sub").
 80+func wf(steps string, actions ...string) string {
 81+	var b strings.Builder
 82+	b.WriteString("engine: devenv\nsteps: [" + steps + "]\nactions:\n")
 83+	for _, a := range actions {
 84+		name, rest, _ := strings.Cut(a, ": ")
 85+		cmd, extra, _ := strings.Cut(rest, "\x00")
 86+		b.WriteString("  " + name + ":\n")
 87+		if extra != "" {
 88+			b.WriteString("    " + extra + "\n")
 89+		}
 90+		b.WriteString("    command: |\n")
 91+		for _, l := range strings.Split(cmd, "\n") {
 92+			b.WriteString("      " + l + "\n")
 93+		}
 94+	}
 95+	return b.String()
 96+}
 97+
 98+// result writes a result JSON to $CI_RESULT from a shell action.
 99+func result(json string) string {
100+	return "cat > \"$CI_RESULT\" <<'EOF'\n" + json + "\nEOF"
101+}
102+
103+func TestRunHappyPathAndEnv(t *testing.T) {
104+	f := newFixture(t, "environment: { GREETING: hi }\n"+wf("a, b",
105+		`a: echo "$GREETING $CI_ACTION $CI_ATTEMPT $CI_SHA $CI_HOST $TANGLED_REF_NAME $CI"
106+echo oops >&2
107+pwd`,
108+		"b: test -d \"$CI_STATE\" && test -d \"$CI_ARTIFACTS\" && test \"$(cat \"$CI_INPUTS\")\" = \"{}\" && test \"$(basename \"$PWD\")\" = sub\x00dir: sub",
109+	))
110+	if err := os.MkdirAll(filepath.Join(f.root, "sub"), 0o755); err != nil {
111+		t.Fatal(err)
112+	}
113+	st, evs := f.run("")
114+	if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"a", "b"}) {
115+		t.Fatalf("status %s, ran %v, end %+v", st.Status, executed(st), st.End)
116+	}
117+	var out, errs []string
118+	for _, e := range evs {
119+		if e.Kind == lex.KindLog && e.Action == "a" {
120+			if e.Stream == "stdout" {
121+				out = append(out, e.Content)
122+			} else {
123+				errs = append(errs, e.Content)
124+			}
125+		}
126+	}
127+	// stdout and stderr interleave nondeterministically; each is ordered
128+	if !slices.Equal(out, []string{"hi a 1 abc local main true", f.root}) || !slices.Equal(errs, []string{"oops"}) {
129+		t.Errorf("stdout %q stderr %q", out, errs)
130+	}
131+	disk, err := Load(f.dir)
132+	if err != nil || disk.Status != StatusSuccess || disk.Cursor != 2 {
133+		t.Errorf("disk state %+v %v", disk, err)
134+	}
135+}
136+
137+func TestNoResultFileFallsBackToExitCode(t *testing.T) {
138+	f := newFixture(t, `
139+engine: devenv
140+steps: [a, b]
141+actions: { a: { command: "exit 3" }, b: { command: "true" } }
142+`)
143+	st, evs := f.run("")
144+	if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"a"}) || st.History[0].ExitCode != 3 {
145+		t.Fatalf("%+v", st)
146+	}
147+	var end lex.RunEvent
148+	for _, e := range evs {
149+		if e.Kind == lex.KindEnd {
150+			end = e
151+		}
152+	}
153+	if end.ExitCode == nil || *end.ExitCode != 3 || end.Status != lex.Failure {
154+		t.Errorf("end event %+v", end)
155+	}
156+}
157+
158+func TestRoutingThroughResults(t *testing.T) {
159+	f := newFixture(t, wf("test, plan, record",
160+		"test: "+result(`{"version":1,"status":"success","next":{"action":"extra","then":"continue","inputs":{"n":7}},"outputs":{"x":1}}`),
161+		`extra: test "$(jq -r .n "$CI_INPUTS")" = 7 && test -s "$CI_TRIGGER_RESULT"`,
162+		"plan: "+result(`{"version":1,"status":"warning","summary":"careful","outputs":{"y":2}}`),
163+		`record: test "$(jq -c . "$CI_STATE/outputs.json")" = '{"x":1,"y":2}'`,
164+	))
165+	st, _ := f.run("")
166+	if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"test", "extra", "plan", "record"}) {
167+		t.Fatalf("status %s ran %v end %+v", st.Status, executed(st), st.End)
168+	}
169+}
170+
171+func TestDivertAndStop(t *testing.T) {
172+	f := newFixture(t, wf("deploy, health, record",
173+		"deploy: true",
174+		"health: "+result(`{"version":1,"status":"failure","next":"undo"}`),
175+		"undo: true",
176+		"record: true",
177+	))
178+	st, _ := f.run("")
179+	if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"deploy", "health", "undo"}) {
180+		t.Fatalf("status %s ran %v", st.Status, executed(st))
181+	}
182+	if !strings.Contains(st.End.Detail, "before record") {
183+		t.Errorf("detail %q", st.End.Detail)
184+	}
185+}
186+
187+func TestContractErrors(t *testing.T) {
188+	cases := map[string]string{
189+		"unknown field":    result(`{"version":1,"status":"success","summray":"x"}`),
190+		"undeclared next":  result(`{"version":1,"status":"success","next":"nope"}`),
191+		"bad version":      result(`{"version":2,"status":"success"}`),
192+		"exit 1 claims ok": result(`{"version":1,"status":"success"}`) + "\nexit 1",
193+		"missing attach":   result(`{"version":1,"status":"success","attachments":[{"path":"none.txt"}]}`),
194+		"escaping attach":  "touch \"$CI_ARTIFACTS/../x\"\n" + result(`{"version":1,"status":"success","attachments":[{"path":"../x"}]}`),
195+		"outputs not obj":  result(`{"version":1,"status":"success","outputs":[1]}`),
196+		"not json":         "echo nope > \"$CI_RESULT\"",
197+		"bad then":         result(`{"version":1,"status":"success","next":{"action":"b","then":"later"}}`),
198+	}
199+	for name, cmd := range cases {
200+		t.Run(name, func(t *testing.T) {
201+			f := newFixture(t, wf("a, b", "a: "+cmd, "b: true"))
202+			st, evs := f.run("")
203+			if st.Status != StatusFailure || len(st.History) != 1 {
204+				t.Fatalf("status %s ran %v", st.Status, executed(st))
205+			}
206+			var r *lex.Result
207+			for _, e := range evs {
208+				if e.Kind == lex.KindResult {
209+					r = e.Result
210+				}
211+			}
212+			if r == nil || !strings.HasPrefix(r.Summary, "contract error") {
213+				t.Errorf("result %+v", r)
214+			}
215+		})
216+	}
217+}
218+
219+func TestAttachmentOK(t *testing.T) {
220+	f := newFixture(t, wf("a",
221+		"a: mkdir -p \"$CI_ARTIFACTS/d\" && echo x > \"$CI_ARTIFACTS/d/p.ndjson\"\n"+
222+			result(`{"version":1,"status":"success","attachments":[{"label":"probes","path":"d/p.ndjson","type":"ndjson"}]}`),
223+	))
224+	st, _ := f.run("")
225+	if st.Status != StatusSuccess {
226+		t.Fatalf("%+v", st.End)
227+	}
228+}
229+
230+func TestLoopGuard(t *testing.T) {
231+	f := newFixture(t, wf("a", "a: "+result(`{"version":1,"status":"success","next":"a"}`)))
232+	f.opts.MaxTransitions = 5
233+	st, _ := f.run("")
234+	if st.Status != StatusFailure || st.End.Reason != lex.ReasonLimit || len(st.History) != 5 {
235+		t.Fatalf("status %s end %+v execs %d", st.Status, st.End, len(st.History))
236+	}
237+}
238+
239+func TestStubUntilNoFollow(t *testing.T) {
240+	wf := `
241+engine: devenv
242+steps: [test, deploy, health]
243+actions:
244+  test:   { command: "true" }
245+  deploy: { command: "touch $CI_RUN_DIR/deployed" }
246+  health: { command: "exit 1" }
247+  undo:   { command: "true" }
248+`
249+	// --stub replaces the action: health "fails" via a fixture that routes to undo
250+	f := newFixture(t, wf)
251+	stub := filepath.Join(t.TempDir(), "degraded.json")
252+	if err := os.WriteFile(stub, []byte(`{"version":1,"status":"failure","next":"undo"}`), 0o644); err != nil {
253+		t.Fatal(err)
254+	}
255+	f.opts.Stubs = map[string]string{"health": stub}
256+	st, _ := f.run("")
257+	if !slices.Equal(executed(st), []string{"test", "deploy", "health", "undo"}) || !st.History[2].Stubbed || st.History[2].ExitCode != 1 {
258+		t.Fatalf("ran %v %+v", executed(st), st.History)
259+	}
260+
261+	// --until stops before deploy
262+	f = newFixture(t, wf)
263+	f.opts.Until = "deploy"
264+	st, _ = f.run("")
265+	if st.Status != StatusCancelled || !slices.Equal(executed(st), []string{"test"}) {
266+		t.Fatalf("until: %s %v", st.Status, executed(st))
267+	}
268+	if _, err := os.Stat(filepath.Join(f.dir.Path(), "deployed")); err == nil {
269+		t.Error("deploy ran")
270+	}
271+
272+	// --no-follow runs one action and says what would happen
273+	f = newFixture(t, wf)
274+	f.opts.NoFollow = true
275+	st, _ = f.run("test")
276+	if !slices.Equal(executed(st), []string{"test"}) || !strings.Contains(st.End.Detail, "would run deploy") {
277+		t.Fatalf("no-follow: %v %+v", executed(st), st.End)
278+	}
279+}
280+
281+func TestRunFromOffPathAction(t *testing.T) {
282+	f := newFixture(t, `
283+engine: devenv
284+steps: [a, b]
285+actions: { a: { command: "true" }, b: { command: "true" }, c: { command: "true" } }
286+`)
287+	st, _ := f.run("c")
288+	// an off-path action started by hand stops after itself
289+	if !slices.Equal(executed(st), []string{"c"}) || st.Status != StatusFailure {
290+		t.Fatalf("%v %s %+v", executed(st), st.Status, st.End)
291+	}
292+	f = newFixture(t, `
293+engine: devenv
294+steps: [a, b]
295+actions: { a: { command: "true" }, b: { command: "true" } }
296+`)
297+	st, _ = f.run("b")
298+	if !slices.Equal(executed(st), []string{"b"}) || st.Status != StatusSuccess {
299+		t.Fatalf("%v %s", executed(st), st.Status)
300+	}
301+}
302+
303+func TestCancel(t *testing.T) {
304+	f := newFixture(t, `
305+engine: devenv
306+steps: [a, b]
307+actions: { a: { command: "sleep 30" }, b: { command: "true" } }
308+`)
309+	e, err := Create(f.m, f.dir, Meta{Run: "r1", Workflow: "w", Repo: "repo", Kind: "manual", Host: "local", Root: f.root}, Bash{}, f.opts)
310+	if err != nil {
311+		t.Fatal(err)
312+	}
313+	defer e.Log.Close()
314+	ctx, cancel := context.WithCancel(context.Background())
315+	go func() {
316+		for {
317+			if s, _ := Load(f.dir); s != nil && len(s.History) == 1 {
318+				cancel()
319+				return
320+			}
321+			time.Sleep(5 * time.Millisecond)
322+		}
323+	}()
324+	st, err := e.Run(ctx)
325+	if err != nil {
326+		t.Fatal(err)
327+	}
328+	if st.Status != StatusCancelled {
329+		t.Fatalf("status %s", st.Status)
330+	}
331+}
332+
333+func TestGateNotYetImplemented(t *testing.T) {
334+	f := newFixture(t, `
335+engine: devenv
336+steps: [a]
337+actions: { a: { command: "true", gate: true } }
338+`)
339+	st, _ := f.run("")
340+	if st.Status != StatusFailure || len(st.History) != 0 {
341+		t.Fatalf("%s %v", st.Status, executed(st))
342+	}
343+	b, _ := json.Marshal(st.End)
344+	if !strings.Contains(string(b), "not implemented") {
345+		t.Errorf("%s", b)
346+	}
347+}
A internal/engine/env.go
+80, -0
 1@@ -0,0 +1,80 @@
 2+package engine
 3+
 4+import (
 5+	"os"
 6+	"sort"
 7+	"strconv"
 8+	"strings"
 9+)
10+
11+// actionEnv builds an action's environment (DESIGN.md §9): the engine's own
12+// environment without any CI_*/TANGLED_* left over from an enclosing run,
13+// then the manifest's environment, then the contract.
14+func (e *Engine) actionEnv(p Pending, n int, resultFile, inputsFile, triggerFile string) []string {
15+	env := map[string]string{}
16+	for _, kv := range e.BaseEnv {
17+		k, v, ok := strings.Cut(kv, "=")
18+		if !ok || strings.HasPrefix(k, "CI_") || strings.HasPrefix(k, "TANGLED_") {
19+			continue
20+		}
21+		env[k] = v
22+	}
23+	for k, v := range e.M.Environment {
24+		env[k] = v
25+	}
26+	m := e.st.Meta
27+	set := func(k, v string) { env[k] = v }
28+
29+	set("CI", "true")
30+	set("CI_RUN_ID", m.Run)
31+	set("CI_RUN_DIR", e.Dir.Path())
32+	set("CI_WORKFLOW", m.Workflow)
33+	set("CI_ACTION", p.Action)
34+	set("CI_ATTEMPT", strconv.Itoa(n))
35+	set("CI_RESULT", resultFile)
36+	set("CI_STATE", e.Dir.State())
37+	set("CI_ARTIFACTS", e.Dir.Artifacts())
38+	set("CI_INPUTS", inputsFile)
39+	set("CI_TRIGGER_RESULT", triggerFile)
40+	set("CI_SHA", m.Sha)
41+	set("CI_REF", m.Ref)
42+	set("CI_REPO", m.Repo)
43+	set("CI_ROOT", m.Root)
44+	set("CI_HOST", m.Host)
45+	for _, c := range m.Checkouts {
46+		if c.Env != "" {
47+			set(c.Env, c.Path)
48+		}
49+	}
50+
51+	// Upstream spindle compatibility (TANGLED_* as documented upstream).
52+	set("TANGLED_SHA", m.Sha)
53+	set("TANGLED_COMMIT_SHA", m.Sha)
54+	set("TANGLED_PIPELINE_KIND", m.Kind)
55+	set("TANGLED_PIPELINE_ID", m.Pipeline)
56+	set("TANGLED_REPO_NAME", m.Repo)
57+	set("TANGLED_REPO_DID", m.RepoDid)
58+	set("TANGLED_REPO_KNOT", m.Knot)
59+	set("TANGLED_REPO_DEFAULT_BRANCH", m.DefaultBranch)
60+	if m.Ref != "" {
61+		set("TANGLED_REF", m.Ref)
62+		switch {
63+		case strings.HasPrefix(m.Ref, "refs/heads/"):
64+			set("TANGLED_REF_NAME", strings.TrimPrefix(m.Ref, "refs/heads/"))
65+			set("TANGLED_REF_TYPE", "branch")
66+		case strings.HasPrefix(m.Ref, "refs/tags/"):
67+			set("TANGLED_REF_NAME", strings.TrimPrefix(m.Ref, "refs/tags/"))
68+			set("TANGLED_REF_TYPE", "tag")
69+		}
70+	}
71+
72+	out := make([]string, 0, len(env))
73+	for k, v := range env {
74+		out = append(out, k+"="+v)
75+	}
76+	sort.Strings(out)
77+	return out
78+}
79+
80+// osEnviron is os.Environ, replaceable in tests.
81+var osEnviron = os.Environ
A internal/engine/exec.go
+87, -0
 1@@ -0,0 +1,87 @@
 2+package engine
 3+
 4+import (
 5+	"context"
 6+	"errors"
 7+	"io"
 8+	"os/exec"
 9+	"syscall"
10+	"time"
11+)
12+
13+// Spec is one action execution.
14+type Spec struct {
15+	Root    string // main checkout: where the devenv shell is activated
16+	Dir     string // absolute working directory for the command
17+	Command string
18+	Env     []string
19+	Stdout  io.Writer
20+	Stderr  io.Writer
21+}
22+
23+// Executor runs an action's command and returns its exit code. An error
24+// means the command could not be run at all (or was cancelled).
25+type Executor interface {
26+	Exec(ctx context.Context, s Spec) (int, error)
27+}
28+
29+// script runs the command in its working directory under bash's strict mode.
30+// The directory comes in through CI_ACTION_DIR so it needs no quoting.
31+func script(command string) string {
32+	return "cd -- \"$CI_ACTION_DIR\"\n" + command
33+}
34+
35+// Devenv runs actions in the main checkout's devenv shell: `devenv shell
36+// bash -euo pipefail -c <script>` with the main checkout as working
37+// directory, so the workflow's repo pins the toolchain (and the engine) even
38+// when the action's dir is another repo.
39+type Devenv struct {
40+	// Binary is the devenv executable; default "devenv".
41+	Binary string
42+}
43+
44+func (d Devenv) Exec(ctx context.Context, s Spec) (int, error) {
45+	bin := d.Binary
46+	if bin == "" {
47+		bin = "devenv"
48+	}
49+	return run(ctx, s, s.Root, bin, "shell", "bash", "-euo", "pipefail", "-c", script(s.Command))
50+}
51+
52+// Bash runs actions with plain bash, without devenv: for tests, and for
53+// debugging a recipe outside its shell (ci run --shell bash).
54+type Bash struct{}
55+
56+func (Bash) Exec(ctx context.Context, s Spec) (int, error) {
57+	return run(ctx, s, s.Dir, "bash", "-euo", "pipefail", "-c", script(s.Command))
58+}
59+
60+// run starts the command in its own process group, so cancelling kills the
61+// whole tree (devenv, bash and whatever the recipe started).
62+func run(ctx context.Context, s Spec, dir, name string, args ...string) (int, error) {
63+	cmd := exec.CommandContext(ctx, name, args...)
64+	cmd.Dir = dir
65+	cmd.Env = append(s.Env, "CI_ACTION_DIR="+s.Dir)
66+	cmd.Stdout = s.Stdout
67+	cmd.Stderr = s.Stderr
68+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
69+	cmd.Cancel = func() error {
70+		return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
71+	}
72+	cmd.WaitDelay = 10 * time.Second
73+	err := cmd.Run()
74+	if ctx.Err() != nil {
75+		return -1, ctx.Err()
76+	}
77+	var ee *exec.ExitError
78+	if errors.As(err, &ee) {
79+		if ee.ExitCode() >= 0 {
80+			return ee.ExitCode(), nil
81+		}
82+		return -1, err
83+	}
84+	if err != nil {
85+		return -1, err
86+	}
87+	return 0, nil
88+}
A internal/engine/result.go
+146, -0
  1@@ -0,0 +1,146 @@
  2+package engine
  3+
  4+import (
  5+	"bytes"
  6+	"encoding/json"
  7+	"errors"
  8+	"fmt"
  9+	"maps"
 10+	"os"
 11+	"path/filepath"
 12+	"strings"
 13+
 14+	"git.kilimanjaro.io/ci/internal/lex"
 15+	"git.kilimanjaro.io/ci/internal/manifest"
 16+)
 17+
 18+// ReadResult reads the result an action left at path and checks it against
 19+// the contract (DESIGN.md §7). A missing or empty file falls back to the exit
 20+// code. Any contract violation is returned as an error; the caller turns it
 21+// into a failure.
 22+func ReadResult(m *manifest.Manifest, path string, exitCode int, artifacts string) (lex.Result, error) {
 23+	b, err := os.ReadFile(path)
 24+	if errors.Is(err, os.ErrNotExist) || (err == nil && len(bytes.TrimSpace(b)) == 0) {
 25+		if exitCode == 0 {
 26+			return lex.Result{Version: lex.ResultVersion, Status: lex.Success}, nil
 27+		}
 28+		return lex.Result{Version: lex.ResultVersion, Status: lex.Failure,
 29+			Summary: fmt.Sprintf("exited with status %d", exitCode)}, nil
 30+	}
 31+	if err != nil {
 32+		return lex.Result{}, err
 33+	}
 34+	var r lex.Result
 35+	if err := lex.StrictUnmarshal(b, &r); err != nil {
 36+		return lex.Result{}, fmt.Errorf("invalid result: %w", err)
 37+	}
 38+	return r, CheckResult(m, r, exitCode, artifacts)
 39+}
 40+
 41+// CheckResult validates a decoded result.
 42+func CheckResult(m *manifest.Manifest, r lex.Result, exitCode int, artifacts string) error {
 43+	var errs []error
 44+	bad := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
 45+	if r.Version != lex.ResultVersion {
 46+		bad("version %d (this engine speaks %d)", r.Version, lex.ResultVersion)
 47+	}
 48+	switch r.Status {
 49+	case lex.Success, lex.Warning:
 50+		if exitCode != 0 {
 51+			bad("exited with status %d but reported %s", exitCode, r.Status)
 52+		}
 53+	case lex.Failure:
 54+	default:
 55+		bad("status %q (want success, warning or failure)", r.Status)
 56+	}
 57+	if r.Next != nil {
 58+		for i, o := range r.Next.Options {
 59+			if _, ok := m.Actions[o.Action]; !ok {
 60+				bad("next[%d]: %q is not a declared action", i, o.Action)
 61+			}
 62+			if o.Then != "" && o.Then != lex.ThenContinue && o.Then != lex.ThenStop {
 63+				bad("next[%d].then: %q (want continue or stop)", i, o.Then)
 64+			}
 65+			if len(o.Inputs) > 0 && !isObject(o.Inputs) {
 66+				bad("next[%d].inputs: must be an object", i)
 67+			}
 68+		}
 69+	}
 70+	for i, a := range r.Attachments {
 71+		if err := attachmentPath(artifacts, a.Path); err != nil {
 72+			bad("attachments[%d]: %v", i, err)
 73+		}
 74+		switch a.Type {
 75+		case "", "text", "markdown", "ndjson", "json":
 76+		default:
 77+			bad("attachments[%d].type: %q", i, a.Type)
 78+		}
 79+	}
 80+	if len(r.Outputs) > 0 && !isObject(r.Outputs) {
 81+		bad("outputs: must be an object")
 82+	}
 83+	return errors.Join(errs...)
 84+}
 85+
 86+// attachmentPath checks that p names an existing file inside artifacts.
 87+func attachmentPath(artifacts, p string) error {
 88+	if p == "" || filepath.IsAbs(p) {
 89+		return fmt.Errorf("path %q must be relative to $CI_ARTIFACTS", p)
 90+	}
 91+	c := filepath.Clean(p)
 92+	if c == ".." || strings.HasPrefix(c, "../") {
 93+		return fmt.Errorf("path %q leaves $CI_ARTIFACTS", p)
 94+	}
 95+	full := filepath.Join(artifacts, c)
 96+	real, err := filepath.EvalSymlinks(full)
 97+	if err != nil {
 98+		return fmt.Errorf("path %q: %w", p, err)
 99+	}
100+	root, err := filepath.EvalSymlinks(artifacts)
101+	if err != nil {
102+		return err
103+	}
104+	if rel, err := filepath.Rel(root, real); err != nil || rel == ".." || strings.HasPrefix(rel, "../") {
105+		return fmt.Errorf("path %q resolves outside $CI_ARTIFACTS", p)
106+	}
107+	if fi, err := os.Stat(real); err != nil || !fi.Mode().IsRegular() {
108+		return fmt.Errorf("path %q is not a regular file", p)
109+	}
110+	return nil
111+}
112+
113+func isObject(b json.RawMessage) bool {
114+	b = bytes.TrimSpace(b)
115+	return len(b) > 0 && b[0] == '{'
116+}
117+
118+// ContractFailure turns a contract violation into the failure result the
119+// router sees.
120+func ContractFailure(err error) lex.Result {
121+	return lex.Result{Version: lex.ResultVersion, Status: lex.Failure, Summary: "contract error: " + err.Error()}
122+}
123+
124+// mergeOutputs merges a result's outputs into the run's outputs.json.
125+func mergeOutputs(path string, outputs json.RawMessage) error {
126+	if len(outputs) == 0 {
127+		return nil
128+	}
129+	cur := map[string]json.RawMessage{}
130+	if b, err := os.ReadFile(path); err == nil {
131+		if err := json.Unmarshal(b, &cur); err != nil {
132+			return fmt.Errorf("%s: %w", path, err)
133+		}
134+	} else if !errors.Is(err, os.ErrNotExist) {
135+		return err
136+	}
137+	var add map[string]json.RawMessage
138+	if err := json.Unmarshal(outputs, &add); err != nil {
139+		return err
140+	}
141+	maps.Copy(cur, add)
142+	b, err := json.MarshalIndent(cur, "", "  ")
143+	if err != nil {
144+		return err
145+	}
146+	return os.WriteFile(path, append(b, '\n'), 0o644)
147+}
A internal/engine/router.go
+263, -0
  1@@ -0,0 +1,263 @@
  2+package engine
  3+
  4+import (
  5+	"encoding/json"
  6+	"fmt"
  7+	"strings"
  8+	"time"
  9+
 10+	"git.kilimanjaro.io/ci/internal/lex"
 11+	"git.kilimanjaro.io/ci/internal/manifest"
 12+)
 13+
 14+// Pending is an action about to run, and what happens after it succeeds.
 15+type Pending struct {
 16+	Action string          `json:"action"`
 17+	Then   string          `json:"then"`
 18+	Reason string          `json:"reason"`
 19+	Inputs json.RawMessage `json:"inputs,omitempty"`
 20+	// TriggerResult is the result file (relative to the run dir) of the
 21+	// action whose result routed here, if any.
 22+	TriggerResult string `json:"triggerResult,omitempty"`
 23+	// From is the action that just finished, for the transition record.
 24+	From string `json:"from,omitempty"`
 25+}
 26+
 27+// PendingGate is a decision waiting for a human.
 28+type PendingGate struct {
 29+	Options []lex.Option `json:"options"`
 30+	Prompt  string       `json:"prompt,omitempty"`
 31+	Summary string       `json:"summary,omitempty"`
 32+	// From is the action whose result raised the gate. Empty for a declared
 33+	// gate reached by default routing: dismissing that cancels the run.
 34+	From          string `json:"from,omitempty"`
 35+	FromStatus    string `json:"fromStatus,omitempty"`
 36+	TriggerResult string `json:"triggerResult,omitempty"`
 37+	CreatedAt     string `json:"createdAt"`
 38+	ExpiresAt     string `json:"expiresAt"`
 39+}
 40+
 41+// End is how a run finished.
 42+type End struct {
 43+	Status string `json:"status"`
 44+	Reason string `json:"reason"`
 45+	Detail string `json:"detail,omitempty"`
 46+}
 47+
 48+// Decision is what the router says happens next: exactly one field is set.
 49+type Decision struct {
 50+	Run  *Pending
 51+	Gate *PendingGate
 52+	End  *End
 53+}
 54+
 55+func (d Decision) String() string {
 56+	switch {
 57+	case d.Run != nil:
 58+		return fmt.Sprintf("run %s (then %s, %s)", d.Run.Action, d.Run.Then, d.Run.Reason)
 59+	case d.Gate != nil:
 60+		var opts []string
 61+		for _, o := range d.Gate.Options {
 62+			opts = append(opts, o.Action)
 63+		}
 64+		return fmt.Sprintf("gate: %s", strings.Join(opts, " | "))
 65+	case d.End != nil:
 66+		s := fmt.Sprintf("end %s (%s)", d.End.Status, d.End.Reason)
 67+		if d.End.Detail != "" {
 68+			s += ": " + d.End.Detail
 69+		}
 70+		return s
 71+	}
 72+	return "nothing"
 73+}
 74+
 75+// Router implements the routing rules of DESIGN.md §8 for one manifest.
 76+type Router struct {
 77+	M   *manifest.Manifest
 78+	Now func() time.Time
 79+}
 80+
 81+func (r *Router) now() time.Time {
 82+	if r.Now != nil {
 83+		return r.Now()
 84+	}
 85+	return time.Now()
 86+}
 87+
 88+// defaultThen is what happens after action succeeds when nobody said:
 89+// happy-path steps continue, anything else stops.
 90+func (r *Router) defaultThen(action string) string {
 91+	if _, ok := r.M.IsStep(action); ok {
 92+		return lex.ThenContinue
 93+	}
 94+	return lex.ThenStop
 95+}
 96+
 97+// Start is the first decision of a run: the first step.
 98+func (r *Router) Start() Decision {
 99+	return r.reach(Pending{Action: r.M.Steps[0], Then: lex.ThenContinue, Reason: lex.ReasonStart})
100+}
101+
102+// Begin starts a run at an arbitrary action (ci action), as if a person had
103+// chosen it: a declared gate still applies.
104+func (r *Router) Begin(action string) Decision {
105+	return r.reach(Pending{Action: action, Then: r.defaultThen(action), Reason: lex.ReasonManual})
106+}
107+
108+// reach routes to p without a result asking for it, applying the action's
109+// declared gate.
110+func (r *Router) reach(p Pending) Decision {
111+	a := r.M.Actions[p.Action]
112+	if a.Gate == nil {
113+		return Decision{Run: &p}
114+	}
115+	prompt := a.Gate.Prompt
116+	if prompt == "" {
117+		prompt = fmt.Sprintf("Run %s?", r.M.Label(p.Action))
118+	}
119+	return Decision{Gate: r.gate(PendingGate{
120+		Options: []lex.Option{{Action: p.Action, Then: p.Then}},
121+		Prompt:  prompt,
122+	})}
123+}
124+
125+// gate stamps g with its creation and expiry times. The timeout is the
126+// longest declared gate timeout among the options, or the default.
127+func (r *Router) gate(g PendingGate) *PendingGate {
128+	var timeout time.Duration
129+	for _, o := range g.Options {
130+		if r.M.Actions[o.Action].Gate != nil {
131+			timeout = max(timeout, r.M.GateTimeout(o.Action))
132+		}
133+	}
134+	if timeout == 0 {
135+		timeout = manifest.DefaultGateTimeout
136+	}
137+	t := r.now().UTC()
138+	g.CreatedAt = t.Format(time.RFC3339Nano)
139+	g.ExpiresAt = t.Add(timeout).Format(time.RFC3339Nano)
140+	return &g
141+}
142+
143+// After decides what follows finished, given its result. st.Cursor must
144+// already reflect finished having run.
145+func (r *Router) After(st *State, finished Pending, res lex.Result, resultFile string) Decision {
146+	if res.Next != nil {
147+		if res.Next.Stop {
148+			if res.Status == lex.Failure {
149+				return Decision{End: &End{StatusFailure, lex.ReasonStop, finished.Action + " failed and stopped the run"}}
150+			}
151+			return Decision{End: &End{StatusSuccess, lex.ReasonStop, finished.Action + " ended the run"}}
152+		}
153+		opts := make([]lex.Option, len(res.Next.Options))
154+		for i, o := range res.Next.Options {
155+			if o.Then == "" {
156+				o.Then = r.defaultThen(o.Action)
157+			}
158+			opts[i] = o
159+		}
160+		if r.needsGate(opts, res.Gated) {
161+			prompt := ""
162+			if len(opts) == 1 {
163+				prompt = opts[0].Prompt
164+				if prompt == "" {
165+					if g := r.M.Actions[opts[0].Action].Gate; g != nil && g.Prompt != "" {
166+						prompt = g.Prompt
167+					} else {
168+						prompt = fmt.Sprintf("Run %s?", r.M.Label(opts[0].Action))
169+					}
170+				}
171+			}
172+			return Decision{Gate: r.gate(PendingGate{
173+				Options:       opts,
174+				Prompt:        prompt,
175+				Summary:       res.Summary,
176+				From:          finished.Action,
177+				FromStatus:    res.Status,
178+				TriggerResult: resultFile,
179+			})}
180+		}
181+		o := opts[0]
182+		return Decision{Run: &Pending{
183+			Action: o.Action, Then: o.Then, Reason: lex.ReasonNext, Inputs: o.Inputs,
184+			TriggerResult: resultFile, From: finished.Action,
185+		}}
186+	}
187+
188+	if res.Status == lex.Failure {
189+		return Decision{End: &End{StatusFailure, lex.ReasonFailure, finished.Action + " failed"}}
190+	}
191+	return r.continueOrStop(st, finished)
192+}
193+
194+// continueOrStop applies finished's `then` after it succeeded.
195+func (r *Router) continueOrStop(st *State, finished Pending) Decision {
196+	if finished.Then == lex.ThenStop {
197+		if st.Cursor >= len(r.M.Steps) {
198+			return Decision{End: &End{StatusSuccess, lex.ReasonStop, "stopped after " + finished.Action + " at the end of the happy path"}}
199+		}
200+		return Decision{End: &End{StatusFailure, lex.ReasonStop,
201+			fmt.Sprintf("stopped after %s, before %s", finished.Action, r.M.Steps[st.Cursor])}}
202+	}
203+	if st.Cursor >= len(r.M.Steps) {
204+		return Decision{End: &End{StatusSuccess, lex.ReasonDefault, "completed the happy path"}}
205+	}
206+	return r.reach(Pending{
207+		Action: r.M.Steps[st.Cursor], Then: lex.ThenContinue, Reason: lex.ReasonDefault, From: finished.Action,
208+	})
209+}
210+
211+// needsGate: a declared gate on any option, a result asking for one, or a
212+// choice between options. Gates are monotonic: a result can add one, never
213+// remove one.
214+func (r *Router) needsGate(opts []lex.Option, gated bool) bool {
215+	if gated || len(opts) > 1 {
216+		return true
217+	}
218+	for _, o := range opts {
219+		if r.M.Actions[o.Action].Gate != nil {
220+			return true
221+		}
222+	}
223+	return false
224+}
225+
226+// Choose answers a gate with option i. The chosen action runs without
227+// further gating: the person has just approved it.
228+func (r *Router) Choose(g *PendingGate, i int) (Decision, error) {
229+	if i < 0 || i >= len(g.Options) {
230+		return Decision{}, fmt.Errorf("no option %d", i)
231+	}
232+	o := g.Options[i]
233+	return Decision{Run: &Pending{
234+		Action: o.Action, Then: o.Then, Reason: lex.ReasonChoice, Inputs: o.Inputs,
235+		TriggerResult: g.TriggerResult, From: g.From,
236+	}}, nil
237+}
238+
239+// Dismiss ends the run at a gate: a declared gate reached by default
240+// routing cancels the run; a gate raised by a result ends the run with that
241+// result's status.
242+func (r *Router) Dismiss(g *PendingGate) Decision {
243+	if g.From == "" {
244+		return Decision{End: &End{StatusCancelled, lex.ReasonDismiss, "dismissed " + optionNames(g)}}
245+	}
246+	status := StatusSuccess
247+	if g.FromStatus == lex.Failure {
248+		status = StatusFailure
249+	}
250+	return Decision{End: &End{status, lex.ReasonDismiss, "dismissed " + optionNames(g) + " after " + g.From}}
251+}
252+
253+// Expire ends the run when a gate times out.
254+func (r *Router) Expire(g *PendingGate) Decision {
255+	return Decision{End: &End{StatusCancelled, lex.ReasonExpired, "gate expired: " + optionNames(g)}}
256+}
257+
258+func optionNames(g *PendingGate) string {
259+	var n []string
260+	for _, o := range g.Options {
261+		n = append(n, o.Action)
262+	}
263+	return strings.Join(n, ", ")
264+}
A internal/engine/router_test.go
+237, -0
  1@@ -0,0 +1,237 @@
  2+package engine
  3+
  4+import (
  5+	"encoding/json"
  6+	"strings"
  7+	"testing"
  8+
  9+	"git.kilimanjaro.io/ci/internal/lex"
 10+	"git.kilimanjaro.io/ci/internal/manifest"
 11+)
 12+
 13+const routerWF = `
 14+engine: devenv
 15+steps: [test, plan, deploy, health-check, record]
 16+actions:
 17+  test:         { command: x }
 18+  plan:         { command: x }
 19+  deploy:       { command: x, gate: { prompt: "Deploy?" } }
 20+  health-check: { command: x }
 21+  record:       { command: x }
 22+  artifacts:    { command: x }
 23+  rollback:     { command: x, gate: { prompt: "Roll back?", timeout: 1h } }
 24+  notify:       { command: x }
 25+`
 26+
 27+func newRouter(t *testing.T) *Router {
 28+	t.Helper()
 29+	m, err := manifest.Parse("w", []byte(routerWF))
 30+	if err != nil {
 31+		t.Fatal(err)
 32+	}
 33+	return &Router{M: m}
 34+}
 35+
 36+// ran simulates the engine executing p: it moves the cursor.
 37+func ran(r *Router, st *State, p Pending) {
 38+	if i, ok := r.M.IsStep(p.Action); ok {
 39+		st.Cursor = i + 1
 40+	}
 41+}
 42+
 43+func res(status string, next string) lex.Result {
 44+	r := lex.Result{Version: 1, Status: status}
 45+	if next != "" {
 46+		var n lex.Next
 47+		if err := json.Unmarshal([]byte(next), &n); err != nil {
 48+			panic(err)
 49+		}
 50+		r.Next = &n
 51+	}
 52+	return r
 53+}
 54+
 55+func mustRun(t *testing.T, d Decision, action, then string) Pending {
 56+	t.Helper()
 57+	if d.Run == nil || d.Run.Action != action || d.Run.Then != then {
 58+		t.Fatalf("want run %s (then %s), got %s", action, then, d)
 59+	}
 60+	return *d.Run
 61+}
 62+
 63+func mustGate(t *testing.T, d Decision, actions ...string) *PendingGate {
 64+	t.Helper()
 65+	if d.Gate == nil {
 66+		t.Fatalf("want gate %v, got %s", actions, d)
 67+	}
 68+	var got []string
 69+	for _, o := range d.Gate.Options {
 70+		got = append(got, o.Action)
 71+	}
 72+	if strings.Join(got, ",") != strings.Join(actions, ",") {
 73+		t.Fatalf("gate options %v, want %v", got, actions)
 74+	}
 75+	return d.Gate
 76+}
 77+
 78+func mustEnd(t *testing.T, d Decision, status string) *End {
 79+	t.Helper()
 80+	if d.End == nil || d.End.Status != status {
 81+		t.Fatalf("want end %s, got %s", status, d)
 82+	}
 83+	return d.End
 84+}
 85+
 86+func TestHappyPathWithDeclaredGate(t *testing.T) {
 87+	r := newRouter(t)
 88+	st := &State{}
 89+	p := mustRun(t, r.Start(), "test", "continue")
 90+	ran(r, st, p)
 91+	p = mustRun(t, r.After(st, p, res("success", ""), "r1"), "plan", "continue")
 92+	ran(r, st, p)
 93+	g := mustGate(t, r.After(st, p, res("warning", ""), "r2"), "deploy")
 94+	if g.From != "" || g.Prompt != "Deploy?" {
 95+		t.Errorf("declared gate: %+v", g)
 96+	}
 97+	d, err := r.Choose(g, 0)
 98+	if err != nil {
 99+		t.Fatal(err)
100+	}
101+	p = mustRun(t, d, "deploy", "continue")
102+	if p.Reason != lex.ReasonChoice {
103+		t.Errorf("reason %s", p.Reason)
104+	}
105+	ran(r, st, p)
106+	p = mustRun(t, r.After(st, p, res("success", ""), "r3"), "health-check", "continue")
107+	ran(r, st, p)
108+	p = mustRun(t, r.After(st, p, res("success", ""), "r4"), "record", "continue")
109+	ran(r, st, p)
110+	e := mustEnd(t, r.After(st, p, res("success", ""), "r5"), StatusSuccess)
111+	if e.Reason != lex.ReasonDefault {
112+		t.Errorf("reason %s", e.Reason)
113+	}
114+}
115+
116+func TestFailureStops(t *testing.T) {
117+	r := newRouter(t)
118+	st := &State{}
119+	p := mustRun(t, r.Start(), "test", "continue")
120+	ran(r, st, p)
121+	mustEnd(t, r.After(st, p, res("failure", ""), "r1"), StatusFailure)
122+}
123+
124+func TestInsertedStepResumesHappyPath(t *testing.T) {
125+	r := newRouter(t)
126+	st := &State{}
127+	p := mustRun(t, r.Start(), "test", "continue")
128+	ran(r, st, p)
129+	p = mustRun(t, r.After(st, p, res("success", `{"action":"artifacts","then":"continue","inputs":{"k":1}}`), "results/001-test.json"), "artifacts", "continue")
130+	if p.TriggerResult != "results/001-test.json" || string(p.Inputs) != `{"k":1}` || p.From != "test" {
131+		t.Errorf("pending %+v", p)
132+	}
133+	ran(r, st, p)
134+	mustRun(t, r.After(st, p, res("success", ""), "r2"), "plan", "continue")
135+}
136+
137+func TestOffPathDefaultsToStop(t *testing.T) {
138+	r := newRouter(t)
139+	st := &State{}
140+	p := mustRun(t, r.Start(), "test", "continue")
141+	ran(r, st, p)
142+	p = mustRun(t, r.After(st, p, res("success", `"notify"`), "r1"), "notify", "stop")
143+	ran(r, st, p)
144+	e := mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusFailure)
145+	if !strings.Contains(e.Detail, "before plan") {
146+		t.Errorf("detail %q", e.Detail)
147+	}
148+}
149+
150+func TestJumpForwardSkipsAndBackRepeats(t *testing.T) {
151+	r := newRouter(t)
152+	st := &State{}
153+	p := mustRun(t, r.Start(), "test", "continue")
154+	ran(r, st, p)
155+	// jump forward to record: a happy-path step, so then defaults to continue
156+	p = mustRun(t, r.After(st, p, res("success", `"record"`), "r1"), "record", "continue")
157+	ran(r, st, p)
158+	mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusSuccess)
159+
160+	// jump back from health-check to plan repeats plan and what follows
161+	st = &State{Cursor: 4}
162+	p = Pending{Action: "health-check", Then: "continue"}
163+	p = mustRun(t, r.After(st, p, res("success", `"plan"`), "r3"), "plan", "continue")
164+	ran(r, st, p)
165+	mustGate(t, r.After(st, p, res("success", ""), "r4"), "deploy")
166+}
167+
168+func TestResultGates(t *testing.T) {
169+	r := newRouter(t)
170+	st := &State{Cursor: 4}
171+	hc := Pending{Action: "health-check", Then: "continue"}
172+
173+	// a declared gate on the routed-to action gates even without gated:true
174+	g := mustGate(t, r.After(st, hc, res("failure", `{"action":"rollback","then":"stop"}`), "r"), "rollback")
175+	if g.From != "health-check" || g.FromStatus != "failure" || g.Prompt != "Roll back?" {
176+		t.Errorf("gate %+v", g)
177+	}
178+
179+	// gated:true on an ungated action
180+	rr := res("warning", `"notify"`)
181+	rr.Gated = true
182+	mustGate(t, r.After(st, hc, rr, "r"), "notify")
183+
184+	// more than one option is always a choice
185+	g = mustGate(t, r.After(st, hc, res("failure", `[{"action":"rollback"},{"action":"health-check","label":"Check again"}]`), "r"), "rollback", "health-check")
186+	if g.Options[0].Then != "stop" || g.Options[1].Then != "continue" {
187+		t.Errorf("default thens: %+v", g.Options)
188+	}
189+
190+	// choosing runs the option without gating again
191+	d, _ := r.Choose(g, 1)
192+	mustRun(t, d, "health-check", "continue")
193+	if _, err := r.Choose(g, 5); err == nil {
194+		t.Error("choose out of range")
195+	}
196+}
197+
198+func TestGateTimeout(t *testing.T) {
199+	r := newRouter(t)
200+	g := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
201+	if g.ExpiresAt == "" || g.CreatedAt == "" || g.ExpiresAt <= g.CreatedAt {
202+		t.Errorf("times %+v", g)
203+	}
204+}
205+
206+func TestDismiss(t *testing.T) {
207+	r := newRouter(t)
208+	declared := mustGate(t, r.reach(Pending{Action: "deploy", Then: "continue"}), "deploy")
209+	mustEnd(t, r.Dismiss(declared), StatusCancelled)
210+
211+	raised := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
212+	mustEnd(t, r.Dismiss(raised), StatusFailure)
213+
214+	warn := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("warning", `[{"action":"rollback"},{"action":"notify"}]`), "r"), "rollback", "notify")
215+	mustEnd(t, r.Dismiss(warn), StatusSuccess)
216+
217+	mustEnd(t, r.Expire(declared), StatusCancelled)
218+}
219+
220+func TestExplicitStop(t *testing.T) {
221+	r := newRouter(t)
222+	mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("success", `"stop"`), "r"), StatusSuccess)
223+	mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("warning", `"stop"`), "r"), StatusSuccess)
224+	mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"stop"`), "r"), StatusFailure)
225+}
226+
227+func TestFailureWithNextRoutes(t *testing.T) {
228+	// a failing result that names a next action is routed, not stopped
229+	r := newRouter(t)
230+	mustRun(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"notify"`), "r"), "notify", "stop")
231+}
232+
233+func TestBeginAppliesDeclaredGate(t *testing.T) {
234+	r := newRouter(t)
235+	mustGate(t, r.Begin("rollback"), "rollback")
236+	mustRun(t, r.Begin("notify"), "notify", "stop")
237+	mustRun(t, r.Begin("plan"), "plan", "continue")
238+}
A internal/engine/state.go
+152, -0
  1@@ -0,0 +1,152 @@
  2+// Package engine runs a workflow: it executes actions, reads their results,
  3+// routes between actions (DESIGN.md §8), and records everything as
  4+// events.ndjson and state.json in the run directory. The same engine runs on
  5+// the dev machine and, inside a sandboxed unit, on the runner.
  6+package engine
  7+
  8+import (
  9+	"encoding/json"
 10+	"fmt"
 11+	"os"
 12+	"path/filepath"
 13+	"time"
 14+)
 15+
 16+// Run statuses (state.json and kind=run events).
 17+const (
 18+	StatusRunning   = "running"
 19+	StatusPaused    = "paused"
 20+	StatusSuccess   = "success"
 21+	StatusFailure   = "failure"
 22+	StatusCancelled = "cancelled"
 23+)
 24+
 25+// Terminal reports whether status ends a run.
 26+func Terminal(status string) bool {
 27+	return status == StatusSuccess || status == StatusFailure || status == StatusCancelled
 28+}
 29+
 30+// StateVersion versions state.json.
 31+const StateVersion = 1
 32+
 33+// Dir is a run directory (DESIGN.md §9).
 34+type Dir string
 35+
 36+func (d Dir) Path() string         { return string(d) }
 37+func (d Dir) StateFile() string    { return filepath.Join(string(d), "state.json") }
 38+func (d Dir) Events() string       { return filepath.Join(string(d), "events.ndjson") }
 39+func (d Dir) ManifestFile() string { return filepath.Join(string(d), "manifest.yml") }
 40+func (d Dir) State() string        { return filepath.Join(string(d), "state") }
 41+func (d Dir) Artifacts() string    { return filepath.Join(string(d), "artifacts") }
 42+func (d Dir) Results() string      { return filepath.Join(string(d), "results") }
 43+func (d Dir) Outputs() string      { return filepath.Join(d.State(), "outputs.json") }
 44+
 45+// Create makes the run directory and its subdirectories.
 46+func (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.
 56+type 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+	// Checkouts are the extra repos (manifest repos:), by name.
 71+	Checkouts []Checkout `json:"checkouts,omitempty"`
 72+}
 73+
 74+// Checkout is an extra repository checked out for a run.
 75+type Checkout struct {
 76+	Repo string `json:"repo"`
 77+	Path string `json:"path"` // absolute
 78+	Rev  string `json:"rev"`
 79+	Env  string `json:"env,omitempty"`
 80+}
 81+
 82+// Exec records one execution of an action.
 83+type Exec struct {
 84+	N        int    `json:"n"`
 85+	Action   string `json:"action"`
 86+	Then     string `json:"then"`
 87+	Reason   string `json:"reason"`
 88+	Status   string `json:"status"`
 89+	ExitCode int    `json:"exitCode"`
 90+	Result   string `json:"result"` // path relative to the run dir
 91+	Stubbed  bool   `json:"stubbed,omitempty"`
 92+	Started  string `json:"started"`
 93+	Finished string `json:"finished,omitempty"`
 94+}
 95+
 96+// State is state.json: everything needed to resume or inspect a run.
 97+type State struct {
 98+	Version int    `json:"version"`
 99+	Meta    Meta   `json:"meta"`
100+	Status  string `json:"status"`
101+	// Cursor is the index in steps of the next happy-path step.
102+	Cursor int `json:"cursor"`
103+	// Seq counts transitions; Execs counts executions.
104+	Seq     int    `json:"seq"`
105+	Execs   int    `json:"execs"`
106+	History []Exec `json:"history,omitempty"`
107+	// Gate is set while the run is paused at a gate.
108+	Gate *PendingGate `json:"gate,omitempty"`
109+	// End is set when the run has finished.
110+	End     *End   `json:"end,omitempty"`
111+	Created string `json:"created"`
112+	Updated string `json:"updated"`
113+}
114+
115+// LastExec returns the most recent execution, if any.
116+func (s *State) LastExec() *Exec {
117+	if len(s.History) == 0 {
118+		return nil
119+	}
120+	return &s.History[len(s.History)-1]
121+}
122+
123+// Load reads a run's state.json.
124+func Load(d Dir) (*State, error) {
125+	b, err := os.ReadFile(d.StateFile())
126+	if err != nil {
127+		return nil, err
128+	}
129+	var s State
130+	if err := json.Unmarshal(b, &s); err != nil {
131+		return nil, fmt.Errorf("%s: %w", d.StateFile(), err)
132+	}
133+	if s.Version != StateVersion {
134+		return nil, fmt.Errorf("%s: state version %d, this engine reads %d", d.StateFile(), s.Version, StateVersion)
135+	}
136+	return &s, nil
137+}
138+
139+// Save writes state.json atomically.
140+func (s *State) Save(d Dir) error {
141+	s.Updated = now()
142+	b, err := json.MarshalIndent(s, "", "  ")
143+	if err != nil {
144+		return err
145+	}
146+	tmp := d.StateFile() + ".tmp"
147+	if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
148+		return err
149+	}
150+	return os.Rename(tmp, d.StateFile())
151+}
152+
153+func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
M internal/lex/ci.go
+4, -0
 1@@ -35,6 +35,7 @@ type Trigger struct {
 2 	Event  []string `json:"event"`
 3 	Branch []string `json:"branch,omitempty"`
 4 	Tag    []string `json:"tag,omitempty"`
 5+	Paths  []string `json:"paths,omitempty"`
 6 }
 7 
 8 type Repo struct {
 9@@ -209,6 +210,9 @@ const (
10 	ReasonFailure = "failure"
11 	ReasonError   = "error"
12 	ReasonLimit   = "limit"
13+	ReasonExpired = "expired"
14+	ReasonUntil   = "until"
15+	ReasonCancel  = "cancelled"
16 )
17 
18 // Transition is run.rtw.ci.transition.
A internal/manifest/manifest.go
+299, -0
  1@@ -0,0 +1,299 @@
  2+// Package manifest loads and validates workflow manifests
  3+// (.tangled/workflows/<name>.yml with engine devenv) and matches their
  4+// triggers.
  5+//
  6+// YAML is decoded generically, its shorthands are normalised (a string for a
  7+// list, `gate: true`, `concurrency: <group>`), and the result is decoded into
  8+// lex.Workflow through strict JSON, so the YAML and JSON forms share one
  9+// validation path and unknown fields are errors.
 10+package manifest
 11+
 12+import (
 13+	"encoding/json"
 14+	"errors"
 15+	"fmt"
 16+	"os"
 17+	"path"
 18+	"path/filepath"
 19+	"regexp"
 20+	"slices"
 21+	"sort"
 22+	"strings"
 23+	"time"
 24+
 25+	"gopkg.in/yaml.v3"
 26+
 27+	"git.kilimanjaro.io/ci/internal/lex"
 28+)
 29+
 30+// Dir is where workflow files live, relative to the repository root.
 31+const Dir = ".tangled/workflows"
 32+
 33+// DefaultGateTimeout applies when a gate declares none.
 34+const DefaultGateTimeout = 24 * time.Hour
 35+
 36+var (
 37+	actionName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
 38+	envName    = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
 39+)
 40+
 41+// ErrOtherEngine is returned by Parse for a workflow that doesn't use the
 42+// devenv engine: it belongs to another runner and is skipped, not invalid.
 43+var ErrOtherEngine = errors.New("not a devenv workflow")
 44+
 45+// Manifest is a parsed, validated workflow.
 46+type Manifest struct {
 47+	Name string // file name without extension
 48+	Raw  []byte // the file as written
 49+	lex.Workflow
 50+}
 51+
 52+// Load reads and parses the workflow file at p.
 53+func Load(p string) (*Manifest, error) {
 54+	b, err := os.ReadFile(p)
 55+	if err != nil {
 56+		return nil, err
 57+	}
 58+	m, err := Parse(NameOf(p), b)
 59+	if err != nil {
 60+		return nil, fmt.Errorf("%s: %w", p, err)
 61+	}
 62+	return m, nil
 63+}
 64+
 65+// NameOf returns a workflow's name: its file name without .yml/.yaml.
 66+func NameOf(p string) string {
 67+	base := path.Base(filepath.ToSlash(p))
 68+	return strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml")
 69+}
 70+
 71+// Find returns the path of the named workflow under root.
 72+func Find(root, name string) (string, error) {
 73+	for _, ext := range []string{".yml", ".yaml"} {
 74+		p := filepath.Join(root, Dir, name+ext)
 75+		if _, err := os.Stat(p); err == nil {
 76+			return p, nil
 77+		}
 78+	}
 79+	return "", fmt.Errorf("no workflow %q in %s", name, filepath.Join(root, Dir))
 80+}
 81+
 82+// List returns the workflow files under root, sorted.
 83+func List(root string) ([]string, error) {
 84+	var out []string
 85+	for _, pat := range []string{"*.yml", "*.yaml"} {
 86+		m, err := filepath.Glob(filepath.Join(root, Dir, pat))
 87+		if err != nil {
 88+			return nil, err
 89+		}
 90+		out = append(out, m...)
 91+	}
 92+	sort.Strings(out)
 93+	return out, nil
 94+}
 95+
 96+// Parse decodes and validates a manifest.
 97+func Parse(name string, b []byte) (*Manifest, error) {
 98+	var doc any
 99+	if err := yaml.Unmarshal(b, &doc); err != nil {
100+		return nil, err
101+	}
102+	top, ok := doc.(map[string]any)
103+	if !ok {
104+		return nil, fmt.Errorf("a workflow must be a mapping")
105+	}
106+	if e, _ := top["engine"].(string); e != lex.EngineDevenv {
107+		return nil, fmt.Errorf("%w (engine %q)", ErrOtherEngine, e)
108+	}
109+	if err := normalise(top); err != nil {
110+		return nil, err
111+	}
112+	j, err := json.Marshal(top)
113+	if err != nil {
114+		return nil, err
115+	}
116+	m := &Manifest{Name: name, Raw: b}
117+	if err := lex.StrictUnmarshal(j, &m.Workflow); err != nil {
118+		return nil, err
119+	}
120+	if err := m.validate(); err != nil {
121+		return nil, err
122+	}
123+	return m, nil
124+}
125+
126+// normalise rewrites YAML shorthands into the lexicon's JSON shape, in place.
127+func normalise(top map[string]any) error {
128+	if when, ok := top["when"].([]any); ok {
129+		for i, c := range when {
130+			cm, ok := c.(map[string]any)
131+			if !ok {
132+				return fmt.Errorf("when[%d]: must be a mapping", i)
133+			}
134+			for _, k := range []string{"event", "branch", "tag", "paths"} {
135+				if s, ok := cm[k].(string); ok {
136+					cm[k] = []any{s}
137+				}
138+			}
139+		}
140+	}
141+	if acts, ok := top["actions"].(map[string]any); ok {
142+		for name, a := range acts {
143+			am, ok := a.(map[string]any)
144+			if !ok {
145+				return fmt.Errorf("actions.%s: must be a mapping", name)
146+			}
147+			switch g := am["gate"].(type) {
148+			case bool:
149+				if g {
150+					am["gate"] = map[string]any{}
151+				} else {
152+					delete(am, "gate")
153+				}
154+			case nil:
155+				if _, present := am["gate"]; present {
156+					am["gate"] = map[string]any{}
157+				}
158+			}
159+			if s, ok := am["concurrency"].(string); ok {
160+				am["concurrency"] = map[string]any{"group": s}
161+			}
162+		}
163+	}
164+	if env, ok := top["environment"].(map[string]any); ok {
165+		for k, v := range env {
166+			switch v.(type) {
167+			case string:
168+			case int, int64, float64, bool:
169+				env[k] = fmt.Sprint(v)
170+			default:
171+				return fmt.Errorf("environment.%s: must be a string", k)
172+			}
173+		}
174+	}
175+	return nil
176+}
177+
178+func (m *Manifest) validate() error {
179+	var errs []error
180+	bad := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
181+
182+	if len(m.Steps) == 0 {
183+		bad("steps: at least one step is required")
184+	}
185+	seen := map[string]bool{}
186+	for i, s := range m.Steps {
187+		if _, ok := m.Actions[s]; !ok {
188+			bad("steps[%d]: %q is not a declared action", i, s)
189+		}
190+		if seen[s] {
191+			bad("steps[%d]: %q appears twice (a result can route back to a step instead)", i, s)
192+		}
193+		seen[s] = true
194+	}
195+	names := make([]string, 0, len(m.Actions))
196+	for n := range m.Actions {
197+		names = append(names, n)
198+	}
199+	sort.Strings(names)
200+	for _, n := range names {
201+		a := m.Actions[n]
202+		if !actionName.MatchString(n) || n == lex.ThenStop {
203+			bad("actions.%s: invalid name (letters, digits, . _ -; not %q)", n, lex.ThenStop)
204+		}
205+		if strings.TrimSpace(a.Command) == "" {
206+			bad("actions.%s: command is required", n)
207+		}
208+		if a.Dir != "" {
209+			if err := relPath(a.Dir); err != nil {
210+				bad("actions.%s.dir: %v", n, err)
211+			}
212+		}
213+		if a.Style != "" && a.Style != "normal" && a.Style != "danger" {
214+			bad("actions.%s.style: %q (want normal or danger)", n, a.Style)
215+		}
216+		if a.Gate != nil && a.Gate.Timeout != "" {
217+			if d, err := time.ParseDuration(a.Gate.Timeout); err != nil || d <= 0 {
218+				bad("actions.%s.gate.timeout: %q is not a positive duration", n, a.Gate.Timeout)
219+			}
220+		}
221+		if a.Concurrency != nil && a.Concurrency.Group == "" {
222+			bad("actions.%s.concurrency: group is required", n)
223+		}
224+	}
225+	paths := map[string]bool{}
226+	for i, r := range m.Repos {
227+		if r.Repo == "" {
228+			bad("repos[%d]: repo is required", i)
229+		}
230+		if err := relPath(r.Path); err != nil {
231+			bad("repos[%d].path: %v", i, err)
232+		} else if c := filepath.Clean(r.Path); c == "." {
233+			bad("repos[%d].path: must not be the main checkout", i)
234+		} else if paths[c] {
235+			bad("repos[%d].path: %q used twice", i, r.Path)
236+		} else {
237+			paths[c] = true
238+		}
239+		if r.Env != "" && !envName.MatchString(r.Env) {
240+			bad("repos[%d].env: %q is not a valid variable name", i, r.Env)
241+		}
242+	}
243+	for k := range m.Environment {
244+		if !envName.MatchString(k) {
245+			bad("environment: %q is not a valid variable name", k)
246+		}
247+	}
248+	for i, c := range m.When {
249+		if len(c.Event) == 0 {
250+			bad("when[%d]: event is required", i)
251+		}
252+		for _, e := range c.Event {
253+			if !slices.Contains([]string{"push", "manual", "pull_request", "schedule"}, e) {
254+				bad("when[%d]: unknown event %q", i, e)
255+			}
256+		}
257+		for _, p := range slices.Concat(c.Branch, c.Tag, c.Paths) {
258+			if !validGlob(p) {
259+				bad("when[%d]: invalid glob %q", i, p)
260+			}
261+		}
262+	}
263+	return errors.Join(errs...)
264+}
265+
266+// relPath checks that p is a relative path. It may climb out of the main
267+// checkout (../servers): extra repos sit next to it.
268+func relPath(p string) error {
269+	if p == "" {
270+		return fmt.Errorf("must not be empty")
271+	}
272+	if filepath.IsAbs(p) {
273+		return fmt.Errorf("%q must be relative to the main checkout", p)
274+	}
275+	return nil
276+}
277+
278+// IsStep reports whether action is on the happy path, and its index.
279+func (m *Manifest) IsStep(action string) (int, bool) {
280+	i := slices.Index(m.Steps, action)
281+	return i, i >= 0
282+}
283+
284+// Label returns an action's display label.
285+func (m *Manifest) Label(action string) string {
286+	if a, ok := m.Actions[action]; ok && a.Label != "" {
287+		return a.Label
288+	}
289+	return action
290+}
291+
292+// GateTimeout returns the timeout of an action's declared gate.
293+func (m *Manifest) GateTimeout(action string) time.Duration {
294+	a := m.Actions[action]
295+	if a.Gate == nil || a.Gate.Timeout == "" {
296+		return DefaultGateTimeout
297+	}
298+	d, _ := time.ParseDuration(a.Gate.Timeout)
299+	return d
300+}
A internal/manifest/manifest_test.go
+201, -0
  1@@ -0,0 +1,201 @@
  2+package manifest
  3+
  4+import (
  5+	"errors"
  6+	"strings"
  7+	"testing"
  8+)
  9+
 10+const sovrn = `
 11+when:
 12+  - event: [push]
 13+    branch: [main]
 14+engine: devenv
 15+repos:
 16+  - { repo: servers, path: ../servers }
 17+  - { repo: data,    path: ../data, env: SECRETS_DIR }
 18+steps: [test, plan, deploy, health-check, record, notify]
 19+actions:
 20+  test:         { command: just test }
 21+  plan:         { dir: ../servers, command: just plan-project sovrn }
 22+  deploy:       { dir: ../servers, command: just deploy-project sovrn,
 23+                  gate: { prompt: "Deploy sovrn?" }, concurrency: fleet,
 24+                  audited: true }
 25+  health-check: { dir: ../servers, command: just prod-health-check sovrn }
 26+  record:       { dir: ../servers, command: just record-deploy sovrn }
 27+  notify:       { command: just notify-deploy }
 28+  rollback:     { dir: ../servers, command: just rollback, style: danger,
 29+                  manual: true, audited: true,
 30+                  gate: { prompt: "Roll back sovrn?" } }
 31+`
 32+
 33+func TestParseDesignExample(t *testing.T) {
 34+	m, err := Parse("deploy", []byte(sovrn))
 35+	if err != nil {
 36+		t.Fatal(err)
 37+	}
 38+	if got := m.Actions["deploy"].Concurrency; got == nil || got.Group != "fleet" {
 39+		t.Errorf("concurrency shorthand: %+v", got)
 40+	}
 41+	if m.Actions["rollback"].Gate.Prompt != "Roll back sovrn?" || !m.Actions["rollback"].Manual {
 42+		t.Errorf("rollback: %+v", m.Actions["rollback"])
 43+	}
 44+	if i, ok := m.IsStep("health-check"); !ok || i != 3 {
 45+		t.Errorf("IsStep(health-check) = %d, %v", i, ok)
 46+	}
 47+	if _, ok := m.IsStep("rollback"); ok {
 48+		t.Error("rollback is not a step")
 49+	}
 50+	if m.Repos[1].Env != "SECRETS_DIR" {
 51+		t.Errorf("repos: %+v", m.Repos)
 52+	}
 53+}
 54+
 55+func TestShorthands(t *testing.T) {
 56+	m, err := Parse("w", []byte(`
 57+when: [{ event: push, branch: main, tag: "v*" }]
 58+engine: devenv
 59+environment: { A: 1, B: true, C: x }
 60+steps: [a]
 61+actions:
 62+  a: { command: "true", gate: true }
 63+  b: { command: "true", gate: }
 64+  c: { command: "true", gate: false }
 65+`))
 66+	if err != nil {
 67+		t.Fatal(err)
 68+	}
 69+	w := m.When[0]
 70+	if len(w.Event) != 1 || w.Event[0] != "push" || w.Branch[0] != "main" || w.Tag[0] != "v*" {
 71+		t.Errorf("when: %+v", w)
 72+	}
 73+	if m.Environment["A"] != "1" || m.Environment["B"] != "true" {
 74+		t.Errorf("environment: %+v", m.Environment)
 75+	}
 76+	if m.Actions["a"].Gate == nil || m.Actions["b"].Gate == nil || m.Actions["c"].Gate != nil {
 77+		t.Errorf("gates: a=%v b=%v c=%v", m.Actions["a"].Gate, m.Actions["b"].Gate, m.Actions["c"].Gate)
 78+	}
 79+	if m.GateTimeout("a") != DefaultGateTimeout {
 80+		t.Errorf("default timeout")
 81+	}
 82+}
 83+
 84+func TestOtherEngine(t *testing.T) {
 85+	_, err := Parse("w", []byte("engine: nixery\nsteps: [{name: x, command: y}]\n"))
 86+	if !errors.Is(err, ErrOtherEngine) {
 87+		t.Fatalf("got %v", err)
 88+	}
 89+}
 90+
 91+func TestInvalid(t *testing.T) {
 92+	cases := map[string]string{
 93+		"undeclared step": `engine: devenv
 94+steps: [a, b]
 95+actions: { a: { command: x } }`,
 96+		"duplicate step": `engine: devenv
 97+steps: [a, a]
 98+actions: { a: { command: x } }`,
 99+		"no steps": `engine: devenv
100+steps: []
101+actions: { a: { command: x } }`,
102+		"unknown field": `engine: devenv
103+steps: [a]
104+actions: { a: { command: x, comand: y } }`,
105+		"empty command": `engine: devenv
106+steps: [a]
107+actions: { a: { command: " " } }`,
108+		"reserved name": `engine: devenv
109+steps: [a]
110+actions: { a: { command: x }, stop: { command: x } }`,
111+		"bad style": `engine: devenv
112+steps: [a]
113+actions: { a: { command: x, style: loud } }`,
114+		"bad timeout": `engine: devenv
115+steps: [a]
116+actions: { a: { command: x, gate: { timeout: soon } } }`,
117+		"absolute dir": `engine: devenv
118+steps: [a]
119+actions: { a: { command: x, dir: /etc } }`,
120+		"repo at root": `engine: devenv
121+repos: [{ repo: r, path: . }]
122+steps: [a]
123+actions: { a: { command: x } }`,
124+		"bad env name": `engine: devenv
125+repos: [{ repo: r, path: ../r, env: 1X }]
126+steps: [a]
127+actions: { a: { command: x } }`,
128+		"unknown event": `engine: devenv
129+when: [{ event: [pr] }]
130+steps: [a]
131+actions: { a: { command: x } }`,
132+		"bad glob": `engine: devenv
133+when: [{ event: [push], branch: ["["] }]
134+steps: [a]
135+actions: { a: { command: x } }`,
136+	}
137+	for name, src := range cases {
138+		if _, err := Parse("w", []byte(src)); err == nil {
139+			t.Errorf("%s: accepted", name)
140+		} else if errors.Is(err, ErrOtherEngine) {
141+			t.Errorf("%s: %v", name, err)
142+		}
143+	}
144+}
145+
146+func TestErrorsAreJoined(t *testing.T) {
147+	_, err := Parse("w", []byte(`engine: devenv
148+steps: [a, b]
149+actions: { a: { command: "", style: loud } }`))
150+	if err == nil {
151+		t.Fatal("accepted")
152+	}
153+	for _, want := range []string{`"b" is not a declared action`, "command is required", "style"} {
154+		if !strings.Contains(err.Error(), want) {
155+			t.Errorf("error %q lacks %q", err, want)
156+		}
157+	}
158+}
159+
160+func TestMatches(t *testing.T) {
161+	m, err := Parse("w", []byte(`engine: devenv
162+when:
163+  - { event: [push], branch: ["main", "release/**"], tag: ["v*"] }
164+  - { event: [push], branch: ["docs"], paths: ["docs/**"] }
165+steps: [a]
166+actions: { a: { command: x } }`))
167+	if err != nil {
168+		t.Fatal(err)
169+	}
170+	cases := []struct {
171+		ev   Event
172+		want bool
173+	}{
174+		{Event{Kind: "push", Ref: "refs/heads/main"}, true},
175+		{Event{Kind: "push", Ref: "refs/heads/release/1/x"}, true},
176+		{Event{Kind: "push", Ref: "refs/heads/feature"}, false},
177+		{Event{Kind: "push", Ref: "refs/tags/v1.2"}, true},
178+		{Event{Kind: "push", Ref: "refs/tags/main"}, false},
179+		{Event{Kind: "push", Ref: "refs/heads/docs", ChangedFiles: []string{"docs/a/b.md"}}, true},
180+		{Event{Kind: "push", Ref: "refs/heads/docs", ChangedFiles: []string{"src/x.go"}}, false},
181+		{Event{Kind: "push", Ref: "refs/heads/docs"}, true}, // changed files unknown
182+		{Event{Kind: "manual"}, true},
183+		{Event{Kind: "pull_request"}, false},
184+	}
185+	for _, c := range cases {
186+		if got := m.Matches(c.ev); got != c.want {
187+			t.Errorf("%+v: got %v", c.ev, got)
188+		}
189+	}
190+	none, _ := Parse("w", []byte("engine: devenv\nsteps: [a]\nactions: { a: { command: x } }"))
191+	if !none.Matches(Event{Kind: "push", Ref: "refs/heads/x"}) {
192+		t.Error("a workflow without conditions always runs")
193+	}
194+}
195+
196+func TestNameOf(t *testing.T) {
197+	for in, want := range map[string]string{"a/b/deploy.yml": "deploy", "x.yaml": "x", "y": "y"} {
198+		if got := NameOf(in); got != want {
199+			t.Errorf("NameOf(%q) = %q", in, got)
200+		}
201+	}
202+}
A internal/manifest/match.go
+71, -0
 1@@ -0,0 +1,71 @@
 2+package manifest
 3+
 4+import (
 5+	"slices"
 6+	"strings"
 7+
 8+	"github.com/bmatcuk/doublestar/v4"
 9+)
10+
11+// Event is what a trigger is matched against.
12+type Event struct {
13+	Kind         string   // push | manual
14+	Ref          string   // full ref, e.g. refs/heads/main (push)
15+	ChangedFiles []string // push; nil means unknown, which never fails a paths filter
16+}
17+
18+// Matches reports whether the workflow should run for ev, with upstream's
19+// semantics: manual dispatch always matches (the caller picked the
20+// workflow); a workflow with no conditions always runs; otherwise any one
21+// condition must match its event, ref and paths.
22+func (m *Manifest) Matches(ev Event) bool {
23+	if ev.Kind == "manual" {
24+		return true
25+	}
26+	if len(m.When) == 0 {
27+		return true
28+	}
29+	for _, c := range m.When {
30+		if !slices.Contains(c.Event, ev.Kind) {
31+			continue
32+		}
33+		if ev.Kind == "push" && !matchRef(ev.Ref, c.Branch, c.Tag) {
34+			continue
35+		}
36+		if len(c.Paths) > 0 && ev.ChangedFiles != nil && !anyFileMatches(ev.ChangedFiles, c.Paths) {
37+			continue
38+		}
39+		return true
40+	}
41+	return false
42+}
43+
44+func matchRef(ref string, branches, tags []string) bool {
45+	if b, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
46+		return anyMatch(b, branches)
47+	}
48+	if t, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
49+		return anyMatch(t, tags)
50+	}
51+	return false
52+}
53+
54+func anyMatch(name string, patterns []string) bool {
55+	for _, p := range patterns {
56+		if ok, _ := doublestar.Match(p, name); ok {
57+			return true
58+		}
59+	}
60+	return false
61+}
62+
63+func anyFileMatches(files, patterns []string) bool {
64+	for _, f := range files {
65+		if anyMatch(f, patterns) {
66+			return true
67+		}
68+	}
69+	return false
70+}
71+
72+func validGlob(p string) bool { return p != "" && doublestar.ValidatePattern(p) }
A internal/render/render.go
+145, -0
  1@@ -0,0 +1,145 @@
  2+// Package render prints run events for a terminal: `ci run` live and
  3+// `ci view` afterwards render the same events.ndjson the same way.
  4+package render
  5+
  6+import (
  7+	"fmt"
  8+	"io"
  9+	"os"
 10+	"strings"
 11+	"sync"
 12+	"time"
 13+
 14+	"git.kilimanjaro.io/ci/internal/lex"
 15+)
 16+
 17+// Terminal renders events to w.
 18+type Terminal struct {
 19+	mu     sync.Mutex
 20+	w      io.Writer
 21+	color  bool
 22+	starts map[int]time.Time
 23+}
 24+
 25+// New returns a renderer for w, with colour if w is a terminal and NO_COLOR
 26+// is unset.
 27+func New(w io.Writer) *Terminal {
 28+	color := false
 29+	if f, ok := w.(*os.File); ok && os.Getenv("NO_COLOR") == "" {
 30+		if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
 31+			color = true
 32+		}
 33+	}
 34+	return &Terminal{w: w, color: color, starts: map[int]time.Time{}}
 35+}
 36+
 37+func (t *Terminal) paint(code, s string) string {
 38+	if !t.color {
 39+		return s
 40+	}
 41+	return "\x1b[" + code + "m" + s + "\x1b[0m"
 42+}
 43+
 44+func (t *Terminal) statusColor(status string) string {
 45+	switch status {
 46+	case lex.Success:
 47+		return "32"
 48+	case lex.Warning, "paused":
 49+		return "33"
 50+	case lex.Failure, "cancelled":
 51+		return "31"
 52+	}
 53+	return "0"
 54+}
 55+
 56+// Event renders one event.
 57+func (t *Terminal) Event(ev lex.RunEvent) {
 58+	t.mu.Lock()
 59+	defer t.mu.Unlock()
 60+	ts, _ := time.Parse(time.RFC3339Nano, ev.Time)
 61+	switch ev.Kind {
 62+	case lex.KindRun:
 63+		if ev.Status == "running" {
 64+			fmt.Fprintf(t.w, "%s %s\n", t.paint("1", "ci"), ev.Content)
 65+			return
 66+		}
 67+		line := fmt.Sprintf("%s %s", t.paint("1", "ci"), t.paint(t.statusColor(ev.Status), ev.Status))
 68+		if ev.Content != "" {
 69+			line += ": " + ev.Content
 70+		}
 71+		fmt.Fprintln(t.w, line)
 72+	case lex.KindCheckout:
 73+		fmt.Fprintf(t.w, "  checkout %s @ %s -> %s\n", ev.Repo, short(ev.Rev), ev.Path)
 74+	case lex.KindStart:
 75+		t.starts[ev.Attempt] = ts
 76+		fmt.Fprintf(t.w, "%s %s  %s\n", t.paint("1;36", "▶"), t.paint("1", ev.Action), t.paint("2", firstLine(ev.Command)))
 77+	case lex.KindLog:
 78+		prefix := t.paint("2", "│ ")
 79+		if ev.Stream == "stderr" {
 80+			prefix = t.paint("33", "│ ")
 81+		}
 82+		fmt.Fprintln(t.w, prefix+ev.Content)
 83+	case lex.KindEnd:
 84+		mark := "✔"
 85+		switch ev.Status {
 86+		case lex.Failure:
 87+			mark = "✘"
 88+		case lex.Warning:
 89+			mark = "!"
 90+		}
 91+		took := ""
 92+		if s, ok := t.starts[ev.Attempt]; ok && !ts.IsZero() {
 93+			took = "  " + ts.Sub(s).Round(100*time.Millisecond).String()
 94+		}
 95+		code := ""
 96+		if ev.ExitCode != nil && *ev.ExitCode != 0 {
 97+			code = fmt.Sprintf("  exit %d", *ev.ExitCode)
 98+		}
 99+		fmt.Fprintf(t.w, "%s %s  %s%s%s\n", t.paint(t.statusColor(ev.Status), mark), ev.Action, t.paint(t.statusColor(ev.Status), ev.Status), code, t.paint("2", took))
100+	case lex.KindResult:
101+		if r := ev.Result; r != nil {
102+			if r.Summary != "" {
103+				for _, l := range strings.Split(strings.TrimRight(r.Summary, "\n"), "\n") {
104+					fmt.Fprintln(t.w, "  "+l)
105+				}
106+			}
107+			for _, a := range r.Attachments {
108+				label := a.Label
109+				if label == "" {
110+					label = a.Path
111+				}
112+				fmt.Fprintf(t.w, "  attachment: %s (%s)\n", label, a.Path)
113+			}
114+		}
115+	case lex.KindTransition:
116+		if tr := ev.Transition; tr != nil && tr.To != "" && tr.Reason != lex.ReasonDefault && tr.Reason != lex.ReasonStart {
117+			fmt.Fprintf(t.w, "%s %s (%s)\n", t.paint("2", "→"), tr.To, tr.Reason)
118+		}
119+	case lex.KindGate:
120+		if g := ev.Gate; g != nil {
121+			fmt.Fprintf(t.w, "%s gate %s", t.paint("1;33", "⏸"), g.State)
122+			if g.Choice != "" {
123+				fmt.Fprintf(t.w, ": %s", g.Choice)
124+			}
125+			if g.Actor != "" {
126+				fmt.Fprintf(t.w, " by %s", g.Actor)
127+			}
128+			fmt.Fprintln(t.w)
129+		}
130+	}
131+}
132+
133+func firstLine(s string) string {
134+	s = strings.TrimSpace(s)
135+	if i := strings.IndexByte(s, '\n'); i >= 0 {
136+		return s[:i] + " …"
137+	}
138+	return s
139+}
140+
141+func short(s string) string {
142+	if len(s) > 12 {
143+		return s[:12]
144+	}
145+	return s
146+}
A internal/runlog/runlog.go
+173, -0
  1@@ -0,0 +1,173 @@
  2+// Package runlog reads and writes a run's events.ndjson: one
  3+// run.rtw.ci.event per line, the single observable format shared by the
  4+// terminal renderer, `ci view`, the runner's streams and the appview.
  5+package runlog
  6+
  7+import (
  8+	"bufio"
  9+	"bytes"
 10+	"encoding/json"
 11+	"fmt"
 12+	"io"
 13+	"os"
 14+	"sync"
 15+	"time"
 16+
 17+	"git.kilimanjaro.io/ci/internal/lex"
 18+)
 19+
 20+// Writer appends events to an events.ndjson file. It is safe for concurrent
 21+// use (an action's stdout and stderr are written from two goroutines).
 22+type Writer struct {
 23+	mu    sync.Mutex
 24+	f     *os.File
 25+	run   string
 26+	seq   int
 27+	sinks []func(lex.RunEvent)
 28+	now   func() time.Time
 29+}
 30+
 31+// Open opens (or creates) path for appending, continuing the sequence of
 32+// any events already in it, so a resumed run extends its own log.
 33+func Open(path, run string) (*Writer, error) {
 34+	n, err := countLines(path)
 35+	if err != nil {
 36+		return nil, err
 37+	}
 38+	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
 39+	if err != nil {
 40+		return nil, err
 41+	}
 42+	return &Writer{f: f, run: run, seq: n, now: time.Now}, nil
 43+}
 44+
 45+func countLines(path string) (int, error) {
 46+	f, err := os.Open(path)
 47+	if os.IsNotExist(err) {
 48+		return 0, nil
 49+	}
 50+	if err != nil {
 51+		return 0, err
 52+	}
 53+	defer f.Close()
 54+	n := 0
 55+	buf := make([]byte, 64*1024)
 56+	for {
 57+		c, err := f.Read(buf)
 58+		n += bytes.Count(buf[:c], []byte{'\n'})
 59+		if err == io.EOF {
 60+			return n, nil
 61+		}
 62+		if err != nil {
 63+			return 0, err
 64+		}
 65+	}
 66+}
 67+
 68+// AddSink registers a function called with every event after it is written.
 69+func (w *Writer) AddSink(f func(lex.RunEvent)) {
 70+	w.mu.Lock()
 71+	defer w.mu.Unlock()
 72+	w.sinks = append(w.sinks, f)
 73+}
 74+
 75+// Emit stamps ev with the next sequence number, the time and the run id, and
 76+// appends it.
 77+func (w *Writer) Emit(ev lex.RunEvent) error {
 78+	w.mu.Lock()
 79+	defer w.mu.Unlock()
 80+	w.seq++
 81+	ev.Seq = w.seq
 82+	ev.Time = w.now().UTC().Format(time.RFC3339Nano)
 83+	ev.Run = w.run
 84+	b, err := json.Marshal(ev)
 85+	if err != nil {
 86+		return err
 87+	}
 88+	if _, err := w.f.Write(append(b, '\n')); err != nil {
 89+		return err
 90+	}
 91+	for _, s := range w.sinks {
 92+		s(ev)
 93+	}
 94+	return nil
 95+}
 96+
 97+// Close closes the file.
 98+func (w *Writer) Close() error { return w.f.Close() }
 99+
100+// Lines returns an io.WriteCloser that emits one log event per line written
101+// to it, for an action's stdout or stderr. Close flushes a final partial line.
102+func (w *Writer) Lines(action string, attempt int, stream string) io.WriteCloser {
103+	return &lineWriter{w: w, action: action, attempt: attempt, stream: stream}
104+}
105+
106+type lineWriter struct {
107+	w       *Writer
108+	action  string
109+	attempt int
110+	stream  string
111+	buf     []byte
112+}
113+
114+func (l *lineWriter) Write(p []byte) (int, error) {
115+	l.buf = append(l.buf, p...)
116+	for {
117+		i := bytes.IndexByte(l.buf, '\n')
118+		if i < 0 {
119+			break
120+		}
121+		if err := l.emit(l.buf[:i]); err != nil {
122+			return 0, err
123+		}
124+		l.buf = l.buf[i+1:]
125+	}
126+	return len(p), nil
127+}
128+
129+func (l *lineWriter) emit(line []byte) error {
130+	return l.w.Emit(lex.RunEvent{
131+		Kind:    lex.KindLog,
132+		Action:  l.action,
133+		Attempt: l.attempt,
134+		Stream:  l.stream,
135+		Content: string(bytes.TrimSuffix(line, []byte{'\r'})),
136+	})
137+}
138+
139+func (l *lineWriter) Close() error {
140+	if len(l.buf) == 0 {
141+		return nil
142+	}
143+	err := l.emit(l.buf)
144+	l.buf = nil
145+	return err
146+}
147+
148+// Read decodes every event in r.
149+func Read(r io.Reader) ([]lex.RunEvent, error) {
150+	var out []lex.RunEvent
151+	err := Scan(r, func(ev lex.RunEvent) error { out = append(out, ev); return nil })
152+	return out, err
153+}
154+
155+// Scan calls f for each event in r, in order.
156+func Scan(r io.Reader, f func(lex.RunEvent) error) error {
157+	sc := bufio.NewScanner(r)
158+	sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
159+	line := 0
160+	for sc.Scan() {
161+		line++
162+		if len(bytes.TrimSpace(sc.Bytes())) == 0 {
163+			continue
164+		}
165+		var ev lex.RunEvent
166+		if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
167+			return fmt.Errorf("line %d: %w", line, err)
168+		}
169+		if err := f(ev); err != nil {
170+			return err
171+		}
172+	}
173+	return sc.Err()
174+}
A internal/tid/tid.go
+41, -0
 1@@ -0,0 +1,41 @@
 2+// Package tid generates ATProto TIDs: 13-character, base32-sortable record
 3+// keys from a microsecond timestamp and a 10-bit clock id. Used for record
 4+// keys and local run ids.
 5+package tid
 6+
 7+import (
 8+	"math/rand/v2"
 9+	"sync"
10+	"time"
11+)
12+
13+const alphabet = "234567abcdefghijklmnopqrstuvwxyz"
14+
15+var (
16+	mu      sync.Mutex
17+	last    int64
18+	clockID = rand.Int64N(1024)
19+)
20+
21+// New returns a TID greater than any previously returned by this process.
22+func New() string {
23+	mu.Lock()
24+	us := time.Now().UnixMicro()
25+	if us <= last {
26+		us = last + 1
27+	}
28+	last = us
29+	mu.Unlock()
30+	return Encode(us, clockID)
31+}
32+
33+// Encode formats a TID from microseconds since the epoch and a clock id.
34+func Encode(us, clock int64) string {
35+	v := uint64(us&(1<<53-1))<<10 | uint64(clock&1023)
36+	var b [13]byte
37+	for i := 12; i >= 0; i-- {
38+		b[i] = alphabet[v&31]
39+		v >>= 5
40+	}
41+	return string(b[:])
42+}
A internal/tid/tid_test.go
+24, -0
 1@@ -0,0 +1,24 @@
 2+package tid
 3+
 4+import "testing"
 5+
 6+func TestSortableAndValid(t *testing.T) {
 7+	prev := ""
 8+	for range 1000 {
 9+		s := New()
10+		if len(s) != 13 || s[0] > 'j' { // top bit is zero: first char is 2..j
11+			t.Fatalf("bad tid %q", s)
12+		}
13+		if s <= prev {
14+			t.Fatalf("%q not after %q", s, prev)
15+		}
16+		prev = s
17+	}
18+}
19+
20+func TestKnownValue(t *testing.T) {
21+	// Checked against indigo atproto/syntax.NewTID(1682452732371000, 0).
22+	if got := Encode(1682452732371000, 0); got != "3ju7uarhs3s22" {
23+		t.Errorf("got %s", got)
24+	}
25+}
A internal/vcs/vcs.go
+68, -0
 1@@ -0,0 +1,68 @@
 2+// Package vcs finds the repository a local run happens in and what commit
 3+// its working copy is, for jj (preferred when present) and git.
 4+package vcs
 5+
 6+import (
 7+	"fmt"
 8+	"os"
 9+	"os/exec"
10+	"path/filepath"
11+	"strings"
12+)
13+
14+// Root returns the nearest directory at or above start that contains .jj or
15+// .git.
16+func Root(start string) (string, error) {
17+	dir, err := filepath.Abs(start)
18+	if err != nil {
19+		return "", err
20+	}
21+	for {
22+		for _, marker := range []string{".jj", ".git"} {
23+			if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
24+				return dir, nil
25+			}
26+		}
27+		parent := filepath.Dir(dir)
28+		if parent == dir {
29+			return "", fmt.Errorf("%s is not inside a jj or git repository", start)
30+		}
31+		dir = parent
32+	}
33+}
34+
35+// Head returns the working copy's commit and whether it has changes. With
36+// jj that is @ (jj snapshots the working copy first, as every jj command
37+// does); with git it is HEAD plus `git status`.
38+func Head(root string) (sha string, dirty bool, err error) {
39+	if _, err := os.Stat(filepath.Join(root, ".jj")); err == nil {
40+		out, err := output(root, "jj", "log", "--no-graph", "-r", "@", "-T", `commit_id ++ " " ++ empty`)
41+		if err != nil {
42+			return "", false, err
43+		}
44+		id, empty, _ := strings.Cut(strings.TrimSpace(out), " ")
45+		return id, empty != "true", nil
46+	}
47+	sha, err = output(root, "git", "rev-parse", "HEAD")
48+	if err != nil {
49+		return "", false, err
50+	}
51+	st, err := output(root, "git", "status", "--porcelain")
52+	if err != nil {
53+		return "", false, err
54+	}
55+	return strings.TrimSpace(sha), strings.TrimSpace(st) != "", nil
56+}
57+
58+func output(dir, name string, args ...string) (string, error) {
59+	cmd := exec.Command(name, args...)
60+	cmd.Dir = dir
61+	out, err := cmd.Output()
62+	if err != nil {
63+		if ee, ok := err.(*exec.ExitError); ok {
64+			return "", fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(ee.Stderr)))
65+		}
66+		return "", fmt.Errorf("%s: %w", name, err)
67+	}
68+	return string(out), nil
69+}
M lexicons/ci/run.rtw.ci.event.json
+82, -18
  1@@ -5,25 +5,89 @@
  2   "defs": {
  3     "main": {
  4       "type": "object",
  5-      "required": ["seq", "time", "kind", "run"],
  6+      "required": [
  7+        "seq",
  8+        "time",
  9+        "kind",
 10+        "run"
 11+      ],
 12       "properties": {
 13-        "seq": { "type": "integer", "description": "Line number in the file, from 1." },
 14-        "time": { "type": "string", "format": "datetime" },
 15-        "run": { "type": "string" },
 16-        "kind": { "type": "string", "knownValues": ["run", "checkout", "start", "log", "end", "result", "transition", "gate"] },
 17-        "action": { "type": "string" },
 18-        "attempt": { "type": "integer", "description": "Execution number of the action within the run (1, 2, ... for repeats); also the log step id." },
 19-        "stream": { "type": "string", "knownValues": ["stdout", "stderr"] },
 20-        "content": { "type": "string", "description": "A log line (kind log), a message (run, checkout)." },
 21-        "command": { "type": "string" },
 22-        "exitCode": { "type": "integer" },
 23-        "status": { "type": "string", "description": "kind run: pending|running|paused|success|failure|cancelled; kind end: the action's status." },
 24-        "repo": { "type": "string", "description": "kind checkout: repo name." },
 25-        "rev": { "type": "string", "description": "kind checkout: checked-out commit." },
 26-        "path": { "type": "string", "description": "kind checkout: checkout path." },
 27-        "result": { "type": "ref", "ref": "run.rtw.ci.step.result" },
 28-        "transition": { "type": "ref", "ref": "run.rtw.ci.transition" },
 29-        "gate": { "type": "ref", "ref": "run.rtw.ci.gate" }
 30+        "seq": {
 31+          "type": "integer",
 32+          "description": "Line number in the file, from 1."
 33+        },
 34+        "time": {
 35+          "type": "string",
 36+          "format": "datetime"
 37+        },
 38+        "run": {
 39+          "type": "string"
 40+        },
 41+        "kind": {
 42+          "type": "string",
 43+          "knownValues": [
 44+            "run",
 45+            "checkout",
 46+            "start",
 47+            "log",
 48+            "end",
 49+            "result",
 50+            "transition",
 51+            "gate"
 52+          ]
 53+        },
 54+        "action": {
 55+          "type": "string"
 56+        },
 57+        "attempt": {
 58+          "type": "integer",
 59+          "description": "Execution number within the run (1, 2, ...), counting repeats of the same action separately; also the log step id."
 60+        },
 61+        "stream": {
 62+          "type": "string",
 63+          "knownValues": [
 64+            "stdout",
 65+            "stderr"
 66+          ]
 67+        },
 68+        "content": {
 69+          "type": "string",
 70+          "description": "A log line (kind log), a message (run, checkout)."
 71+        },
 72+        "command": {
 73+          "type": "string"
 74+        },
 75+        "exitCode": {
 76+          "type": "integer"
 77+        },
 78+        "status": {
 79+          "type": "string",
 80+          "description": "kind run: pending|running|paused|success|failure|cancelled; kind end: the action's status."
 81+        },
 82+        "repo": {
 83+          "type": "string",
 84+          "description": "kind checkout: repo name."
 85+        },
 86+        "rev": {
 87+          "type": "string",
 88+          "description": "kind checkout: checked-out commit."
 89+        },
 90+        "path": {
 91+          "type": "string",
 92+          "description": "kind checkout: checkout path."
 93+        },
 94+        "result": {
 95+          "type": "ref",
 96+          "ref": "run.rtw.ci.step.result"
 97+        },
 98+        "transition": {
 99+          "type": "ref",
100+          "ref": "run.rtw.ci.transition"
101+        },
102+        "gate": {
103+          "type": "ref",
104+          "ref": "run.rtw.ci.gate"
105+        }
106       }
107     }
108   }
M lexicons/ci/run.rtw.ci.transition.json
+60, -11
 1@@ -5,18 +5,67 @@
 2   "defs": {
 3     "main": {
 4       "type": "object",
 5-      "required": ["run", "workflow", "seq", "reason", "createdAt"],
 6+      "required": [
 7+        "run",
 8+        "workflow",
 9+        "seq",
10+        "reason",
11+        "createdAt"
12+      ],
13       "properties": {
14-        "pipeline": { "type": "string", "format": "at-uri" },
15-        "run": { "type": "string" },
16-        "workflow": { "type": "string" },
17-        "seq": { "type": "integer" },
18-        "from": { "type": "string", "description": "Action that just finished; absent at the start of a run." },
19-        "to": { "type": "string", "description": "Action that runs next; absent when the run ends." },
20-        "then": { "type": "string", "knownValues": ["continue", "stop"] },
21-        "reason": { "type": "string", "knownValues": ["start", "default", "next", "choice", "manual", "stop", "dismiss", "failure", "error", "limit"] },
22-        "detail": { "type": "string" },
23-        "createdAt": { "type": "string", "format": "datetime" }
24+        "pipeline": {
25+          "type": "string",
26+          "format": "at-uri"
27+        },
28+        "run": {
29+          "type": "string"
30+        },
31+        "workflow": {
32+          "type": "string"
33+        },
34+        "seq": {
35+          "type": "integer"
36+        },
37+        "from": {
38+          "type": "string",
39+          "description": "Action that just finished; absent at the start of a run."
40+        },
41+        "to": {
42+          "type": "string",
43+          "description": "Action that runs next; absent when the run ends."
44+        },
45+        "then": {
46+          "type": "string",
47+          "knownValues": [
48+            "continue",
49+            "stop"
50+          ]
51+        },
52+        "reason": {
53+          "type": "string",
54+          "knownValues": [
55+            "start",
56+            "default",
57+            "next",
58+            "choice",
59+            "manual",
60+            "stop",
61+            "dismiss",
62+            "failure",
63+            "error",
64+            "limit",
65+            "expired",
66+            "until",
67+            "cancelled"
68+          ]
69+        },
70+        "detail": {
71+          "type": "string"
72+        },
73+        "createdAt": {
74+          "type": "string",
75+          "format": "datetime"
76+        }
77       }
78     }
79   }
M lexicons/ci/run.rtw.ci.workflow.json
+122, -28
  1@@ -5,17 +5,27 @@
  2   "defs": {
  3     "main": {
  4       "type": "object",
  5-      "required": ["when", "engine", "steps", "actions"],
  6+      "required": [
  7+        "when",
  8+        "engine",
  9+        "steps",
 10+        "actions"
 11+      ],
 12       "properties": {
 13         "when": {
 14           "type": "array",
 15           "description": "Trigger conditions, as upstream's workflow files. Matched by the runner on a ref update or manual trigger.",
 16-          "items": { "type": "ref", "ref": "#trigger" }
 17+          "items": {
 18+            "type": "ref",
 19+            "ref": "#trigger"
 20+          }
 21         },
 22         "engine": {
 23           "type": "string",
 24           "description": "Must be devenv. Upstream spindles ignore workflows for engines they don't run.",
 25-          "knownValues": ["devenv"]
 26+          "knownValues": [
 27+            "devenv"
 28+          ]
 29         },
 30         "environment": {
 31           "type": "unknown",
 32@@ -24,13 +34,18 @@
 33         "repos": {
 34           "type": "array",
 35           "description": "Extra repositories checked out next to the main one for each run.",
 36-          "items": { "type": "ref", "ref": "#repo" }
 37+          "items": {
 38+            "type": "ref",
 39+            "ref": "#repo"
 40+          }
 41         },
 42         "steps": {
 43           "type": "array",
 44           "description": "The happy path: action names in order.",
 45           "minLength": 1,
 46-          "items": { "type": "string" }
 47+          "items": {
 48+            "type": "string"
 49+          }
 50         },
 51         "actions": {
 52           "type": "unknown",
 53@@ -40,62 +55,141 @@
 54     },
 55     "trigger": {
 56       "type": "object",
 57-      "required": ["event"],
 58+      "required": [
 59+        "event"
 60+      ],
 61       "properties": {
 62         "event": {
 63           "type": "array",
 64           "description": "In YAML a single string is accepted too.",
 65-          "items": { "type": "string", "knownValues": ["push", "manual", "pull_request", "schedule"] }
 66+          "items": {
 67+            "type": "string",
 68+            "knownValues": [
 69+              "push",
 70+              "manual",
 71+              "pull_request",
 72+              "schedule"
 73+            ]
 74+          }
 75         },
 76         "branch": {
 77           "type": "array",
 78           "description": "Branch globs (* and **). In YAML a single string is accepted too.",
 79-          "items": { "type": "string" }
 80+          "items": {
 81+            "type": "string"
 82+          }
 83         },
 84         "tag": {
 85           "type": "array",
 86           "description": "Tag globs (* and **), push events only. In YAML a single string is accepted too.",
 87-          "items": { "type": "string" }
 88+          "items": {
 89+            "type": "string"
 90+          }
 91+        },
 92+        "paths": {
 93+          "type": "array",
 94+          "description": "Push only: run only if a changed file matches one of these globs (* and **), as upstream. In YAML a single string is accepted too.",
 95+          "items": {
 96+            "type": "string"
 97+          }
 98         }
 99       }
100     },
101     "repo": {
102       "type": "object",
103-      "required": ["repo", "path"],
104+      "required": [
105+        "repo",
106+        "path"
107+      ],
108       "properties": {
109-        "repo": { "type": "string", "description": "Repository name on the forge (resolved to a clone URL by configuration)." },
110-        "path": { "type": "string", "description": "Checkout path relative to the main checkout, e.g. ../servers." },
111-        "rev": { "type": "string", "description": "Revision to check out. Default: the forge's default branch at the time the run starts." },
112-        "env": { "type": "string", "description": "Environment variable that receives the checkout's absolute path, e.g. SECRETS_DIR." }
113+        "repo": {
114+          "type": "string",
115+          "description": "Repository name on the forge (resolved to a clone URL by configuration)."
116+        },
117+        "path": {
118+          "type": "string",
119+          "description": "Checkout path relative to the main checkout, e.g. ../servers."
120+        },
121+        "rev": {
122+          "type": "string",
123+          "description": "Revision to check out. Default: the forge's default branch at the time the run starts."
124+        },
125+        "env": {
126+          "type": "string",
127+          "description": "Environment variable that receives the checkout's absolute path, e.g. SECRETS_DIR."
128+        }
129       }
130     },
131     "action": {
132       "type": "object",
133-      "required": ["command"],
134+      "required": [
135+        "command"
136+      ],
137       "properties": {
138-        "command": { "type": "string", "description": "Run as `devenv shell -- bash -euo pipefail -c <command>`." },
139-        "dir": { "type": "string", "description": "Working directory relative to the main checkout. Default: the main checkout." },
140-        "label": { "type": "string", "description": "Button and timeline label. Default: the action name." },
141-        "gate": { "type": "ref", "ref": "#gate", "description": "Always ask a human before running. In YAML `gate: true` is accepted too." },
142-        "concurrency": { "type": "ref", "ref": "#concurrency", "description": "In YAML a plain group name is accepted too." },
143-        "manual": { "type": "boolean", "description": "Offer as a button on the run page at any time." },
144-        "style": { "type": "string", "knownValues": ["normal", "danger"] },
145-        "audited": { "type": "boolean", "description": "Local runs that execute this action report to the appview." }
146+        "command": {
147+          "type": "string",
148+          "description": "Run as `devenv shell -- bash -euo pipefail -c <command>`."
149+        },
150+        "dir": {
151+          "type": "string",
152+          "description": "Working directory relative to the main checkout. Default: the main checkout."
153+        },
154+        "label": {
155+          "type": "string",
156+          "description": "Button and timeline label. Default: the action name."
157+        },
158+        "gate": {
159+          "type": "ref",
160+          "ref": "#gate",
161+          "description": "Always ask a human before running. In YAML `gate: true` is accepted too."
162+        },
163+        "concurrency": {
164+          "type": "ref",
165+          "ref": "#concurrency",
166+          "description": "In YAML a plain group name is accepted too."
167+        },
168+        "manual": {
169+          "type": "boolean",
170+          "description": "Offer as a button on the run page at any time."
171+        },
172+        "style": {
173+          "type": "string",
174+          "knownValues": [
175+            "normal",
176+            "danger"
177+          ]
178+        },
179+        "audited": {
180+          "type": "boolean",
181+          "description": "Local runs that execute this action report to the appview."
182+        }
183       }
184     },
185     "gate": {
186       "type": "object",
187       "properties": {
188-        "prompt": { "type": "string" },
189-        "timeout": { "type": "string", "description": "Go duration, e.g. 24h. Default 24h." }
190+        "prompt": {
191+          "type": "string"
192+        },
193+        "timeout": {
194+          "type": "string",
195+          "description": "Go duration, e.g. 24h. Default 24h."
196+        }
197       }
198     },
199     "concurrency": {
200       "type": "object",
201-      "required": ["group"],
202+      "required": [
203+        "group"
204+      ],
205       "properties": {
206-        "group": { "type": "string" },
207-        "supersede": { "type": "boolean", "description": "A newer run reaching this group's gate supersedes older runs waiting at it." }
208+        "group": {
209+          "type": "string"
210+        },
211+        "supersede": {
212+          "type": "boolean",
213+          "description": "A newer run reaching this group's gate supersedes older runs waiting at it."
214+        }
215       }
216     }
217   }