+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.
+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+}
+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...)
+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-}
+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=
+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+}
+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+}
+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+}
+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+}
+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+});
+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; }
+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}}
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"}}
+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"}}
+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"}}
+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 }