Commit f84f577ef1

f84f577ef19cd21feafec3f5429ca73c8ce45589

parent: c5a2a4cc37

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-23 05:24 UTC

spec: build log --follow

Ref #250
docs/specs/2026-09-23-build-log-follow-design.md added +122
@@ -0,0 +1,122 @@
1# Following a running build
2
3Closes #250. `build log <owner/name> <n> --follow` streams a build's log
4until the build reaches an outcome, and the web build page streams the
5same command without JavaScript.
6
7## Problem
8
9No surface can follow a running build. `build log` prints what is stored
10and exits; the build page renders the log once. Watching a build means
11re-running the command or reloading the page.
12
13## Decision
14
15The capability is a flag on the existing control command. The web page
16dispatches that command with a writer that escapes and flushes each
17chunk, so the CLI, stock ssh and the page share one implementation.
18
19Rejected: a `Refresh` header on the build page. It re-renders the page
20and re-reads the whole log per reload per viewer, interrupts selection
21and screen readers, needs an off switch for WCAG 2.2.1, and gives only
22the web a way to follow.
23
24## Store: waking followers
25
26`Store` gains an in-memory waiter table keyed by build id:
27
28- `BuildLogWait(id int64) <-chan struct{}` returns a channel closed by
29 the next change to that build.
30- `AppendBuildLog`, `FinishBuild` and `CancelBuild` close and drop the
31 build's channel after their write succeeds (`wakeBuild(id)`).
32- `BuildLogFrom(id, offset int64) (status string, chunk []byte, err
33 error)` reads `status` and `substr(log, offset+1)` in one query, so a
34 follower reads each byte once.
35
36gitbayd is one process and its SSH and HTTP servers share one
37`*store.Store`, so an in-memory table reaches every follower. Writers in
38another process (`gitbayd admin` subcommands) do not wake anyone; the
39follow loop also re-reads every 10 seconds, which bounds that case.
40
41## Command
42
43`build log <owner/name> <n> [--follow]`, still `ReadOnly`.
44
45Without `--follow`, unchanged.
46
47With `--follow`:
48
491. Write the stored log.
502. Loop: take a wait channel, read from the offset, write any new bytes.
51 If the status is no longer `pending` or `running` and the read
52 returned nothing new, stop. Otherwise wait on the channel, the
53 10-second timer, or `Ctx.Done`.
543. Write `build <n> <status>` to stderr and exit 0, whatever the
55 outcome. Stdout stays the log, byte for byte.
56
57The wait channel is taken before the read, so a change between the read
58and the wait still wakes the loop.
59
60`Ctx` gains `Done <-chan struct{}`, nil when the surface has none. sshd
61sets it from the session's context and httpd from `r.Context()`. A write
62error also ends the loop. On `Done` the command returns
63`protocol.ExitFailure` with no message; nobody is reading.
64
65At most 8 follows per account run at once (a counter in `control`,
66decremented on return). The ninth exits 4: "8 follows are already open
67for this account; close one and retry".
68
69The CLI's `pass("log", …)` help in `cmd/gitbay/main.go` names
70`--follow`.
71
72## Web
73
74`GET /{owner}/{repo}/builds/{n}` streams when the build is `pending` or
75`running` and the query has no `follow=0`. Otherwise it renders as now.
76
77Streaming:
78
791. Render `build.html` into a buffer with `Log` set to a marker and
80 `Live` true, and split the output at the marker.
812. Write the head, flush.
823. Dispatch `build log <repo> <n> --follow` with `Stdout` an escaping
83 writer (`template.HTMLEscape` per chunk, then flush through
84 `http.ResponseController`) and `Done` from the request context.
854. After the command returns, read the build and write
86 `<p class="notice" role="status">build finished: <status></p>` after
87 the `</pre>` that begins the tail, then the rest of the tail. A
88 dropped connection writes nothing more.
89
90`build.html`, when `Live`, puts a line above the log: the log streams
91until the build ends; a stream that stops with no "build finished" line
92resumes on reload; and a link to `?follow=0`, the same page rendered
93once, as the way to stop the updates (WCAG 2.2.2). The stored log
94renders inside the stream from the first write, so there is no separate
95"no log yet" state while live.
96
97`gzipWriter` gains `Flush()` (flush the gzip stream, then the underlying
98writer) and `Unwrap()`. The HTTP server has no `WriteTimeout`, so a long
99silent step does not end the response. The handler sets
100`X-Accel-Buffering: no` for a proxy in front of the instance.
101
102## API
103
104`/api/v1/cmd` and `/api/v1/read` buffer stdout, so there `--follow`
105returns the whole log when the build ends. Streaming a JSON response is
106out of scope.
107
108## Tests
109
110- `internal/store`: `BuildLogWait` closes on append, finish and cancel;
111 `BuildLogFrom` returns bytes past the offset and the status.
112- `internal/control`: follow a running build while another goroutine
113 appends and finishes; stdout is the full log, stderr ends with the
114 outcome, exit 0. Cancel ends a follow. A closed `Done` ends a follow.
115 The ninth concurrent follow exits 4.
116- `internal/httpd`: `gzipWriter` passes a flush through.
117- `e2e`: ssh `build log --follow` on a running build while the runner
118 key appends with `runner log` and reports with `runner done`; the web
119 page of a running build arrives complete with "build finished:
120 success"; `?follow=0` renders without the live line.
121
122Docs: the CI wiki page and Parity get the flag.