The builder narrates every step, and every command it runs

A build was silent from clone to publish, so a build in progress, one that failed
quietly, and a request that never arrived all looked identical — which cost a long
diagnosis against a running mesh chasing "the handler never fired".

Now: the handler announces a request the instant it lands. Build logs each phase
— clone, commit, manifest, bases, each artifact starting and finishing with what
it produced, resolve, done — through a Log callback that is nil-safe, so the tests
that pass none still build. And the Command runner echoes every command before it
runs, with where and how long it took, because on a hang the last line is exactly
the command it is stuck inside: "git clone waiting on a network that will not
answer" rather than "the builder did nothing".

The unreadable-request path prints to stdout now too, not stderr, so it shows in
docker logs without splitting streams — the split is what hid it.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-15 22:26:35 +02:00
parent b8cacbaf4d
commit 9070d2502c
5 changed files with 120 additions and 20 deletions
+93 -3
View File
@@ -14,6 +14,7 @@ import (
"regexp"
"sort"
"strings"
"time"
"github.com/novox/mesh-control/internal/catalogue"
)
@@ -67,7 +68,10 @@ type Result struct {
// archive failed would otherwise leave half of itself in the store under a digest the mesh never
// records — reachable, unreferenced, and indistinguishable from something in use.
func Build(ctx context.Context, run Runner, publish Publisher,
repository, path, ref, workspace string, held map[string]string) (Result, error) {
repository, path, ref, workspace string, held map[string]string, log Log) (Result, error) {
say := logging(log)
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
// Made rather than required. A builder that fails because the directory it was told to work
// in does not exist is a builder that needs a setup step nobody documented.
@@ -82,8 +86,10 @@ func Build(ctx context.Context, run Runner, publish Publisher,
// that reuses a working tree can succeed because of something a previous build left behind,
// and that is a build nobody can reproduce.
if _, err := run(ctx, workspace, "git", "clone", "--quiet", repository, tree); err != nil {
say("clone", "FAILED: %v", err)
return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err)
}
say("clone", "done")
if ref != "" {
if _, err := run(ctx, tree, "git", "checkout", "--quiet", ref); err != nil {
return Result{}, fmt.Errorf("%s has no %s: %w", repository, ref, err)
@@ -94,6 +100,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
return Result{}, err
}
commit = strings.TrimSpace(commit)
say("commit", "%s", short(commit))
// A module is a repository and a path within it (novox/hq ADR 0069). The ordinary case is an
// empty path, meaning the repository's root; a repository holding several modules names each
@@ -112,8 +119,10 @@ func Build(ctx context.Context, run Runner, publish Publisher,
}
manifest, err := catalogue.ParseManifest(raw)
if err != nil {
say("manifest", "INVALID: %v", err)
return Result{}, err
}
say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest))
var built []catalogue.Built
if manifest.Build != nil {
@@ -122,29 +131,86 @@ func Build(ctx context.Context, run Runner, publish Publisher,
// fix it rather than inside a build that stops on its own first line.
args, err := standingOn(manifest, held)
if err != nil {
say("bases", "UNMET: %v", err)
return Result{}, err
}
if len(args) > 0 {
say("bases", "%d resolved from what the mesh holds", len(args)/2)
}
artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...)
// Ordered, so two builds of one commit do the same work in the same sequence and their
// logs can be compared.
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
for _, a := range artifacts {
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args, held)
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args, held, say)
if err != nil {
say("artifact", "%s FAILED: %v", a.Name, err)
return Result{}, err
}
say("artifact", "%s done — %s", a.Name, describeMade(made))
built = append(built, made)
}
}
resolved, err := manifest.Resolve(built)
if err != nil {
say("resolve", "FAILED: %v", err)
return Result{}, err
}
say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built))
return Result{Manifest: resolved, Commit: commit, Built: built,
Against: against(within, manifest)}, nil
}
// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none,
// and a build with nowhere to speak must still build.
type Log func(step, message string)
func logging(log Log) func(step, format string, args ...any) {
if log == nil {
return func(string, string, ...any) {}
}
return func(step, format string, args ...any) {
log(step, fmt.Sprintf(format, args...))
}
}
func describePath(path string) string {
if path == "" {
return ""
}
return " at " + path
}
func refOrHead(ref string) string {
if ref == "" {
return "HEAD"
}
return ref
}
func artifactCount(m catalogue.Manifest) int {
if m.Build == nil {
return 0
}
return len(m.Build.Artifacts)
}
func langSuffix(a catalogue.Artifact) string {
if a.Language != "" {
return ", " + a.Language
}
return ""
}
func describeMade(made catalogue.Built) string {
if made.Digest != "" {
return made.Kind + " " + short(strings.TrimPrefix(made.Digest, "sha256:"))
}
return made.Kind + " " + made.Reference
}
// inside resolves a module's path within a clone, and refuses one that leaves it.
//
// **A build reads only its own tree.** A path of `../../etc` would otherwise make a build read —
@@ -216,16 +282,18 @@ const ManifestName = "module.json"
func one(ctx context.Context, run Runner, publish Publisher,
module, tree, commit string, a catalogue.Artifact, args []string,
held map[string]string) (catalogue.Built, error) {
held map[string]string, say func(step, format string, args ...any)) (catalogue.Built, error) {
switch a.Kind {
case catalogue.ArtifactUpstream:
// Mirrored, not built. Pulled by the reference the module names and pushed under a name
// of the mesh's own, so what a machine fetches is pinned by a digest this registry
// assigned rather than by a tag somebody else can move.
say("mirror", "pulling %s", a.From)
if _, err := run(ctx, tree, "docker", "pull", a.From); err != nil {
return catalogue.Built{}, fmt.Errorf("%s: cannot fetch %s: %w", module, a.From, err)
}
say("mirror", "publishing under the mesh's own name")
reference, err := publish.PublishImage(ctx, a.From, module+"/"+a.Name)
if err != nil {
return catalogue.Built{}, err
@@ -244,9 +312,11 @@ func one(ctx context.Context, run Runner, publish Publisher,
invocation = append(invocation, "--target", a.Target)
}
invocation = append(invocation, ".")
say("image", "docker build -f %s", a.From)
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
}
say("image", "built, publishing")
reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name)
if err != nil {
return catalogue.Built{}, err
@@ -277,10 +347,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
"holds no copy of it. Build %s first",
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
}
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
compiled, err := compile(ctx, run, tree, chain, base, a)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
}
say("bundle", "compiled, packing")
body, err := pack(compiled)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
@@ -401,16 +473,31 @@ func short(commit string) string {
// Command is a Runner that actually runs things.
func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
// **Every command is echoed before it runs**, with where. On a build that hangs, the last line
// is exactly the command it is inside — which is the difference between "the builder did
// nothing" and "git clone is waiting on a network that will not answer". Silent on success is
// what made an empty workspace unreadable.
started := timeNow()
fmt.Printf(" $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " "))
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf(" ! %s %s failed after %s\n", name, args[0], since(started))
return string(out), fmt.Errorf("%s %s: %w\n%s",
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
fmt.Printf(" ✓ %s %s (%s)\n", name, firstArg(args), since(started))
return string(out), nil
}
func firstArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[0]
}
var _ io.Writer = (*stringWriter)(nil)
// standingOn turns the bases a module named into build arguments for what this mesh holds.
@@ -504,3 +591,6 @@ func sourcesFor(entrypoints []string, out string) []string {
}
return sources
}
func timeNow() time.Time { return time.Now() }
func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).String() }