package builds import ( "encoding/json" "fmt" "sort" "time" "git.novox.be/novox/mesh-delivery/sdk/trunk" "git.novox.be/novox/mesh-sdk/go/keys" ) // The builder's log (clause 1; the lead and the operator, 2026-10-29): the builder's own module log (ADR 0297) `build-log`, // kept 30 days (max-age-days), one entry under the key `` when a build of a fingerprint starts and one when it // ends, so the newest entry of a key is the module's newest start or end. The builder's tools read it through the // runtime's log verbs; another module reads it by naming BuildLogRead in its manifest's `reads`: a bundle through the // runtime (stdio.Log(BuildLogRead)), a process on its own credential (process.Conn.ReadLog), each by newest per key, // from a time, or following new entries. The catalogue module asks the builder's tool BuildsSinceTool instead. const ( BuilderLog = "build-log" BuildLogRead = BuilderModule + "." + BuilderLog BuildStartedEvent = "build.started" BuildEndedEvent = "build.ended" // BuildSaidEvent is a build's narration between its start and its end (design 51: a build narrates its publish, // asked, waiting on a seat, stored, refused, at least every five minutes while it waits): its module, fingerprint, // line and why. BuildSaidEvent = "build.said" // BuilderLogDays is how long the log keeps its entries. BuilderLogDays = 30 // BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its // argument `since` (RFC 3339), its answer BuildsSince. BuildsSinceTool = "builder_builds_since" ) // BuildLogKey is the key of a module's entries in the builder's log: the module's name, refused when a log key could not // hold it. func BuildLogKey(module string) (string, error) { if err := keys.CheckName("module", module); err != nil { return "", err } return module, nil } // BuildLogEntry is one entry of the builder's log: the module, the fingerprint, the source revision, where the module is // built from (its repository, its directory there, the commit and the ref), the build node and the time; at the end, // the outcome, the manifest built (its digest, read from the indexer's state `manifests` under ManifestsKey), what it was // built against (the bases' references, as the controller's built-before said them) and each artifact made // (BuiltArtifact, the done key's: its name, kind, digest and reference). The repository, directory and commit are what // the indexer's PathOf, the outside-built registrations and the assigner's running commits read; the manifest, against // and made what the catalogue module registers a build from (the lead, 2026-10-29). type BuildLogEntry struct { V int `json:"v"` Event string `json:"event"` Module string `json:"module"` FP string `json:"fp"` Source string `json:"source"` Repository string `json:"repository"` Directory string `json:"directory"` Commit string `json:"commit"` Ref string `json:"ref,omitempty"` Node string `json:"node"` At time.Time `json:"at"` Outcome string `json:"outcome,omitempty"` Manifest string `json:"manifest,omitempty"` Against []string `json:"against,omitempty"` Made []BuiltArtifact `json:"made,omitempty"` // Imports are, at a build's end, the mesh packages (import paths) the built programs import, their tests left out, // sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 ยง2): the indexer // keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's. Imports []string `json:"imports,omitempty"` // OutOfStep are, at a build's end, the generated files its test step found out of step with what their generator // writes, as the build module answered them (its test step's `out-of-step`): the indexer raises a finding naming them // for a trunk build, and only a clean build of the same module clears it. A start or a narration names none. OutOfStep []string `json:"out-of-step,omitempty"` // Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone. Line string `json:"line,omitempty"` Why string `json:"why,omitempty"` } // BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held // more than one answer carries (then ask again from the last one's time). type BuildsSince struct { Since time.Time `json:"since"` Builds []BuildLogEntry `json:"builds"` More bool `json:"more,omitempty"` } // NewestPerModule is each module's newest entry of entries (one kind, read from the last per subject or whole), by // module: the reader's helper for the five newest and the running commits. func NewestPerModule(entries []BuildLogEntry) map[string]BuildLogEntry { out := map[string]BuildLogEntry{} for _, e := range entries { if have, ok := out[e.Module]; !ok || e.At.After(have.At) { out[e.Module] = e } } return out } // EndedSince is, of entries, the ended builds at or after since, oldest first: the reader's helper for history and for // BuildsSinceTool's answer. func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry { var out []BuildLogEntry for _, e := range entries { if e.Event == BuildEndedEvent && !e.At.Before(since) { out = append(out, e) } } sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) return out } // EncodeBuildLogEntry is e as JSON at Version; it refuses an entry a reader could not place. func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) { e.V = keys.Version switch { case e.Event == BuildSaidEvent: switch { case e.Module == "" || e.FP == "" || e.At.IsZero() || e.Line == "": return nil, fmt.Errorf("builds: a build's narration names its module, fingerprint, line and time") case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || len(e.OutOfStep) > 0: return nil, fmt.Errorf("builds: a build's narration names no outcome, manifest, base, artifact, import or file out of step") } return json.Marshal(e) case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent: return nil, fmt.Errorf("builds: a build log entry is %s, %s or %s, not %q", BuildStartedEvent, BuildSaidEvent, BuildEndedEvent, e.Event) case e.Line != "" || e.Why != "": return nil, fmt.Errorf("builds: a build's start or end carries no narration's line") case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "": return nil, fmt.Errorf("builds: a build log entry names its module, fingerprint, repository, commit, node and time") case e.Event == BuildEndedEvent && e.Outcome == "": return nil, fmt.Errorf("builds: a build's end names its outcome") case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || len(e.OutOfStep) > 0): return nil, fmt.Errorf("builds: a build's start names no outcome, manifest, base, artifact, import or file out of step") } if err := trunk.CheckImports(e.Imports); err != nil { return nil, err } return json.Marshal(e) } // DecodeBuildLogEntry reads one entry. func DecodeBuildLogEntry(data []byte) (BuildLogEntry, error) { var e BuildLogEntry if err := json.Unmarshal(data, &e); err != nil { return BuildLogEntry{}, fmt.Errorf("builds: a build log entry is not readable: %w", err) } if e.V != keys.Version { return BuildLogEntry{}, fmt.Errorf("builds: a build log entry of version %d, and this reads %d", e.V, keys.Version) } return e, nil } // BuildDuration is a build's duration, its end less its start (clause 1): false unless both are the same module's // build of the same fingerprint, the start first. func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) { if start.Event != BuildStartedEvent || end.Event != BuildEndedEvent || start.Module != end.Module || start.FP != end.FP || end.At.Before(start.At) { return 0, false } return end.At.Sub(start.At), true } // IsBuildEntry says whether an entry of the builder's log is a build's start or end, read from its event alone, so an entry of // another kind (a build's narration, build.said) is passed over before its form is read. One whose event cannot be read is // taken as a build's, so its decoding says what is wrong with it. func IsBuildEntry(value []byte) bool { var e struct { Event *string `json:"event"` } if json.Unmarshal(value, &e) != nil || e.Event == nil { return true } return *e.Event == BuildStartedEvent || *e.Event == BuildEndedEvent }