A build says what it does on the bus, as it happens (novox/hq ADR 0157)

The build-machine seat emits `started` and `log.<build id>` beside `built`. Every line the builder
speaks — each step, each command with its duration, and on failure the command's own output — goes
to stderr as before and onto the bus under the build's id, one subject per build, kept a week in
EVENTS with every other event. `builds --log <id>` reads it back from the stream with a consumer
that is gone when the reading is done, on the command line and as the controller's seat verb;
`builds` lists each build's id and `build` says the id it asked with.

Lines are core publishes with a sequence number, so a build is not slowed by an ack per line and a
gap is visible; `started` and `built` are awaited into the stream. The seat protocol widens
additively at the controller's next start; the holder's grant follows on the broker node's next
composition.
This commit is contained in:
2026-10-01 00:43:07 +02:00
parent f6685ed22d
commit 17f7cb0d9c
12 changed files with 259 additions and 19 deletions
+41
View File
@@ -27,6 +27,40 @@ const TheBuildMachine = "mesh-build-machine"
func BuildWork() string { return "mesh.seat." + TheBuildMachine + ".accept.build" }
func BuildOutcome() string { return "mesh.seat." + TheBuildMachine + ".event.built" }
// BuildStarted is where a build machine says it has taken a build, and BuildLog is where it says
// what it is doing, one line per message, under the build's own id (novox/hq ADR 0157).
//
// **The whole build is on the bus as it happens.** The outcome alone told a person that a build
// failed and its first line why; everything between — which command, how long, where it hung —
// lived in one container's stderr on one machine. Every line is now an event of the role, retained
// with the rest of the mesh's events, so a reader follows a build live by subscribing its subject,
// or reads it back afterwards from the stream, and a viewer is a subscriber and nothing more.
func BuildStarted() string { return "mesh.seat." + TheBuildMachine + ".event.started" }
func BuildLog(id string) string { return "mesh.seat." + TheBuildMachine + ".event.log." + id }
// BuildStart is what a build machine says the moment it takes a build.
type BuildStart struct {
ID string `json:"id"`
Repository string `json:"repository"`
Path string `json:"path,omitempty"`
Ref string `json:"ref,omitempty"`
On string `json:"on"`
At string `json:"at"`
}
// BuildLine is one thing a build said while building.
type BuildLine struct {
ID string `json:"id"`
// Seq counts the lines of one build from 1, so a reader that joined late or read two copies
// can order them and see a gap.
Seq int `json:"seq"`
At string `json:"at"`
// Step is which part of the build spoke — clone, context, image, run, failed — and Message is
// what it said, as the builder's own log prints it.
Step string `json:"step"`
Message string `json:"message"`
}
// KeyRoleBuilt is the build outcome under the role's name, on the bus the mesh runs on today.
//
// The same event as KeyModuleBuilt and published beside it, because a catalogue installed before this
@@ -57,6 +91,13 @@ type BuildMachine interface {
// Build is one request a machine has been handed.
type Build interface {
// Began says the build has been taken and is under way, before anything runs.
Began(ctx context.Context) error
// Say publishes one line of what the build is doing. Never fails the build: a line the bus
// did not take is a line lost, and the outcome still comes.
Say(step, message string)
// Request is what to build.
Request() BuildRequest
+34
View File
@@ -169,6 +169,7 @@ type natsBuild struct {
msg *nats.Msg
on string
js *broker.JetStream
seq int
}
func (b *natsBuild) Request() BuildRequest { return b.request }
@@ -203,4 +204,37 @@ func (b *natsBuild) Announce(ctx context.Context, result BuildResult) error {
func (b *natsBuild) Done() error { return b.msg.Ack() }
// Began publishes that this machine has taken the build, into the stream like the outcome, so a
// reader that asks afterwards sees when it started as well as how it ended.
func (b *natsBuild) Began(ctx context.Context) error {
body, err := json.Marshal(BuildStart{
ID: b.request.ID, Repository: b.request.Repository, Path: b.request.Path,
Ref: b.request.Ref, On: b.on, At: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
return err
}
publish, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if _, err := b.js.Context().Publish(BuildStarted(), body, nats.Context(publish)); err != nil {
return fmt.Errorf("cannot say a build started: %w", err)
}
return nil
}
// Say publishes one line under the build's id. Core publish, unawaited: the stream that holds the
// role's events captures it on its way through, and a build must not slow to the pace of an ack
// per line. A line the bus did not take is counted anyway, so the gap is visible to a reader.
func (b *natsBuild) Say(step, message string) {
b.seq++
body, err := json.Marshal(BuildLine{
ID: b.request.ID, Seq: b.seq, At: time.Now().UTC().Format(time.RFC3339Nano),
Step: step, Message: message,
})
if err != nil {
return
}
_ = b.js.Conn().Publish(BuildLog(b.request.ID), body)
}
func (b *natsBuild) Hold(after time.Duration) error { return b.msg.NakWithDelay(after) }
+32
View File
@@ -79,6 +79,14 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
defer func() { _ = watching.Unsubscribe() }()
_ = js.Conn().Flush()
// And a reader following this one build by its subject alone (novox/hq ADR 0157).
lines, err := js.Conn().SubscribeSync(BuildLog("b-1"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = lines.Unsubscribe() }()
_ = js.Conn().Flush()
// A build machine holding the role.
machine := MachineOverNATS(js, "anchor")
defer machine.Close()
@@ -86,6 +94,9 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
go func() {
failed <- machine.Take(ctx, func(ctx context.Context, work Build) {
r := work.Request()
_ = work.Began(ctx)
work.Say("clone", "cloning /r")
work.Say("image", "building shop")
_ = work.Announce(ctx, BuildResult{
ID: r.ID, Repository: r.Repository, On: "anchor", Commit: "abc1234",
Manifest: json.RawMessage(`{"module":"shop"}`),
@@ -104,6 +115,27 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
}
t.Fatalf("the asker never got an outcome: %v", err)
}
// The reader heard the build as it went, in order, under its id.
for want := 1; want <= 2; want++ {
msg, err := lines.NextMsg(5 * time.Second)
if err != nil {
t.Fatalf("line %d of the build never reached its subject: %v", want, err)
}
var line BuildLine
if err := json.Unmarshal(msg.Data, &line); err != nil || line.ID != "b-1" || line.Seq != want {
t.Fatalf("line %d came back as %s (%v)", want, msg.Data, err)
}
}
// And it is in the stream for a reader who comes later.
info, err := js.Context().StreamInfo(broker.EventsStream, &nats.StreamInfoRequest{SubjectsFilter: BuildLog("b-1")})
if err != nil {
t.Fatal(err)
}
if info.State.Subjects[BuildLog("b-1")] != 2 {
t.Fatalf("the stream holds %v under the build's subject, want 2", info.State.Subjects)
}
if err == nil {
}
if result.ID != "b-1" || result.Commit != "abc1234" {
t.Fatalf("the asker got %+v", result)
}