View: answer builder_history from the builder's log, each build's end with its duration from its start (issue 1955, ADR 0571 §1)
This commit is contained in:
@@ -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.<module>; 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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user