Appview: runs, gates, actions, agent API, ci trigger/wait
- internal/appview: server-rendered pages over the runner's API (runs with 'Needs you' first, run page with gate choices, step summaries, attachments, manual actions, live log over SSE, cancel, run a workflow); works without JS - auth: owner password (bcrypt), HMAC-signed SameSite=Strict session, session-bound CSRF tokens; agent bearer tokens read, trigger and run non-danger actions, never approve gates or cancel - agent API (/api/runs, log.txt, trigger, actions) and ci trigger / ci wait (CI_APPVIEW_URL, CI_TOKEN); runner run view lists manual and danger actions - every ci command is implemented now (stubs removed) Bug 7f8343d.
18 files changed,  +1607, -35
M DESIGN.md
+18, -3
 1@@ -506,7 +506,21 @@ fleet plan step 2.8 (rebuild on the standard layout).
 2 
 3 ## 11. Appview
 4 
 5-Minimal server-rendered HTML (Go templates + htmx), reachable from a phone.
 6+Minimal server-rendered HTML (Go templates, plain CSS, ~40 lines of vanilla
 7+JS for live logs and confirmations; every page works without JS), reachable
 8+from a phone. It is a stateless view over the runner's API (one runner, so
 9+no index of its own); a second runner would bring back an index fed by the
10+runners' `/events`.
11+
12+Auth: the owner signs in with a password (bcrypt hash file, e.g.
13+`mkpasswd -m bcrypt -R 12`); the session is an HMAC-signed, `SameSite=Strict`
14+cookie, and every form carries a session-bound CSRF token. Agents use bearer
15+tokens from a `<name> <token>` file: they can read runs and plain-text logs,
16+trigger workflows and run manual actions that aren't `style: danger`, never
17+approve gates or cancel. Approvals are recorded with the owner's DID as
18+actor. `ci trigger <repo> [workflow]` and `ci wait <repo> [sha]` use the agent
19+API (`CI_APPVIEW_URL`, `CI_TOKEN`); `ci wait` exits like the run (75 when it
20+is waiting for you).
21 
22 - **Pipelines**: repo, sha, per-workflow status; live logs over SSE (proxied
23   from the runner's `/logs`).
24@@ -518,8 +532,9 @@ Minimal server-rendered HTML (Go templates + htmx), reachable from a phone.
25 - **API**: JSON status and plain-text logs for agents; `ci wait` uses it.
26   Agent tokens can read, trigger and re-run, not approve gates or run `danger`
27   actions.
28-- **ntfy**: gate waiting, run failed, run succeeded; *view* actions link to the
29-  page (no auth tokens inside notifications).
30+- **ntfy** (sent by the runner, which sees every transition): gate waiting,
31+  failure, optionally success; *view* actions link to the appview's run page
32+  (no auth tokens inside notifications).
33 - **Local run reporting**: `ci run --report` (automatic when an `audited`
34   action runs) publishes a local run's events, so laptop deploys appear in the
35   history and their rollback buttons work.
A cmd/ci/appview.go
+196, -0
  1@@ -0,0 +1,196 @@
  2+package main
  3+
  4+import (
  5+	"bytes"
  6+	"encoding/json"
  7+	"flag"
  8+	"fmt"
  9+	"io"
 10+	"net/http"
 11+	"net/url"
 12+	"os"
 13+	"strings"
 14+	"time"
 15+
 16+	"git.kilimanjaro.io/ci/internal/appview"
 17+	"git.kilimanjaro.io/ci/internal/engine"
 18+	"git.kilimanjaro.io/ci/internal/runner"
 19+)
 20+
 21+func init() {
 22+	commands["appview"] = command{"web UI and agent API over the runner (serve)", cmdAppview}
 23+	commands["trigger"] = command{"start a workflow on the runner (via the appview)", cmdTrigger}
 24+	commands["wait"] = command{"wait for a repo's run and print its log (via the appview)", cmdWait}
 25+}
 26+
 27+func cmdAppview(args []string) int {
 28+	if len(args) == 0 || args[0] != "serve" {
 29+		fmt.Fprintln(os.Stderr, "usage: ci appview serve [flags]")
 30+		return exitUsage
 31+	}
 32+	fs := flag.NewFlagSet("ci appview serve", flag.ContinueOnError)
 33+	listen := fs.String("listen", "127.0.0.1:8477", "address to serve on")
 34+	runnerURL := fs.String("runner", "http://127.0.0.1:8476", "the runner's API")
 35+	runnerToken := fs.String("runner-token-file", "", "bearer token for the runner")
 36+	passwordHash := fs.String("password-hash-file", "", "bcrypt hash of the owner's password, e.g. from `mkpasswd -m bcrypt` (required)")
 37+	keyFile := fs.String("session-key-file", "", "secret that signs sessions (default: random, so a restart signs you out)")
 38+	agents := fs.String("agent-tokens-file", "", "lines of \"<name> <token>\" for agents (read, trigger, non-danger actions)")
 39+	knot := fs.String("knot", "", "the forge host (repos are did:web:<knot>:<repo>) (required)")
 40+	owner := fs.String("owner-did", "", "recorded as the actor of your approvals (default \"owner\")")
 41+	secure := fs.Bool("secure-cookies", true, "mark cookies Secure (serve behind https)")
 42+	if err := fs.Parse(args[1:]); err != nil {
 43+		return exitUsage
 44+	}
 45+	if *passwordHash == "" || *knot == "" {
 46+		fmt.Fprintln(os.Stderr, "ci appview serve: --password-hash-file and --knot are required")
 47+		return exitUsage
 48+	}
 49+	hash, err := readToken(*passwordHash)
 50+	if err != nil {
 51+		fmt.Fprintln(os.Stderr, "ci appview serve:", err)
 52+		return exitFailure
 53+	}
 54+	key := appview.NewKey()
 55+	if *keyFile != "" {
 56+		k, err := readToken(*keyFile)
 57+		if err != nil {
 58+			fmt.Fprintln(os.Stderr, "ci appview serve:", err)
 59+			return exitFailure
 60+		}
 61+		key = []byte(k)
 62+	}
 63+	agentTokens, err := appview.LoadAgents(*agents)
 64+	if err != nil {
 65+		fmt.Fprintln(os.Stderr, "ci appview serve:", err)
 66+		return exitFailure
 67+	}
 68+	rtoken, err := readToken(*runnerToken)
 69+	if err != nil {
 70+		fmt.Fprintln(os.Stderr, "ci appview serve:", err)
 71+		return exitFailure
 72+	}
 73+	actor := *owner
 74+	if actor == "" {
 75+		actor = "owner"
 76+	}
 77+	s := &appview.Server{
 78+		Runner: &appview.Runner{URL: *runnerURL, Token: rtoken},
 79+		Auth:   &appview.Auth{PasswordHash: []byte(hash), Key: key, Agents: agentTokens, Secure: *secure},
 80+		Knot:   *knot, OwnerDid: actor,
 81+	}
 82+	return serve(*listen, s.Handler(), nil)
 83+}
 84+
 85+// appviewClient is the agent side: CI_APPVIEW_URL and CI_TOKEN (an agent
 86+// token), e.g. for Claude in a remote session.
 87+func appviewClient() (base, token string, err error) {
 88+	base, token = os.Getenv("CI_APPVIEW_URL"), os.Getenv("CI_TOKEN")
 89+	if base == "" || token == "" {
 90+		return "", "", fmt.Errorf("set CI_APPVIEW_URL and CI_TOKEN (an agent token)")
 91+	}
 92+	return strings.TrimSuffix(base, "/"), token, nil
 93+}
 94+
 95+func apiCall(method, path string, body any, out any) error {
 96+	base, token, err := appviewClient()
 97+	if err != nil {
 98+		return err
 99+	}
100+	var rd io.Reader
101+	if body != nil {
102+		b, _ := json.Marshal(body)
103+		rd = bytes.NewReader(b)
104+	}
105+	req, err := http.NewRequest(method, base+path, rd)
106+	if err != nil {
107+		return err
108+	}
109+	req.Header.Set("Authorization", "Bearer "+token)
110+	if body != nil {
111+		req.Header.Set("Content-Type", "application/json")
112+	}
113+	resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
114+	if err != nil {
115+		return err
116+	}
117+	defer resp.Body.Close()
118+	if resp.StatusCode >= 300 {
119+		b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
120+		return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
121+	}
122+	if w, ok := out.(io.Writer); ok {
123+		_, err := io.Copy(w, resp.Body)
124+		return err
125+	}
126+	if out != nil {
127+		return json.NewDecoder(resp.Body).Decode(out)
128+	}
129+	return nil
130+}
131+
132+func cmdTrigger(args []string) int {
133+	fs := flag.NewFlagSet("ci trigger", flag.ContinueOnError)
134+	ref := fs.String("ref", "main", "branch or commit")
135+	if err := fs.Parse(args); err != nil || fs.NArg() < 1 || fs.NArg() > 2 {
136+		fmt.Fprintln(os.Stderr, "usage: ci trigger [--ref main] <repo> [workflow]")
137+		return exitUsage
138+	}
139+	in := map[string]string{"repo": fs.Arg(0), "ref": *ref}
140+	if fs.NArg() == 2 {
141+		in["workflow"] = fs.Arg(1)
142+	}
143+	var out map[string]string
144+	if err := apiCall("POST", "/api/trigger", in, &out); err != nil {
145+		fmt.Fprintln(os.Stderr, "ci trigger:", err)
146+		return exitFailure
147+	}
148+	fmt.Println(out["pipeline"])
149+	return exitSuccess
150+}
151+
152+// cmdWait waits for the newest run of a repo (optionally of a commit and
153+// workflow) to finish or pause, then prints its plain-text log; the exit
154+// code follows the run (75 = waiting at a gate for the owner).
155+func cmdWait(args []string) int {
156+	fs := flag.NewFlagSet("ci wait", flag.ContinueOnError)
157+	workflow := fs.String("workflow", "", "only runs of this workflow")
158+	timeout := fs.Duration("timeout", time.Hour, "give up after this long")
159+	tail := fs.Int("tail", 60, "print only the last N log lines (0: all)")
160+	if err := fs.Parse(args); err != nil || fs.NArg() < 1 || fs.NArg() > 2 {
161+		fmt.Fprintln(os.Stderr, "usage: ci wait [--workflow w] [--timeout 1h] <repo> [sha-prefix]")
162+		return exitUsage
163+	}
164+	repo, sha := fs.Arg(0), fs.Arg(1)
165+	deadline := time.Now().Add(*timeout)
166+	for {
167+		var runs []runner.RunView
168+		if err := apiCall("GET", "/api/runs?limit=20&repo="+url.QueryEscape(repo), nil, &runs); err != nil {
169+			fmt.Fprintln(os.Stderr, "ci wait:", err)
170+			return exitFailure
171+		}
172+		for _, v := range runs {
173+			if v.Parent != "" || v.State == nil || (*workflow != "" && v.Workflow != *workflow) || !strings.HasPrefix(v.State.Meta.Sha, sha) {
174+				continue
175+			}
176+			st := v.State.Status
177+			if st != engine.StatusRunning && v.Status != "pending" {
178+				var buf bytes.Buffer
179+				if err := apiCall("GET", "/api/runs/"+url.PathEscape(v.ID)+"/log.txt", nil, &buf); err == nil {
180+					lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
181+					if *tail > 0 && len(lines) > *tail {
182+						lines = lines[len(lines)-*tail:]
183+					}
184+					fmt.Println(strings.Join(lines, "\n"))
185+				}
186+				fmt.Fprintf(os.Stderr, "run %s: %s\n", v.ID, st)
187+				return exitCode(st)
188+			}
189+			break // newest matching run still running: wait
190+		}
191+		if time.Now().After(deadline) {
192+			fmt.Fprintln(os.Stderr, "ci wait: timed out")
193+			return exitFailure
194+		}
195+		time.Sleep(5 * time.Second)
196+	}
197+}
M cmd/ci/main.go
+0, -1
1@@ -36,7 +36,6 @@ 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...)
D cmd/ci/stubs.go
+0, -28
 1@@ -1,28 +0,0 @@
 2-package main
 3-
 4-import (
 5-	"fmt"
 6-	"os"
 7-)
 8-
 9-// addStubs registers the commands not implemented yet; each issue in the
10-// tracker replaces its stub. Called from main after every init has run, so a
11-// real command always wins.
12-func addStubs() {
13-	for name, summary := range map[string]string{
14-		"resume":  "continue a paused run (engine)",
15-		"trigger": "ask the runner for a manual pipeline",
16-		"wait":    "wait for a pipeline and print failing logs",
17-		"bridge":  "forge bridge (knot-compatible event stream)",
18-		"runner":  "runner daemon (spindle-compatible)",
19-		"appview": "web UI",
20-	} {
21-		if _, ok := commands[name]; ok {
22-			continue
23-		}
24-		commands[name] = command{summary, func([]string) int {
25-			fmt.Fprintf(os.Stderr, "ci %s: not implemented yet\n", name)
26-			return 2
27-		}}
28-	}
29-}
M flake.nix
+17, -2
 1@@ -34,7 +34,7 @@
 2               ./lexicons
 3             ];
 4           };
 5-          vendorHash = "sha256-wShjEvQW4KV3hT4SlF5AYJzpbWonuFkg+2cbP325qjY=";
 6+          vendorHash = "sha256-EQJSRDpp6xxGbVWVUxaOMWjsvuIbiixnE6jRGmiatZI=";
 7           subPackages = [ "cmd/ci" ];
 8           env.CGO_ENABLED = "0";
 9           ldflags = [ "-X main.version=${version}" ];
10@@ -54,8 +54,23 @@
11         ci = self.packages.${final.stdenv.hostPlatform.system}.ci;
12       };
13 
14+      # The whole test suite (the package's own check only covers cmd/ci).
15+      # Engine, checkout and runner tests drive real git and bash.
16       checks = forAllSystems (pkgs: {
17-        ci = self.packages.${pkgs.stdenv.hostPlatform.system}.ci.overrideAttrs { doCheck = true; };
18+        tests = self.packages.${pkgs.stdenv.hostPlatform.system}.ci.overrideAttrs (old: {
19+          pname = "ci-tests";
20+          nativeCheckInputs = [
21+            pkgs.git
22+            pkgs.jq
23+            pkgs.bash
24+          ];
25+          checkPhase = ''
26+            runHook preCheck
27+            export HOME=$TMPDIR
28+            go test ./...
29+            runHook postCheck
30+          '';
31+        });
32       });
33     };
34 }
M go.mod
+2, -1
 1@@ -10,7 +10,8 @@ require (
 2 	github.com/mattn/go-isatty v0.0.24 // indirect
 3 	github.com/ncruces/go-strftime v1.0.0 // indirect
 4 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 5-	golang.org/x/sys v0.48.0 // indirect
 6+	golang.org/x/crypto v0.58.0 // indirect
 7+	golang.org/x/sys v0.49.0 // indirect
 8 	gopkg.in/yaml.v3 v3.0.1 // indirect
 9 	modernc.org/libc v1.77.1 // indirect
10 	modernc.org/mathutil v1.7.1 // indirect
M go.sum
+4, -0
 1@@ -12,8 +12,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
 2 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 3 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 4 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 5+golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
 6+golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
 7 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
 8 golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 9+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
10+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
11 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
12 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
13 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A internal/appview/appview_test.go
+255, -0
  1@@ -0,0 +1,255 @@
  2+package appview
  3+
  4+import (
  5+	"context"
  6+	"encoding/json"
  7+	"io"
  8+	"net/http"
  9+	"net/http/cookiejar"
 10+	"net/http/httptest"
 11+	"net/url"
 12+	"os"
 13+	"os/exec"
 14+	"path/filepath"
 15+	"regexp"
 16+	"strings"
 17+	"testing"
 18+	"time"
 19+
 20+	"golang.org/x/crypto/bcrypt"
 21+
 22+	"git.kilimanjaro.io/ci/internal/eventlog"
 23+	"git.kilimanjaro.io/ci/internal/ident"
 24+	"git.kilimanjaro.io/ci/internal/lex"
 25+	"git.kilimanjaro.io/ci/internal/runner"
 26+)
 27+
 28+var ciBinary string
 29+
 30+func TestMain(m *testing.M) {
 31+	dir, _ := os.MkdirTemp("", "ci-appview-test")
 32+	ciBinary = filepath.Join(dir, "ci")
 33+	if out, err := exec.Command("go", "build", "-o", ciBinary, "../../cmd/ci").CombinedOutput(); err != nil {
 34+		panic(string(out))
 35+	}
 36+	os.Symlink("ci", filepath.Join(dir, "ci-result"))
 37+	code := m.Run()
 38+	os.RemoveAll(dir)
 39+	os.Exit(code)
 40+}
 41+
 42+func sh(t *testing.T, dir string, args ...string) string {
 43+	t.Helper()
 44+	cmd := exec.Command(args[0], args[1:]...)
 45+	cmd.Dir = dir
 46+	cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
 47+		"GIT_COMMITTER_EMAIL=t@t", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
 48+	out, err := cmd.CombinedOutput()
 49+	if err != nil {
 50+		t.Fatalf("%v: %v\n%s", args, err, out)
 51+	}
 52+	return strings.TrimSpace(string(out))
 53+}
 54+
 55+const wf = `
 56+when: [{ event: [push, manual], branch: main }]
 57+engine: devenv
 58+steps: [build, deploy]
 59+actions:
 60+  build:    { command: "echo building; echo prev > $CI_STATE/prev" }
 61+  deploy:   { command: "echo deploying", gate: { prompt: "Deploy demo?" } }
 62+  rollback: { command: "cat $CI_STATE/prev", manual: true, style: danger, label: Roll back }
 63+  note:     { command: "echo noted", manual: true }
 64+`
 65+
 66+const agentToken = "agent-token-0123456789"
 67+
 68+type env struct {
 69+	t     *testing.T
 70+	r     *runner.Runner
 71+	av    *httptest.Server
 72+	owner *http.Client
 73+	sha   string
 74+}
 75+
 76+func setup(t *testing.T) *env {
 77+	t.Helper()
 78+	t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
 79+	forge := t.TempDir()
 80+	work := filepath.Join(t.TempDir(), "demo")
 81+	os.MkdirAll(filepath.Join(work, ".tangled/workflows"), 0o755)
 82+	os.WriteFile(filepath.Join(work, ".tangled/workflows/deploy.yml"), []byte(wf), 0o644)
 83+	sh(t, work, "git", "init", "-q", "-b", "main")
 84+	sh(t, work, "git", "add", "-A")
 85+	sh(t, work, "git", "commit", "-qm", "init")
 86+	sha := sh(t, work, "git", "rev-parse", "HEAD")
 87+	sh(t, "", "git", "clone", "-q", "--bare", work, filepath.Join(forge, "demo.git"))
 88+
 89+	state := t.TempDir()
 90+	log, err := eventlog.Open(filepath.Join(state, "runner.db"))
 91+	if err != nil {
 92+		t.Fatal(err)
 93+	}
 94+	t.Cleanup(func() { log.Close() })
 95+	cfg := runner.Config{RunsDir: filepath.Join(state, "runs"), CacheDir: filepath.Join(state, "cache"),
 96+		Knot: "git.test", OwnerDid: "did:plc:me", CloneURL: filepath.Join(forge, "{repo}.git")}
 97+	r, err := runner.New(cfg, log, &runner.Local{Engine: ciBinary, Shell: "bash", LocksDir: filepath.Join(state, "locks")}, nil)
 98+	if err != nil {
 99+		t.Fatal(err)
100+	}
101+	rs := httptest.NewServer(r.Handler("runner-token"))
102+	t.Cleanup(rs.Close)
103+
104+	hash, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
105+	s := &Server{Runner: &Runner{URL: rs.URL, Token: "runner-token"},
106+		Auth: &Auth{PasswordHash: hash, Key: NewKey(), Agents: map[string]string{agentToken: "claude"}},
107+		Knot: "git.test", OwnerDid: "did:plc:me"}
108+	av := httptest.NewServer(s.Handler())
109+	t.Cleanup(av.Close)
110+	jar, _ := cookiejar.New(nil)
111+	owner := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
112+	return &env{t: t, r: r, av: av, owner: owner, sha: sha}
113+}
114+
115+func (e *env) do(c *http.Client, method, path string, form url.Values, token string) (int, string) {
116+	e.t.Helper()
117+	var body io.Reader
118+	if form != nil {
119+		body = strings.NewReader(form.Encode())
120+	}
121+	req, _ := http.NewRequest(method, e.av.URL+path, body)
122+	if form != nil {
123+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
124+	}
125+	if token != "" {
126+		req.Header.Set("Authorization", "Bearer "+token)
127+	}
128+	resp, err := c.Do(req)
129+	if err != nil {
130+		e.t.Fatal(err)
131+	}
132+	defer resp.Body.Close()
133+	b, _ := io.ReadAll(resp.Body)
134+	return resp.StatusCode, string(b)
135+}
136+
137+func (e *env) waitStatus(id, status string) {
138+	e.t.Helper()
139+	for range 600 {
140+		if x, err := e.r.GetRun(id); err == nil && x.Status == status {
141+			time.Sleep(100 * time.Millisecond) // let the follower settle
142+			return
143+		}
144+		time.Sleep(50 * time.Millisecond)
145+	}
146+	x, _ := e.r.GetRun(id)
147+	e.t.Fatalf("%s: %s, want %s", id, x.Status, status)
148+}
149+
150+var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
151+
152+func TestOwnerApprovesAgentCannot(t *testing.T) {
153+	e := setup(t)
154+	agent := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
155+	if err := e.r.OnRefUpdate(context.Background(), lex.GitRefUpdate{Repo: ident.RepoDID("git.test", "demo"), Ref: "refs/heads/main", NewSha: e.sha}); err != nil {
156+		t.Fatal(err)
157+	}
158+	runs, _ := e.r.ListRuns("", 5)
159+	id := runs[0].ID
160+	e.waitStatus(id, "paused")
161+
162+	// pages need the owner's session; agents are sent to log in
163+	if code, _ := e.do(agent, "GET", "/", nil, agentToken); code != http.StatusSeeOther {
164+		t.Errorf("agent page: %d", code)
165+	}
166+	// the agent API reads and refuses gates
167+	if code, body := e.do(agent, "GET", "/api/runs", nil, agentToken); code != 200 || !strings.Contains(body, id) {
168+		t.Errorf("agent runs: %d %s", code, body)
169+	}
170+	if code, _ := e.do(agent, "GET", "/api/runs", nil, "wrong-token-xxxxxxxxx"); code != 401 {
171+		t.Errorf("bad token: %d", code)
172+	}
173+	if code, _ := e.do(agent, "POST", "/api/runs/"+id+"/gate", url.Values{"option": {"1"}}, agentToken); code != 403 {
174+		t.Errorf("agent gate: %d", code)
175+	}
176+	if code, _ := e.do(agent, "POST", "/runs/"+id+"/gate", url.Values{"option": {"1"}}, agentToken); code != 403 {
177+		t.Errorf("agent form gate: %d", code)
178+	}
179+
180+	// owner login
181+	if code, _ := e.do(e.owner, "POST", "/login", url.Values{"password": {"nope"}, "next": {"/"}}, ""); code != 401 {
182+		t.Errorf("wrong password: %d", code)
183+	}
184+	if code, _ := e.do(e.owner, "POST", "/login", url.Values{"password": {"pw"}, "next": {"//evil.example"}}, ""); code != http.StatusSeeOther {
185+		t.Fatalf("login: %d", code)
186+	}
187+	code, body := e.do(e.owner, "GET", "/", nil, "")
188+	if code != 200 || !strings.Contains(body, "Needs you") || !strings.Contains(body, "Deploy demo?") {
189+		t.Fatalf("runs page: %d\n%s", code, body)
190+	}
191+	code, body = e.do(e.owner, "GET", "/runs/"+id, nil, "")
192+	if code != 200 || !strings.Contains(body, "Deploy demo?") || !strings.Contains(body, "building") || !strings.Contains(body, "Roll back") {
193+		t.Fatalf("run page: %d\n%s", code, body)
194+	}
195+	csrf := csrfRe.FindStringSubmatch(body)[1]
196+
197+	// without the form token: refused
198+	if code, _ := e.do(e.owner, "POST", "/runs/"+id+"/gate", url.Values{"option": {"1"}}, ""); code != 403 {
199+		t.Errorf("no csrf: %d", code)
200+	}
201+	if code, _ := e.do(e.owner, "POST", "/runs/"+id+"/gate", url.Values{"option": {"1"}, "csrf": {csrf}}, ""); code != http.StatusSeeOther {
202+		t.Fatalf("approve: %d", code)
203+	}
204+	e.waitStatus(id, "success")
205+	st, _ := e.r.GetRun(id)
206+	b, _ := os.ReadFile(filepath.Join(st.Dir, "events.ndjson"))
207+	if !strings.Contains(string(b), `"actor":"did:plc:me"`) {
208+		t.Error("approval not recorded with the owner's DID")
209+	}
210+
211+	// agents may run non-danger actions, not danger ones
212+	if code, _ := e.do(agent, "POST", "/api/runs/"+id+"/actions/rollback", url.Values{}, agentToken); code != 403 {
213+		t.Errorf("agent danger action: %d", code)
214+	}
215+	code, body = e.do(agent, "POST", "/api/runs/"+id+"/actions/note", url.Values{}, agentToken)
216+	if code != 200 {
217+		t.Fatalf("agent note: %d %s", code, body)
218+	}
219+	var child map[string]string
220+	json.Unmarshal([]byte(body), &child)
221+	e.waitStatus(child["run"], "success")
222+
223+	// the live stream of a finished run ends at once
224+	code, body = e.do(e.owner, "GET", "/runs/"+id+"/live?after=9999", nil, "")
225+	if code != 200 || !strings.Contains(body, "event: end") {
226+		t.Errorf("live: %d %q", code, body)
227+	}
228+	// plain-text log for agents
229+	if code, body := e.do(agent, "GET", "/api/runs/"+id+"/log.txt", nil, agentToken); code != 200 || !strings.Contains(body, "== gate approved") {
230+		t.Errorf("log.txt: %d\n%s", code, body)
231+	}
232+
233+	// agents can trigger
234+	if code, _ := e.do(agent, "POST", "/api/trigger", nil, agentToken); code != 400 {
235+		t.Errorf("trigger without body: %d", code)
236+	}
237+	req, _ := http.NewRequest("POST", e.av.URL+"/api/trigger", strings.NewReader(`{"repo":"demo","workflow":"deploy","ref":"main"}`))
238+	req.Header.Set("Authorization", "Bearer "+agentToken)
239+	resp, err := agent.Do(req)
240+	if err != nil {
241+		t.Fatal(err)
242+	}
243+	rb, _ := io.ReadAll(resp.Body)
244+	resp.Body.Close()
245+	if resp.StatusCode != 200 || !strings.Contains(string(rb), "at://did:web:git.test/sh.tangled.pipeline/") {
246+		t.Errorf("trigger: %d %s", resp.StatusCode, rb)
247+	}
248+}
249+
250+func TestSafeNext(t *testing.T) {
251+	for in, want := range map[string]string{"/runs/x": "/runs/x", "//evil": "/", "https://evil": "/", "/\\evil": "/", "": "/"} {
252+		if got := safeNext(in); got != want {
253+			t.Errorf("safeNext(%q) = %q", in, got)
254+		}
255+	}
256+}
A internal/appview/auth.go
+176, -0
  1@@ -0,0 +1,176 @@
  2+package appview
  3+
  4+import (
  5+	"bufio"
  6+	"crypto/hmac"
  7+	"crypto/rand"
  8+	"crypto/sha256"
  9+	"crypto/subtle"
 10+	"encoding/base64"
 11+	"encoding/hex"
 12+	"fmt"
 13+	"net/http"
 14+	"os"
 15+	"strconv"
 16+	"strings"
 17+	"time"
 18+
 19+	"golang.org/x/crypto/bcrypt"
 20+)
 21+
 22+// Who is making a request.
 23+type Who struct {
 24+	Owner bool   // logged in with the owner's password
 25+	Agent string // name of the agent token used, if any
 26+}
 27+
 28+// CanRead: the owner and any agent.
 29+func (w Who) CanRead() bool { return w.Owner || w.Agent != "" }
 30+
 31+// CanApprove: only the owner's session approves gates, runs danger actions
 32+// and cancels (DESIGN.md §8 rule 7).
 33+func (w Who) CanApprove() bool { return w.Owner }
 34+
 35+// Actor is how the decision is recorded.
 36+func (w Who) Actor(owner string) string {
 37+	if w.Owner {
 38+		return owner
 39+	}
 40+	return "agent:" + w.Agent
 41+}
 42+
 43+// Auth holds the owner's password hash, the session key and agent tokens.
 44+type Auth struct {
 45+	PasswordHash []byte            // bcrypt
 46+	Key          []byte            // signs session cookies and CSRF tokens
 47+	Agents       map[string]string // token -> name
 48+	Secure       bool              // Secure cookies (behind https)
 49+	SessionTTL   time.Duration
 50+}
 51+
 52+const cookieName = "ci_session"
 53+
 54+// LoadAgents reads "<name> <token>" lines (# comments).
 55+func LoadAgents(path string) (map[string]string, error) {
 56+	out := map[string]string{}
 57+	if path == "" {
 58+		return out, nil
 59+	}
 60+	f, err := os.Open(path)
 61+	if err != nil {
 62+		return nil, err
 63+	}
 64+	defer f.Close()
 65+	sc := bufio.NewScanner(f)
 66+	for sc.Scan() {
 67+		line := strings.TrimSpace(sc.Text())
 68+		if line == "" || strings.HasPrefix(line, "#") {
 69+			continue
 70+		}
 71+		name, tok, ok := strings.Cut(line, " ")
 72+		tok = strings.TrimSpace(tok)
 73+		if !ok || len(tok) < 16 {
 74+			return nil, fmt.Errorf("%s: want \"<name> <token>\" with a token of 16+ characters", path)
 75+		}
 76+		out[tok] = name
 77+	}
 78+	return out, sc.Err()
 79+}
 80+
 81+// NewKey returns a random session key (sessions end when the appview restarts
 82+// unless a key file is configured).
 83+func NewKey() []byte {
 84+	k := make([]byte, 32)
 85+	_, _ = rand.Read(k)
 86+	return k
 87+}
 88+
 89+func (a *Auth) ttl() time.Duration {
 90+	if a.SessionTTL > 0 {
 91+		return a.SessionTTL
 92+	}
 93+	return 30 * 24 * time.Hour
 94+}
 95+
 96+func (a *Auth) sign(s string) string {
 97+	m := hmac.New(sha256.New, a.Key)
 98+	m.Write([]byte(s))
 99+	return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
100+}
101+
102+// CheckPassword reports whether password is the owner's.
103+func (a *Auth) CheckPassword(password string) bool {
104+	return len(a.PasswordHash) > 0 && bcrypt.CompareHashAndPassword(a.PasswordHash, []byte(password)) == nil
105+}
106+
107+// Login sets the session cookie.
108+func (a *Auth) Login(w http.ResponseWriter) {
109+	exp := strconv.FormatInt(time.Now().Add(a.ttl()).Unix(), 10)
110+	nonce := make([]byte, 12)
111+	_, _ = rand.Read(nonce)
112+	v := exp + "." + hex.EncodeToString(nonce)
113+	http.SetCookie(w, &http.Cookie{
114+		Name: cookieName, Value: v + "." + a.sign(v), Path: "/", HttpOnly: true, Secure: a.Secure,
115+		SameSite: http.SameSiteStrictMode, MaxAge: int(a.ttl().Seconds()),
116+	})
117+}
118+
119+// Logout clears it.
120+func (a *Auth) Logout(w http.ResponseWriter) {
121+	http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.Secure, SameSite: http.SameSiteStrictMode})
122+}
123+
124+// session returns the session id if the cookie is valid.
125+func (a *Auth) session(r *http.Request) (string, bool) {
126+	c, err := r.Cookie(cookieName)
127+	if err != nil {
128+		return "", false
129+	}
130+	i := strings.LastIndexByte(c.Value, '.')
131+	if i < 0 {
132+		return "", false
133+	}
134+	v, sig := c.Value[:i], c.Value[i+1:]
135+	if subtle.ConstantTimeCompare([]byte(sig), []byte(a.sign(v))) != 1 {
136+		return "", false
137+	}
138+	exp, _, _ := strings.Cut(v, ".")
139+	t, err := strconv.ParseInt(exp, 10, 64)
140+	if err != nil || time.Now().Unix() > t {
141+		return "", false
142+	}
143+	return v, true
144+}
145+
146+// Who identifies a request: a valid session cookie, or an agent's bearer
147+// token. Agent tokens never make a request the owner's.
148+func (a *Auth) Who(r *http.Request) Who {
149+	if _, ok := a.session(r); ok {
150+		return Who{Owner: true}
151+	}
152+	if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
153+		tok := strings.TrimPrefix(h, "Bearer ")
154+		for t, name := range a.Agents {
155+			if subtle.ConstantTimeCompare([]byte(t), []byte(tok)) == 1 {
156+				return Who{Agent: name}
157+			}
158+		}
159+	}
160+	return Who{}
161+}
162+
163+// CSRF returns the form token for the request's session.
164+func (a *Auth) CSRF(r *http.Request) string {
165+	s, ok := a.session(r)
166+	if !ok {
167+		return ""
168+	}
169+	return a.sign("csrf:" + s)
170+}
171+
172+// CheckCSRF verifies a form's token (session-bound; cookies are also
173+// SameSite=Strict).
174+func (a *Auth) CheckCSRF(r *http.Request) bool {
175+	want := a.CSRF(r)
176+	return want != "" && subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(want)) == 1
177+}
A internal/appview/client.go
+162, -0
  1@@ -0,0 +1,162 @@
  2+package appview
  3+
  4+import (
  5+	"bytes"
  6+	"context"
  7+	"encoding/json"
  8+	"fmt"
  9+	"io"
 10+	"net/http"
 11+	"net/url"
 12+	"strings"
 13+	"time"
 14+
 15+	"git.kilimanjaro.io/ci/internal/lex"
 16+	"git.kilimanjaro.io/ci/internal/runner"
 17+)
 18+
 19+// Runner is a client of a runner's API (internal/runner Handler).
 20+type Runner struct {
 21+	URL   string // e.g. http://127.0.0.1:8476
 22+	Token string
 23+	HTTP  *http.Client
 24+}
 25+
 26+func (c *Runner) do(ctx context.Context, method, path string, body any, out any) error {
 27+	var rd io.Reader
 28+	if body != nil {
 29+		b, err := json.Marshal(body)
 30+		if err != nil {
 31+			return err
 32+		}
 33+		rd = bytes.NewReader(b)
 34+	}
 35+	req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(c.URL, "/")+path, rd)
 36+	if err != nil {
 37+		return err
 38+	}
 39+	if c.Token != "" {
 40+		req.Header.Set("Authorization", "Bearer "+c.Token)
 41+	}
 42+	if body != nil {
 43+		req.Header.Set("Content-Type", "application/json")
 44+	}
 45+	hc := c.HTTP
 46+	if hc == nil {
 47+		hc = &http.Client{Timeout: 30 * time.Second}
 48+	}
 49+	resp, err := hc.Do(req)
 50+	if err != nil {
 51+		return err
 52+	}
 53+	defer resp.Body.Close()
 54+	if resp.StatusCode >= 300 {
 55+		var e struct {
 56+			Error string `json:"error"`
 57+		}
 58+		b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
 59+		if json.Unmarshal(b, &e) == nil && e.Error != "" {
 60+			return fmt.Errorf("%s", e.Error)
 61+		}
 62+		return fmt.Errorf("runner: %s: %s", resp.Status, strings.TrimSpace(string(b)))
 63+	}
 64+	if out == nil {
 65+		return nil
 66+	}
 67+	if w, ok := out.(io.Writer); ok {
 68+		_, err := io.Copy(w, resp.Body)
 69+		return err
 70+	}
 71+	return json.NewDecoder(resp.Body).Decode(out)
 72+}
 73+
 74+// Runs lists runs, newest first.
 75+func (c *Runner) Runs(ctx context.Context, repo string, limit int) ([]runner.RunView, error) {
 76+	q := url.Values{"limit": {fmt.Sprint(limit)}}
 77+	if repo != "" {
 78+		q.Set("repo", repo)
 79+	}
 80+	var out []runner.RunView
 81+	return out, c.do(ctx, "GET", "/api/runs?"+q.Encode(), nil, &out)
 82+}
 83+
 84+// Run returns one run.
 85+func (c *Runner) Run(ctx context.Context, id string) (runner.RunView, error) {
 86+	var out runner.RunView
 87+	return out, c.do(ctx, "GET", "/api/runs/"+url.PathEscape(id), nil, &out)
 88+}
 89+
 90+// Events returns a run's events after seq.
 91+func (c *Runner) Events(ctx context.Context, id string, after int) ([]lex.RunEvent, error) {
 92+	var buf bytes.Buffer
 93+	if err := c.do(ctx, "GET", fmt.Sprintf("/api/runs/%s/events?after=%d", url.PathEscape(id), after), nil, &buf); err != nil {
 94+		return nil, err
 95+	}
 96+	var out []lex.RunEvent
 97+	for _, line := range bytes.Split(buf.Bytes(), []byte("\n")) {
 98+		if len(bytes.TrimSpace(line)) == 0 {
 99+			continue
100+		}
101+		var ev lex.RunEvent
102+		if err := json.Unmarshal(line, &ev); err != nil {
103+			return nil, err
104+		}
105+		out = append(out, ev)
106+	}
107+	return out, nil
108+}
109+
110+// Text copies a run's plain-text log to w.
111+func (c *Runner) Text(ctx context.Context, id string, w io.Writer) error {
112+	return c.do(ctx, "GET", "/api/runs/"+url.PathEscape(id)+"/log.txt", nil, w)
113+}
114+
115+// File copies a run file (results/…, artifacts/…) to w.
116+func (c *Runner) File(ctx context.Context, id, path string, w io.Writer) error {
117+	return c.do(ctx, "GET", "/api/runs/"+url.PathEscape(id)+"/files/"+path, nil, w)
118+}
119+
120+// Gate answers a run's gate: option is 1-based; dismiss ends the run.
121+func (c *Runner) Gate(ctx context.Context, id string, option int, dismiss bool, actor string) error {
122+	return c.do(ctx, "POST", "/api/runs/"+url.PathEscape(id)+"/gate",
123+		map[string]any{"option": option, "dismiss": dismiss, "actor": actor}, nil)
124+}
125+
126+// Action starts a manual action run and returns its id.
127+func (c *Runner) Action(ctx context.Context, id, action, actor string) (string, error) {
128+	var out struct {
129+		Run string `json:"run"`
130+	}
131+	err := c.do(ctx, "POST", "/api/runs/"+url.PathEscape(id)+"/actions/"+url.PathEscape(action), map[string]any{"actor": actor}, &out)
132+	return out.Run, err
133+}
134+
135+// Cancel stops a run.
136+func (c *Runner) Cancel(ctx context.Context, id string) error {
137+	return c.do(ctx, "POST", "/api/runs/"+url.PathEscape(id)+"/cancel", nil, nil)
138+}
139+
140+// Trigger starts a manual pipeline of repo (a DID) at rev (a commit or a
141+// branch, resolved by the runner) for the named workflows (all if none).
142+func (c *Runner) Trigger(ctx context.Context, repoDid, rev string, workflows []string) (string, error) {
143+	in := lex.TriggerPipelineInput{Repo: repoDid, Workflows: workflows,
144+		Trigger: &lex.ManualTrigger{LexiconTypeID: "sh.tangled.ci.trigger#manual", Sha: rev}}
145+	if !isSha(rev) {
146+		ref := "refs/heads/" + rev
147+		in.Trigger.Ref = &ref
148+	}
149+	var out lex.TriggerPipelineOutput
150+	return out.Pipeline, c.do(ctx, "POST", "/xrpc/sh.tangled.ci.triggerPipeline", in, &out)
151+}
152+
153+func isSha(s string) bool {
154+	if len(s) != 40 {
155+		return false
156+	}
157+	for _, c := range s {
158+		if !strings.ContainsRune("0123456789abcdef", c) {
159+			return false
160+		}
161+	}
162+	return true
163+}
A internal/appview/server.go
+532, -0
  1@@ -0,0 +1,532 @@
  2+// Package appview is the web UI and agent API over a runner (DESIGN.md
  3+// §11): server-rendered pages that work on a phone without JavaScript (a
  4+// little JS streams logs live), owner login for approvals, and bearer
  5+// tokens for agents that may read, trigger and run non-danger actions but
  6+// never approve gates.
  7+package appview
  8+
  9+import (
 10+	"context"
 11+	"embed"
 12+	"encoding/json"
 13+	"fmt"
 14+	"html/template"
 15+	"io"
 16+	"log/slog"
 17+	"net/http"
 18+	"net/url"
 19+	"slices"
 20+	"strconv"
 21+	"strings"
 22+	"time"
 23+
 24+	"git.kilimanjaro.io/ci/internal/engine"
 25+	"git.kilimanjaro.io/ci/internal/ident"
 26+	"git.kilimanjaro.io/ci/internal/lex"
 27+	"git.kilimanjaro.io/ci/internal/runner"
 28+)
 29+
 30+//go:embed templates/*.html static/*
 31+var assets embed.FS
 32+
 33+// Server is the appview.
 34+type Server struct {
 35+	Runner *Runner
 36+	Auth   *Auth
 37+	// Knot is the forge host (repos are did:web:<Knot>:<repo>).
 38+	Knot string
 39+	// OwnerDid is recorded as the actor of the owner's decisions.
 40+	OwnerDid string
 41+	Logger   *slog.Logger
 42+
 43+	tmpl *template.Template
 44+}
 45+
 46+// Handler serves the UI and the agent API.
 47+func (s *Server) Handler() http.Handler {
 48+	s.tmpl = template.Must(template.New("").Funcs(funcs).ParseFS(assets, "templates/*.html"))
 49+	if s.Logger == nil {
 50+		s.Logger = slog.Default()
 51+	}
 52+	mux := http.NewServeMux()
 53+	mux.Handle("GET /static/", http.FileServerFS(assets))
 54+	mux.HandleFunc("GET /login", s.loginPage)
 55+	mux.HandleFunc("POST /login", s.login)
 56+	mux.HandleFunc("POST /logout", s.logout)
 57+	mux.HandleFunc("GET /{$}", s.page(s.runsPage))
 58+	mux.HandleFunc("GET /runs/{id}", s.page(s.runPage))
 59+	mux.HandleFunc("GET /runs/{id}/live", s.page(s.live))
 60+	mux.HandleFunc("GET /runs/{id}/files/{path...}", s.page(s.file))
 61+	mux.HandleFunc("POST /runs/{id}/gate", s.owner(s.gate))
 62+	mux.HandleFunc("POST /runs/{id}/actions/{action}", s.owner(s.action))
 63+	mux.HandleFunc("POST /runs/{id}/cancel", s.owner(s.cancel))
 64+	mux.HandleFunc("POST /trigger", s.owner(s.trigger))
 65+
 66+	// agent API (bearer token; the owner's session works too)
 67+	mux.HandleFunc("GET /api/runs", s.api(s.apiRuns))
 68+	mux.HandleFunc("GET /api/runs/{id}", s.api(s.apiRun))
 69+	mux.HandleFunc("GET /api/runs/{id}/log.txt", s.api(s.apiText))
 70+	mux.HandleFunc("POST /api/trigger", s.api(s.apiTrigger))
 71+	mux.HandleFunc("POST /api/runs/{id}/actions/{action}", s.api(s.apiAction))
 72+	mux.HandleFunc("POST /api/runs/{id}/gate", s.api(func(w http.ResponseWriter, r *http.Request, who Who) {
 73+		apiError(w, http.StatusForbidden, "approving gates needs the owner's session in the appview")
 74+	}))
 75+	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
 76+	return securityHeaders(mux)
 77+}
 78+
 79+func securityHeaders(h http.Handler) http.Handler {
 80+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 81+		w.Header().Set("X-Content-Type-Options", "nosniff")
 82+		w.Header().Set("Referrer-Policy", "same-origin")
 83+		w.Header().Set("X-Frame-Options", "DENY")
 84+		w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; frame-ancestors 'none'")
 85+		h.ServeHTTP(w, r)
 86+	})
 87+}
 88+
 89+// page requires the owner's session for HTML pages (read-only pages too:
 90+// logs can carry deploy details).
 91+func (s *Server) page(h func(http.ResponseWriter, *http.Request, Who)) http.HandlerFunc {
 92+	return func(w http.ResponseWriter, r *http.Request) {
 93+		who := s.Auth.Who(r)
 94+		if !who.Owner {
 95+			http.Redirect(w, r, "/login?next="+urlEscape(r.URL.RequestURI()), http.StatusSeeOther)
 96+			return
 97+		}
 98+		h(w, r, who)
 99+	}
100+}
101+
102+// owner guards state-changing form posts: the owner's session and its CSRF
103+// token.
104+func (s *Server) owner(h func(http.ResponseWriter, *http.Request, Who)) http.HandlerFunc {
105+	return func(w http.ResponseWriter, r *http.Request) {
106+		who := s.Auth.Who(r)
107+		if !who.CanApprove() {
108+			http.Error(w, "forbidden", http.StatusForbidden)
109+			return
110+		}
111+		if !s.Auth.CheckCSRF(r) {
112+			http.Error(w, "bad form token; reload the page", http.StatusForbidden)
113+			return
114+		}
115+		h(w, r, who)
116+	}
117+}
118+
119+func (s *Server) api(h func(http.ResponseWriter, *http.Request, Who)) http.HandlerFunc {
120+	return func(w http.ResponseWriter, r *http.Request) {
121+		who := s.Auth.Who(r)
122+		if !who.CanRead() {
123+			apiError(w, http.StatusUnauthorized, "unauthorized")
124+			return
125+		}
126+		h(w, r, who)
127+	}
128+}
129+
130+func apiError(w http.ResponseWriter, code int, msg string) {
131+	w.Header().Set("Content-Type", "application/json")
132+	w.WriteHeader(code)
133+	_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
134+}
135+
136+func apiJSON(w http.ResponseWriter, v any) {
137+	w.Header().Set("Content-Type", "application/json")
138+	_ = json.NewEncoder(w).Encode(v)
139+}
140+
141+func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
142+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
143+	if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
144+		s.Logger.Error("render", "template", name, "err", err)
145+	}
146+}
147+
148+func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
149+	s.render(w, "login.html", map[string]any{"Next": safeNext(r.URL.Query().Get("next")), "Error": ""})
150+}
151+
152+func (s *Server) login(w http.ResponseWriter, r *http.Request) {
153+	next := safeNext(r.FormValue("next"))
154+	if !s.Auth.CheckPassword(r.FormValue("password")) {
155+		time.Sleep(500 * time.Millisecond)
156+		w.WriteHeader(http.StatusUnauthorized)
157+		s.render(w, "login.html", map[string]any{"Next": next, "Error": "Wrong password."})
158+		return
159+	}
160+	s.Auth.Login(w)
161+	http.Redirect(w, r, next, http.StatusSeeOther)
162+}
163+
164+func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
165+	if s.Auth.CheckCSRF(r) {
166+		s.Auth.Logout(w)
167+	}
168+	http.Redirect(w, r, "/login", http.StatusSeeOther)
169+}
170+
171+// safeNext keeps redirects on this site.
172+func safeNext(n string) string {
173+	if !strings.HasPrefix(n, "/") || strings.HasPrefix(n, "//") || strings.Contains(n, "\\") {
174+		return "/"
175+	}
176+	return n
177+}
178+
179+func (s *Server) runsPage(w http.ResponseWriter, r *http.Request, who Who) {
180+	runs, err := s.Runner.Runs(r.Context(), r.URL.Query().Get("repo"), 100)
181+	if err != nil {
182+		http.Error(w, err.Error(), http.StatusBadGateway)
183+		return
184+	}
185+	var waiting, rest []runner.RunView
186+	repos := map[string][]string{}
187+	for _, x := range runs {
188+		if x.Status == engine.StatusPaused {
189+			waiting = append(waiting, x)
190+		} else {
191+			rest = append(rest, x)
192+		}
193+		if !slices.Contains(repos[x.Repo], x.Workflow) {
194+			repos[x.Repo] = append(repos[x.Repo], x.Workflow)
195+		}
196+	}
197+	s.render(w, "runs.html", map[string]any{
198+		"Waiting": waiting, "Runs": rest, "Repos": repos, "Repo": r.URL.Query().Get("repo"), "CSRF": s.Auth.CSRF(r),
199+	})
200+}
201+
202+// timelineEntry is an action execution with its result summary.
203+type timelineEntry struct {
204+	engine.Exec
205+	Summary     string
206+	Attachments []lex.Attachment
207+}
208+
209+func (s *Server) runPage(w http.ResponseWriter, r *http.Request, who Who) {
210+	id := r.PathValue("id")
211+	v, err := s.Runner.Run(r.Context(), id)
212+	if err != nil {
213+		http.Error(w, err.Error(), http.StatusNotFound)
214+		return
215+	}
216+	evs, err := s.Runner.Events(r.Context(), id, 0)
217+	if err != nil {
218+		http.Error(w, err.Error(), http.StatusBadGateway)
219+		return
220+	}
221+	results := map[int]*lex.Result{}
222+	var lines []logLine
223+	last := 0
224+	for _, ev := range evs {
225+		if ev.Kind == lex.KindResult && ev.Result != nil {
226+			results[ev.Attempt] = ev.Result
227+		}
228+		if l, ok := renderLine(ev); ok {
229+			lines = append(lines, l)
230+		}
231+		last = ev.Seq
232+	}
233+	var timeline []timelineEntry
234+	if v.State != nil {
235+		for _, x := range v.State.History {
236+			t := timelineEntry{Exec: x}
237+			if res := results[x.N]; res != nil {
238+				t.Summary, t.Attachments = res.Summary, res.Attachments
239+			}
240+			timeline = append(timeline, t)
241+		}
242+	}
243+	s.render(w, "run.html", map[string]any{
244+		"Run": v, "Timeline": timeline, "Lines": lines, "Last": last, "CSRF": s.Auth.CSRF(r),
245+		"Live": v.State != nil && (v.State.Status == engine.StatusRunning || v.Status == lex.StatusPending),
246+	})
247+}
248+
249+// logLine is a rendered event for the log view.
250+type logLine struct {
251+	Class string `json:"class"`
252+	Text  string `json:"text"`
253+}
254+
255+func renderLine(ev lex.RunEvent) (logLine, bool) {
256+	switch ev.Kind {
257+	case lex.KindStart:
258+		return logLine{"start", fmt.Sprintf("▶ %s  %s", ev.Action, firstLine(ev.Command))}, true
259+	case lex.KindLog:
260+		if ev.Stream == "stderr" {
261+			return logLine{"stderr", ev.Content}, true
262+		}
263+		return logLine{"stdout", ev.Content}, true
264+	case lex.KindEnd:
265+		return logLine{"end " + ev.Status, fmt.Sprintf("%s %s: %s", mark(ev.Status), ev.Action, ev.Status)}, true
266+	case lex.KindGate:
267+		if ev.Gate != nil {
268+			t := "⏸ gate " + ev.Gate.State
269+			if ev.Gate.Choice != "" {
270+				t += ": " + ev.Gate.Choice
271+			}
272+			if ev.Gate.Actor != "" {
273+				t += " by " + ev.Gate.Actor
274+			}
275+			return logLine{"gate", t}, true
276+		}
277+	case lex.KindRun:
278+		if ev.Status != engine.StatusRunning {
279+			return logLine{"run " + ev.Status, fmt.Sprintf("■ %s %s", ev.Status, ev.Content)}, true
280+		}
281+	}
282+	return logLine{}, false
283+}
284+
285+func mark(status string) string {
286+	switch status {
287+	case lex.Failure:
288+		return "✘"
289+	case lex.Warning:
290+		return "!"
291+	}
292+	return "✔"
293+}
294+
295+func firstLine(s string) string {
296+	s = strings.TrimSpace(s)
297+	if i := strings.IndexByte(s, '\n'); i >= 0 {
298+		return s[:i] + " …"
299+	}
300+	return s
301+}
302+
303+// live streams new log lines as server-sent events until the run stops
304+// running; the page reloads then to show the gate or the result.
305+func (s *Server) live(w http.ResponseWriter, r *http.Request, who Who) {
306+	id := r.PathValue("id")
307+	after, _ := strconv.Atoi(r.URL.Query().Get("after"))
308+	fl, ok := w.(http.Flusher)
309+	if !ok {
310+		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
311+		return
312+	}
313+	w.Header().Set("Content-Type", "text/event-stream")
314+	w.Header().Set("Cache-Control", "no-cache")
315+	ctx := r.Context()
316+	for {
317+		evs, err := s.Runner.Events(ctx, id, after)
318+		if err != nil {
319+			return
320+		}
321+		for _, ev := range evs {
322+			after = ev.Seq
323+			if l, ok := renderLine(ev); ok {
324+				b, _ := json.Marshal(l)
325+				fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.Seq, b)
326+			}
327+		}
328+		fl.Flush()
329+		if len(evs) == 0 {
330+			v, err := s.Runner.Run(ctx, id)
331+			if err != nil || v.State == nil || v.State.Status != engine.StatusRunning {
332+				fmt.Fprint(w, "event: end\ndata: {}\n\n")
333+				fl.Flush()
334+				return
335+			}
336+		}
337+		select {
338+		case <-ctx.Done():
339+			return
340+		case <-time.After(time.Second):
341+		}
342+	}
343+}
344+
345+func (s *Server) file(w http.ResponseWriter, r *http.Request, who Who) {
346+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
347+	if err := s.Runner.File(r.Context(), r.PathValue("id"), r.PathValue("path"), w); err != nil {
348+		http.Error(w, err.Error(), http.StatusNotFound)
349+	}
350+}
351+
352+func (s *Server) back(w http.ResponseWriter, r *http.Request, id string) {
353+	http.Redirect(w, r, "/runs/"+url.PathEscape(id), http.StatusSeeOther)
354+}
355+
356+func (s *Server) gate(w http.ResponseWriter, r *http.Request, who Who) {
357+	id := r.PathValue("id")
358+	option, _ := strconv.Atoi(r.FormValue("option"))
359+	dismiss := r.FormValue("dismiss") != ""
360+	if err := s.Runner.Gate(r.Context(), id, option, dismiss, who.Actor(s.OwnerDid)); err != nil {
361+		http.Error(w, err.Error(), http.StatusConflict)
362+		return
363+	}
364+	waitBriefly(r.Context())
365+	s.back(w, r, id)
366+}
367+
368+func (s *Server) action(w http.ResponseWriter, r *http.Request, who Who) {
369+	child, err := s.Runner.Action(r.Context(), r.PathValue("id"), r.PathValue("action"), who.Actor(s.OwnerDid))
370+	if err != nil {
371+		http.Error(w, err.Error(), http.StatusConflict)
372+		return
373+	}
374+	waitBriefly(r.Context())
375+	s.back(w, r, child)
376+}
377+
378+func (s *Server) cancel(w http.ResponseWriter, r *http.Request, who Who) {
379+	id := r.PathValue("id")
380+	if err := s.Runner.Cancel(r.Context(), id); err != nil {
381+		http.Error(w, err.Error(), http.StatusConflict)
382+		return
383+	}
384+	waitBriefly(r.Context())
385+	s.back(w, r, id)
386+}
387+
388+func (s *Server) trigger(w http.ResponseWriter, r *http.Request, who Who) {
389+	repo, workflow, rev := r.FormValue("repo"), r.FormValue("workflow"), strings.TrimSpace(r.FormValue("ref"))
390+	if repo == "" { // without JavaScript, the select's "repo|workflow"
391+		repo, workflow, _ = strings.Cut(r.FormValue("target"), "|")
392+	}
393+	if rev == "" {
394+		rev = "main"
395+	}
396+	var wfs []string
397+	if workflow != "" {
398+		wfs = []string{workflow}
399+	}
400+	if _, err := s.Runner.Trigger(r.Context(), ident.RepoDID(s.Knot, repo), rev, wfs); err != nil {
401+		http.Error(w, err.Error(), http.StatusBadRequest)
402+		return
403+	}
404+	waitBriefly(r.Context())
405+	http.Redirect(w, r, "/?repo="+urlEscape(repo), http.StatusSeeOther)
406+}
407+
408+// waitBriefly gives the runner a moment to start a segment, so the page
409+// the person lands on already shows it.
410+func waitBriefly(ctx context.Context) {
411+	select {
412+	case <-ctx.Done():
413+	case <-time.After(700 * time.Millisecond):
414+	}
415+}
416+
417+func (s *Server) apiRuns(w http.ResponseWriter, r *http.Request, who Who) {
418+	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
419+	if limit <= 0 || limit > 500 {
420+		limit = 50
421+	}
422+	runs, err := s.Runner.Runs(r.Context(), r.URL.Query().Get("repo"), limit)
423+	if err != nil {
424+		apiError(w, http.StatusBadGateway, err.Error())
425+		return
426+	}
427+	apiJSON(w, runs)
428+}
429+
430+func (s *Server) apiRun(w http.ResponseWriter, r *http.Request, who Who) {
431+	v, err := s.Runner.Run(r.Context(), r.PathValue("id"))
432+	if err != nil {
433+		apiError(w, http.StatusNotFound, err.Error())
434+		return
435+	}
436+	apiJSON(w, v)
437+}
438+
439+func (s *Server) apiText(w http.ResponseWriter, r *http.Request, who Who) {
440+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
441+	if err := s.Runner.Text(r.Context(), r.PathValue("id"), w); err != nil {
442+		http.Error(w, err.Error(), http.StatusNotFound)
443+	}
444+}
445+
446+func (s *Server) apiTrigger(w http.ResponseWriter, r *http.Request, who Who) {
447+	var in struct {
448+		Repo     string `json:"repo"`
449+		Workflow string `json:"workflow"`
450+		Ref      string `json:"ref"`
451+	}
452+	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil || in.Repo == "" {
453+		apiError(w, http.StatusBadRequest, "want {repo, workflow?, ref?}")
454+		return
455+	}
456+	if in.Ref == "" {
457+		in.Ref = "main"
458+	}
459+	var wfs []string
460+	if in.Workflow != "" {
461+		wfs = []string{in.Workflow}
462+	}
463+	p, err := s.Runner.Trigger(r.Context(), ident.RepoDID(s.Knot, in.Repo), in.Ref, wfs)
464+	if err != nil {
465+		apiError(w, http.StatusBadRequest, err.Error())
466+		return
467+	}
468+	apiJSON(w, map[string]string{"pipeline": p})
469+}
470+
471+// apiAction lets agents run manual actions, except danger ones.
472+func (s *Server) apiAction(w http.ResponseWriter, r *http.Request, who Who) {
473+	id, action := r.PathValue("id"), r.PathValue("action")
474+	if !who.Owner {
475+		v, err := s.Runner.Run(r.Context(), id)
476+		if err != nil {
477+			apiError(w, http.StatusNotFound, err.Error())
478+			return
479+		}
480+		if slices.Contains(v.Danger, action) {
481+			apiError(w, http.StatusForbidden, action+" is a danger action: it needs the owner in the appview")
482+			return
483+		}
484+	}
485+	child, err := s.Runner.Action(r.Context(), id, action, who.Actor(s.OwnerDid))
486+	if err != nil {
487+		apiError(w, http.StatusConflict, err.Error())
488+		return
489+	}
490+	apiJSON(w, map[string]string{"run": child})
491+}
492+
493+func urlEscape(s string) string { return url.QueryEscape(s) }
494+
495+var funcs = template.FuncMap{
496+	"short": func(s string) string {
497+		if len(s) > 12 {
498+			return s[:12]
499+		}
500+		return s
501+	},
502+	"ago": func(ts string) string {
503+		t, err := time.Parse(time.RFC3339Nano, ts)
504+		if err != nil {
505+			return ts
506+		}
507+		d := time.Since(t).Round(time.Second)
508+		switch {
509+		case d < time.Minute:
510+			return fmt.Sprintf("%ds ago", int(d.Seconds()))
511+		case d < time.Hour:
512+			return fmt.Sprintf("%dm ago", int(d.Minutes()))
513+		case d < 48*time.Hour:
514+			return fmt.Sprintf("%dh ago", int(d.Hours()))
515+		}
516+		return t.Format("2006-01-02")
517+	},
518+	"inc":    func(i int) int { return i + 1 },
519+	"label":  func(o lex.Option, v runner.RunView) string { return optionLabel(o, v) },
520+	"danger": func(o lex.Option, v runner.RunView) bool { return slices.Contains(v.Danger, o.Action) },
521+}
522+
523+func optionLabel(o lex.Option, v runner.RunView) string {
524+	if o.Label != "" {
525+		return o.Label
526+	}
527+	for _, m := range v.Manual {
528+		if m.Name == o.Action {
529+			return m.Label
530+		}
531+	}
532+	return o.Action
533+}
A internal/appview/static/app.js
+32, -0
 1@@ -0,0 +1,32 @@
 2+// Confirm danger buttons, split the trigger form's select, and stream the log.
 3+document.addEventListener("submit", (e) => {
 4+  const q = e.target.dataset.confirm;
 5+  if (q && !confirm(q)) e.preventDefault();
 6+});
 7+document.addEventListener("DOMContentLoaded", () => {
 8+  const target = document.getElementById("target");
 9+  if (target) {
10+    const sync = () => {
11+      const [repo, wf] = target.value.split("|");
12+      document.getElementById("trigger-repo").value = repo;
13+      document.getElementById("trigger-workflow").value = wf;
14+    };
15+    target.addEventListener("change", sync);
16+    sync();
17+  }
18+  const log = document.getElementById("log");
19+  if (log && log.dataset.live && window.EventSource) {
20+    const atBottom = () => window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
21+    const es = new EventSource(log.dataset.live);
22+    es.onmessage = (m) => {
23+      const l = JSON.parse(m.data);
24+      const follow = atBottom();
25+      const span = document.createElement("span");
26+      span.className = "l " + l.class;
27+      span.textContent = l.text;
28+      log.append(span, "\n");
29+      if (follow) window.scrollTo(0, document.body.scrollHeight);
30+    };
31+    es.addEventListener("end", () => { es.close(); location.reload(); });
32+  }
33+});
A internal/appview/static/style.css
+48, -0
 1@@ -0,0 +1,48 @@
 2+:root {
 3+  --bg: #fafaf9; --fg: #1c1917; --muted: #78716c; --card: #fff; --line: #e7e5e4;
 4+  --ok: #15803d; --warn: #b45309; --bad: #b91c1c; --run: #1d4ed8; --wait: #a16207;
 5+  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
 6+}
 7+@media (prefers-color-scheme: dark) {
 8+  :root { --bg: #0c0a09; --fg: #e7e5e4; --muted: #a8a29e; --card: #1c1917; --line: #292524;
 9+    --ok: #4ade80; --warn: #fbbf24; --bad: #f87171; --run: #60a5fa; --wait: #facc15; }
10+}
11+* { box-sizing: border-box; }
12+body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.45 system-ui, sans-serif; }
13+main { max-width: 56rem; margin: 0 auto; padding: 0 16px 48px; }
14+a { color: inherit; }
15+.bar { padding: 12px 16px; border-bottom: 1px solid var(--line); }
16+.bar .home { font-weight: 700; text-decoration: none; }
17+h1 { font-size: 1.25rem; margin: 0 0 .25rem; } h2 { font-size: 1.05rem; margin: 1.2rem 0 .5rem; }
18+.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 14px 0; }
19+.card h2 { margin-top: 0; }
20+.muted, .meta { color: var(--muted); } .small { font-size: .85rem; }
21+.runs { list-style: none; padding: 0; margin: 0; }
22+.run a { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; padding: 10px 12px; margin: 6px 0;
23+  background: var(--card); border: 1px solid var(--line); border-radius: 10px; text-decoration: none; }
24+.run .what { font-weight: 600; overflow-wrap: anywhere; } .run .meta, .run .prompt { grid-column: 1 / -1; font-size: .85rem; }
25+.run .prompt { color: var(--wait); }
26+.badge { font-size: .75rem; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; white-space: nowrap; align-self: start; }
27+.s-success { color: var(--ok); } .s-failure, .s-failed { color: var(--bad); } .s-warning { color: var(--warn); }
28+.s-running, .s-pending { color: var(--run); } .s-paused { color: var(--wait); } .s-cancelled { color: var(--muted); }
29+.end { margin: .4rem 0 0; }
30+pre { font-family: var(--mono); font-size: .8rem; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; margin: .4rem 0; }
31+.summary { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
32+.log { max-height: 70vh; overflow-y: auto; background: var(--bg); padding: 8px; border-radius: 6px; }
33+.log .start { font-weight: 700; color: var(--run); } .log .stderr { color: var(--warn); }
34+.log .end.success { color: var(--ok); } .log .end.failure { color: var(--bad); } .log .gate { color: var(--wait); font-weight: 600; }
35+.live { color: var(--run); font-size: .8rem; }
36+.timeline { padding-left: 1.2rem; margin: 0; } .timeline li { margin: .35rem 0; } .timeline .what { font-weight: 600; }
37+.choices, .actions { display: flex; flex-direction: column; gap: 10px; }
38+.choices form, .actions form { display: flex; flex-direction: column; gap: 4px; }
39+button { font: inherit; border-radius: 8px; padding: 8px 14px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; }
40+button.big { padding: 14px; font-size: 1.05rem; font-weight: 700; width: 100%; }
41+button.primary { background: var(--run); border-color: var(--run); color: #fff; }
42+button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
43+button.quiet { background: transparent; color: var(--muted); }
44+button.link { background: none; border: none; color: var(--muted); text-decoration: underline; padding: 0; }
45+.login { max-width: 22rem; margin: 15vh auto; display: flex; flex-direction: column; gap: 12px; }
46+label { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
47+input, select { font: inherit; padding: 8px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); }
48+.trigger { display: flex; flex-direction: column; gap: 10px; }
49+.error { color: var(--bad); } .cancel, .logout { margin-top: 1rem; }
A internal/appview/templates/layout.html
+26, -0
 1@@ -0,0 +1,26 @@
 2+{{define "head"}}<!doctype html>
 3+<html lang="en">
 4+<head>
 5+<meta charset="utf-8">
 6+<meta name="viewport" content="width=device-width, initial-scale=1">
 7+<title>{{.}} · ci</title>
 8+<link rel="stylesheet" href="/static/style.css">
 9+<script src="/static/app.js" defer></script>
10+</head>
11+<body>
12+<header class="bar"><a class="home" href="/">ci</a></header>
13+<main>
14+{{end}}
15+{{define "foot"}}</main>
16+</body>
17+</html>
18+{{end}}
19+{{define "badge"}}<span class="badge s-{{.}}">{{.}}</span>{{end}}
20+{{define "runrow"}}<li class="run">
21+  <a href="/runs/{{.ID}}">
22+    <span class="what">{{.Repo}} / {{.Workflow}}{{if .Parent}} <em>manual</em>{{end}}</span>
23+    {{template "badge" .Status}}
24+    <span class="meta">{{if .State}}{{short .State.Meta.Sha}}{{if .State.Meta.Ref}} · {{.State.Meta.Ref}}{{end}} · {{end}}{{ago .Created}}</span>
25+    {{if and .State .State.Gate}}<span class="prompt">⏸ {{.State.Gate.Prompt}}</span>{{end}}
26+  </a>
27+</li>{{end}}
A internal/appview/templates/login.html
+9, -0
 1@@ -0,0 +1,9 @@
 2+{{template "head" "Sign in"}}
 3+<form class="card login" method="post" action="/login">
 4+  <h1>Sign in</h1>
 5+  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
 6+  <input type="hidden" name="next" value="{{.Next}}">
 7+  <label>Password <input type="password" name="password" autocomplete="current-password" autofocus required></label>
 8+  <button type="submit">Sign in</button>
 9+</form>
10+{{template "foot"}}
A internal/appview/templates/run.html
+74, -0
 1@@ -0,0 +1,74 @@
 2+{{template "head" (printf "%s / %s" .Run.Repo .Run.Workflow)}}
 3+{{$v := .Run}}{{$csrf := .CSRF}}
 4+<section class="card head">
 5+  <h1>{{$v.Repo}} / {{$v.Workflow}} {{template "badge" $v.Status}}</h1>
 6+  {{with $v.State}}
 7+  <p class="meta">
 8+    {{if .Meta.Sha}}<code>{{short .Meta.Sha}}</code>{{end}}{{if .Meta.Ref}} · {{.Meta.Ref}}{{end}} · {{.Meta.Kind}} · {{ago .Created}}
 9+    {{if $v.Parent}}<br>manual run for <a href="/runs/{{$v.Parent}}">{{$v.Parent}}</a>{{end}}
10+  </p>
11+  {{with .End}}<p class="end s-{{.Status}}">{{.Detail}}</p>{{end}}
12+  {{end}}
13+</section>
14+
15+{{with $v.State}}{{with .Gate}}
16+<section class="card gate">
17+  <h2>⏸ {{if .Prompt}}{{.Prompt}}{{else}}Choose the next step{{end}}</h2>
18+  {{if .Summary}}<pre class="summary">{{.Summary}}</pre>{{end}}
19+  <p class="muted small">Expires {{.ExpiresAt}}</p>
20+  <div class="choices">
21+  {{range $i, $o := .Options}}
22+    <form method="post" action="/runs/{{$v.ID}}/gate" {{if danger $o $v}}data-confirm="{{label $o $v}}?"{{end}}>
23+      <input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="option" value="{{inc $i}}">
24+      <button class="{{if danger $o $v}}danger big{{else}}primary big{{end}}">{{label $o $v}}</button>
25+      {{if and $o.Prompt (gt (len $.Run.State.Gate.Options) 1)}}<span class="small muted">{{$o.Prompt}}</span>{{end}}
26+    </form>
27+  {{end}}
28+    <form method="post" action="/runs/{{$v.ID}}/gate">
29+      <input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="dismiss" value="1">
30+      <button class="quiet">Dismiss</button>
31+    </form>
32+  </div>
33+</section>
34+{{end}}{{end}}
35+
36+{{if .Timeline}}
37+<section class="card">
38+  <h2>Steps</h2>
39+  <ol class="timeline">
40+  {{range .Timeline}}
41+    <li class="s-{{.Status}}">
42+      <span class="what">{{.Action}}</span> {{template "badge" .Status}}{{if .Stubbed}} <em>stubbed</em>{{end}}
43+      {{if .Summary}}<pre class="summary">{{.Summary}}</pre>{{end}}
44+      {{range .Attachments}}<a class="small" href="/runs/{{$v.ID}}/files/artifacts/{{.Path}}">📎 {{if .Label}}{{.Label}}{{else}}{{.Path}}{{end}}</a> {{end}}
45+    </li>
46+  {{end}}
47+  </ol>
48+</section>
49+{{end}}
50+
51+{{if $v.Manual}}
52+<section class="card actions">
53+  <h2>Actions</h2>
54+  {{range $v.Manual}}
55+  <form method="post" action="/runs/{{$v.ID}}/actions/{{.Name}}" data-confirm="{{.Label}}?">
56+    <input type="hidden" name="csrf" value="{{$csrf}}">
57+    <button class="{{if eq .Style "danger"}}danger big{{else}}primary{{end}}">{{.Label}}</button>
58+  </form>
59+  {{end}}
60+</section>
61+{{end}}
62+
63+<section class="card">
64+  <h2>Log {{if .Live}}<span class="live">● live</span>{{end}}</h2>
65+  <pre class="log" id="log" {{if .Live}}data-live="/runs/{{$v.ID}}/live?after={{.Last}}"{{end}}>{{range .Lines}}<span class="l {{.Class}}">{{.Text}}</span>
66+{{end}}</pre>
67+  <p class="small"><a href="/api/runs/{{$v.ID}}/log.txt">plain text</a></p>
68+</section>
69+
70+{{if and $v.State (or (eq $v.State.Status "running") (eq $v.State.Status "paused"))}}
71+<form method="post" action="/runs/{{$v.ID}}/cancel" data-confirm="Cancel this run?" class="cancel">
72+  <input type="hidden" name="csrf" value="{{$csrf}}"><button class="quiet">Cancel run</button>
73+</form>
74+{{end}}
75+{{template "foot"}}
A internal/appview/templates/runs.html
+30, -0
 1@@ -0,0 +1,30 @@
 2+{{template "head" "Runs"}}
 3+{{if .Waiting}}
 4+<section>
 5+  <h2>Needs you</h2>
 6+  <ul class="runs">{{range .Waiting}}{{template "runrow" .}}{{end}}</ul>
 7+</section>
 8+{{end}}
 9+<section>
10+  <h2>Runs{{if .Repo}} · {{.Repo}} <a class="small" href="/">all</a>{{end}}</h2>
11+  {{if .Runs}}<ul class="runs">{{range .Runs}}{{template "runrow" .}}{{end}}</ul>{{else}}<p class="muted">No runs yet.</p>{{end}}
12+</section>
13+{{if .Repos}}
14+<section>
15+  <h2>Run a workflow</h2>
16+  <form class="card trigger" method="post" action="/trigger">
17+    <input type="hidden" name="csrf" value="{{.CSRF}}">
18+    <label>Repository and workflow
19+      <select name="target" id="target" required>
20+        {{range $repo, $wfs := .Repos}}{{range $wfs}}<option value="{{$repo}}|{{.}}">{{$repo}} / {{.}}</option>{{end}}{{end}}
21+      </select>
22+    </label>
23+    <input type="hidden" name="repo" id="trigger-repo">
24+    <input type="hidden" name="workflow" id="trigger-workflow">
25+    <label>Branch or commit <input name="ref" placeholder="main"></label>
26+    <button type="submit">Run</button>
27+  </form>
28+</section>
29+{{end}}
30+<form method="post" action="/logout" class="logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Sign out</button></form>
31+{{template "foot"}}
M internal/runner/api.go
+26, -0
 1@@ -7,9 +7,11 @@ import (
 2 	"errors"
 3 	"fmt"
 4 	"io"
 5+	"maps"
 6 	"net/http"
 7 	"os"
 8 	"path/filepath"
 9+	"slices"
10 	"strings"
11 	"time"
12 
13@@ -78,12 +80,36 @@ func fail(w http.ResponseWriter, code int, err error) {
14 type RunView struct {
15 	Run
16 	State *engine.State `json:"state,omitempty"`
17+	// Manual are the workflow's manual actions (buttons on the run page).
18+	Manual []ManualAction `json:"manual,omitempty"`
19+	// Danger lists every action with style danger (gate options too).
20+	Danger []string `json:"danger,omitempty"`
21+}
22+
23+// ManualAction is a manual action of a run's workflow.
24+type ManualAction struct {
25+	Name  string `json:"name"`
26+	Label string `json:"label"`
27+	Style string `json:"style,omitempty"`
28+	Gated bool   `json:"gated,omitempty"`
29 }
30 
31 func (r *Runner) view(x Run) RunView {
32 	v := RunView{Run: x}
33 	if st, err := engine.Load(engine.Dir(x.Dir)); err == nil {
34 		v.State = st
35+		if raw, err := os.ReadFile(engine.Dir(x.Dir).ManifestFile()); err == nil {
36+			if m, err := manifest.Parse(st.Meta.Workflow, raw); err == nil {
37+				for _, name := range slices.Sorted(maps.Keys(m.Actions)) {
38+					if m.Actions[name].Style == "danger" {
39+						v.Danger = append(v.Danger, name)
40+					}
41+					if a := m.Actions[name]; a.Manual {
42+						v.Manual = append(v.Manual, ManualAction{Name: name, Label: m.Label(name), Style: a.Style, Gated: a.Gate != nil})
43+					}
44+				}
45+			}
46+		}
47 	}
48 	return v
49 }