diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index e0bbef6..2af9092 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -145,6 +145,27 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool { limit, _ := args["limit"].(float64) return r.BuilderBuilds(module, state, int(limit)) }}, + {Name: view.BuildHistoryTool, + Description: "Every build that ended since a time (default the last 24 hours), oldest first, from the builder's log build-log (kept 30 " + + "days): its module, fingerprint, repository, directory, commit and ref, the build node, when it ended, its outcome (done, failed or " + + "queued), the manifest, the bases it was built against, each artifact made, and seconds, how long it took from its start (absent " + + "when the start is older than since). With module, that module's builds only; more says the log held more: ask again from the " + + "last build's time. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "only this module's builds"}, + "since": map[string]any{"type": "string", "description": "RFC 3339 time; default 24 hours ago"}}}, + Run: func(args map[string]any) (any, error) { + module, _ := args["module"].(string) + since := time.Now().Add(-view.DefaultHistorySince) + if raw, _ := args["since"].(string); strings.TrimSpace(raw) != "" { + at, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw)) + if err != nil { + return nil, fmt.Errorf("since %q is not an RFC 3339 time: %w", raw, err) + } + since = at + } + return r.BuildHistory(module, since) + }}, {Name: view.BuilderBuildReadTool, Description: "One build of the builder as stored. Give the module and its fingerprint (fp2, src2:...), or the module alone for the build " + "at the fingerprint the indexer holds for it now, joined through modules.; with commit, only if the indexer holds the module at that commit (a module's value names the commit its fingerprint first appeared at, and stays there through merges that leave the module alone). Read-only.", @@ -264,7 +285,8 @@ func main() { ConditionsState: read(stdio.State(view.StatusConditionsState)), AttemptsState: read(stdio.State(view.StatusAttemptsState)), PresenceState: read(stdio.State(view.StatusPresenceState)), StuckState: read(stdio.State(view.StatusStuckState)), PredefinedState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerPredefinedState)), - ConfigCandidatesState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState))} + ConfigCandidatesState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState)), + BuildLog: stdio.Log(delivery.BuildLogRead).Since} u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} for _, lang := range delivery.AskLanguages { diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index b103e85..0e230ce 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-view/internal/view" @@ -177,6 +178,28 @@ func TestBuilderBuildsListsKeysAsStored(t *testing.T) { t.Errorf("%s filter = %+v, want %s", args, got, want) } } + // 1955/L7 (ADR 0571 clause 1): builder_history answers each end from the builder's log with its duration from its start, + // the start of a later build of the same fingerprint not counted, and narrows by module through the log's key. + t0 := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) + entry := func(event string, at time.Duration) []byte { + e := delivery.BuildLogEntry{Event: event, Module: "netcheck", FP: fp2A, Repository: "novox/mesh-catalog", Directory: "modules/netcheck", + Commit: strings.Repeat("7", 40), Node: "ace", At: t0.Add(at)} + if event == delivery.BuildEndedEvent { + e.Outcome = delivery.BuildDone + } + raw, _ := delivery.EncodeBuildLogEntry(e) + return raw + } + var asked string + r.BuildLog = func(at time.Time, keys string, limit int) (*stdio.LogPage, error) { + asked = keys + return &stdio.LogPage{Entries: []stdio.LogEntry{{Sequence: 1, Value: entry(delivery.BuildStartedEvent, 0)}, + {Sequence: 2, Value: entry(delivery.BuildEndedEvent, 90*time.Second)}, {Sequence: 3, Value: entry(delivery.BuildStartedEvent, time.Hour)}}}, nil + } + out, err = run(t, r, view.BuildHistoryTool, map[string]any{"module": "netcheck", "since": t0.Format(time.RFC3339)}) + if h, _ := out.(view.HistoryAnswer); err != nil || len(h.Builds) != 1 || h.Builds[0].Seconds != 90 || h.Builds[0].Outcome != delivery.BuildDone || asked != "netcheck" || h.More { + t.Errorf("history = %+v, %v (keys %q), want the one end at 90 s", out, err, asked) + } } // 1723/AC2: builder_build answers one key by module and fp2, and by module alone through the indexer's fp2 (joined at its commit). diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index 96438b1..6340773 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029212112-2d2aa7ba9bbd + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029222821-bb993dd60641 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index 685613a..07e0ff7 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029212112-2d2aa7ba9bbd h1:WhqMEXFZ9Zc+fEfLsnf1jhhGkc4IqMwUpXXwZq+V3+E= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029212112-2d2aa7ba9bbd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029222821-bb993dd60641 h1:nP/jrjm2Qt1wdmd1Bc+E6c4+PobAzW5o64SPArE+jaw= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029222821-bb993dd60641/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go index cc9b8f8..fc9ac7c 100644 --- a/modules/mesh-delivery-view/internal/view/read.go +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -14,6 +14,7 @@ import ( "sync/atomic" "time" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -52,8 +53,13 @@ type Reader struct { ConditionsState, AttemptsState, PresenceState, StuckState StateRead // PredefinedState and ConfigCandidatesState are the indexer's control records (ADR 0565 §2, §15); unset reads none. PredefinedState, ConfigCandidatesState StateRead + // BuildLog reads the builder's log from a time (stdio.Log(delivery.BuildLogRead).Since); unset reads none. + BuildLog LogSince } +// LogSince is one page of a log from a time on, at most limit entries of keys (stdio.KeptLog.Since). +type LogSince func(at time.Time, keys string, limit int) (*stdio.LogPage, error) + // The read tools of the indexer's control records (ADR 0565 §15): the indexer serves no tool of its own (ADR 0437 §4). const ( PredefinedReadTool = "indexer_predefined" @@ -782,3 +788,82 @@ func (r Reader) Trusts() (TrustsAnswer, error) { } return out, nil } + +// BuildHistoryTool is the view's tool of the builds that ended, read from the builder's log (novox/hq ADR 0571 clause 1). +const BuildHistoryTool = "builder_history" + +// The bounds of builder_history: how far back it reads when no since is given, and the entries it reads of the log. +const ( + DefaultHistorySince = 24 * time.Hour + MaxHistoryEntries = 1000 +) + +// HistoryRow is one ended build of the builder's log, with how long it took from its start (none when its start is not in +// what was read). +type HistoryRow struct { + delivery.BuildLogEntry + Seconds float64 `json:"seconds,omitempty"` +} + +// HistoryAnswer is builder_history's answer: every build that ended since, oldest first, and More when the log held more +// entries than one read takes (ask again from the last build's time). +type HistoryAnswer struct { + Since time.Time `json:"since"` + Builds []HistoryRow `json:"builds"` + More bool `json:"more,omitempty"` +} + +// BuildHistory is every build of module (every module when empty) that ended at or after since, oldest first, each with its +// duration from the start the log holds of the same module and fingerprint before it (delivery.BuildDuration). An entry that +// does not read is refused, naming its sequence. +func (r Reader) BuildHistory(module string, since time.Time) (HistoryAnswer, error) { + out := HistoryAnswer{Since: since.UTC(), Builds: []HistoryRow{}} + if r.BuildLog == nil { + return out, fmt.Errorf("the builder's log cannot be read here") + } + keys := "" + if module = strings.TrimSpace(module); module != "" { + k, err := delivery.BuildLogKey(module) + if err != nil { + return out, err + } + keys = k + } + page, err := r.BuildLog(since, keys, MaxHistoryEntries) + if err != nil { + return out, fmt.Errorf("the builder's log %s from %s: %w", delivery.BuildLogRead, since.Format(time.RFC3339), err) + } + if page == nil { + return out, nil + } + var entries []delivery.BuildLogEntry + for _, le := range page.Entries { + e, err := delivery.DecodeBuildLogEntry(le.Value) + if err != nil { + return out, fmt.Errorf("the builder's log %s, entry %d: %w", delivery.BuildLogRead, le.Sequence, err) + } + entries = append(entries, e) + } + starts := map[string][]delivery.BuildLogEntry{} + for _, e := range entries { + if e.Event == delivery.BuildStartedEvent { + starts[e.Module+" "+e.FP] = append(starts[e.Module+" "+e.FP], e) + } + } + for _, e := range delivery.EndedSince(entries, since) { + row := HistoryRow{BuildLogEntry: e} + // the newest start of the same build at or before its end: a fingerprint built again later starts again after it + var start delivery.BuildLogEntry + for _, s := range starts[e.Module+" "+e.FP] { + if !s.At.After(e.At) && s.At.After(start.At) { + start = s + } + } + if d, ok := delivery.BuildDuration(start, e); ok { + row.Seconds = d.Seconds() + } + out.Builds = append(out.Builds, row) + } + out.More = page.Next != 0 + return out, nil +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 4c36dc6..72c912c 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -87,7 +87,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.predefined", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-vault.signers", "node-engine.have"} // view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.predefined", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-vault.signers", "node-engine.have"} // view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -119,7 +119,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, a := range m.Build.Artifacts { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index 4d27585..6f16694 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -13,6 +13,7 @@ "indexer_config_candidates", "builder_build", "builder_builds", + "builder_history", "assigner_wants", "assigner_want", "assigner_differences", @@ -45,6 +46,7 @@ "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", + "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks",