226 lines
8.9 KiB
Go
226 lines
8.9 KiB
Go
package buildstep
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// A build step's log on its build module's own stream (the operator, 2026-10-30: a verdict cut a failure to its last four
|
|
// kilobytes and named no place that held the rest, so a check's cause was guessed; novox/hq ADR 0157 for the shape: a
|
|
// build says what it does on the bus, under its id, in sequenced lines, read back from the stream). The step's result
|
|
// keeps each failure's Output clipped to OutputBytes, the summary; the failure's whole output, up to FullBytes, is said
|
|
// as delivery.StepLog events on delivery.StepLogEvent(run, step), at most LogEventBytes each, on the stream the module
|
|
// declares for `log.**` (max age and size in its manifest, dropping its oldest). ReadStepLog reads it back in pages.
|
|
|
|
const (
|
|
// FullBytes is the most of one failure's output that is said whole (its last bytes).
|
|
FullBytes = 1 << 20
|
|
// LogEventBytes is the most lines one log event holds, and LogPageBytes the most one page of a read answers.
|
|
LogEventBytes = 64 << 10
|
|
LogPageBytes = 64 << 10
|
|
)
|
|
|
|
// stepLog says one step's log: its node, id and run, the build module, how to say an event, and the next line's number.
|
|
type stepLog struct {
|
|
say func(event string, body any) error
|
|
module, node string
|
|
id, run string
|
|
seq int
|
|
// dead says a publish failed: the step says nothing more on its log, so a bus that does not take its lines costs the
|
|
// step at most one publish's timeout, and never fails it (the 2026-10-30 review).
|
|
dead bool
|
|
}
|
|
|
|
// stepLog is the log of the step whose ask runs under ctx, or nil when no ask runs (a tool called by hand) or the module
|
|
// says no log.
|
|
func (s AskSteps) stepLog(ctx context.Context) *stepLog {
|
|
r, ok := ctx.Value(phaseKey{}).(*phases)
|
|
if !ok || r == nil || s.Narrate == nil {
|
|
return nil
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.log == nil {
|
|
r.log = &stepLog{say: s.Narrate, module: s.Module, node: r.step.Node, id: r.step.ID, run: r.run}
|
|
}
|
|
return r.log
|
|
}
|
|
|
|
// narrate says every failure's Full of a step answer's "failed" on the step's log and empties it, setting the failure's
|
|
// Step, Module, Machine and Run; a failure whose lines could not all be said keeps no address, that is said once through
|
|
// logf, and the step says nothing more on its log (dead). With l nil, Full is only emptied: the step's result never holds it.
|
|
func (l *stepLog) narrate(raw json.RawMessage, logf func(string, ...any)) json.RawMessage {
|
|
if !strings.Contains(string(raw), `"full_output"`) {
|
|
return raw
|
|
}
|
|
var answer map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &answer); err != nil || answer["failed"] == nil {
|
|
return raw
|
|
}
|
|
var failed []map[string]json.RawMessage
|
|
if err := json.Unmarshal(answer["failed"], &failed); err != nil {
|
|
return raw
|
|
}
|
|
for i, f := range failed {
|
|
var full string
|
|
_ = json.Unmarshal(f["full_output"], &full)
|
|
delete(failed[i], "full_output")
|
|
if full == "" || l == nil || l.dead {
|
|
continue
|
|
}
|
|
var name struct {
|
|
Package string `json:"package"`
|
|
Test string `json:"test"`
|
|
}
|
|
b, _ := json.Marshal(f)
|
|
_ = json.Unmarshal(b, &name)
|
|
if err := l.say1(name.Package, name.Test, strings.Split(full, "\n")); err != nil {
|
|
l.dead = true
|
|
if logf != nil {
|
|
logf("the step %s says no more on its log, from the whole output of %s %s on: %v", l.id, name.Package, name.Test, err)
|
|
}
|
|
continue
|
|
}
|
|
for field, v := range map[string]string{"step": l.id, "module": l.module, "machine": l.node, "run": l.run} {
|
|
failed[i][field], _ = json.Marshal(v)
|
|
}
|
|
}
|
|
answer["failed"], _ = json.Marshal(failed)
|
|
out, err := json.Marshal(answer)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return out
|
|
}
|
|
|
|
// say1 says one failure's lines, in events of at most LogEventBytes, numbered on from the step's last line.
|
|
func (l *stepLog) say1(pkg, test string, lines []string) error {
|
|
event := delivery.StepLogEvent(l.run, l.id)
|
|
for len(lines) > 0 {
|
|
n, size := 0, 0
|
|
for n < len(lines) && (n == 0 || size+len(lines[n])+1 <= LogEventBytes) {
|
|
if len(lines[n]) > LogEventBytes {
|
|
lines[n] = strings.ToValidUTF8(lines[n][:LogEventBytes], "") // one line longer than an event is cut
|
|
}
|
|
size += len(lines[n]) + 1
|
|
n++
|
|
}
|
|
if err := l.say(event, delivery.StepLog{Seq: l.seq + 1, Package: pkg, Test: test, Lines: lines[:n]}); err != nil {
|
|
return err
|
|
}
|
|
l.seq += n
|
|
lines = lines[n:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StreamRead reads a module's own stream: stdio.Stream(name).Read's shape.
|
|
type StreamRead func(events string, from uint64, limit int) (*stdio.StreamPage, error)
|
|
|
|
// StepLogPage is one page of a step's log: the lines from the stream sequence From on, headed "=== <package> <test>"
|
|
// where a failure's lines start on the page, Next the sequence to read on from (0 when nothing follows yet; a step still running may
|
|
// say more), Last the stream's last sequence, and Gap when a line number was skipped (the stream dropped it with age).
|
|
type StepLogPage struct {
|
|
Run string `json:"run"`
|
|
Step string `json:"step"`
|
|
Event string `json:"event"`
|
|
From uint64 `json:"from"`
|
|
Lines []string `json:"lines"`
|
|
Bytes int `json:"bytes"`
|
|
Next uint64 `json:"next"`
|
|
Last uint64 `json:"last"`
|
|
Gap bool `json:"gap,omitempty"`
|
|
}
|
|
|
|
// ReadStepLog reads one page of about LogPageBytes of the log of the step id under run, from the stream sequence from on.
|
|
func ReadStepLog(read StreamRead, run, id string, from uint64) (StepLogPage, error) {
|
|
if !stepToken(run) || !stepToken(id) {
|
|
return StepLogPage{}, fmt.Errorf("a step's log is named by its run and its step id, each one token of letters, digits and -: %q, %q", run, id)
|
|
}
|
|
event := delivery.StepLogEvent(run, id)
|
|
page := StepLogPage{Run: run, Step: id, Event: event, From: from, Lines: []string{}}
|
|
seq, test := 0, "\x00"
|
|
for {
|
|
got, err := read(event, from, 16)
|
|
if err != nil {
|
|
return page, fmt.Errorf("the log %s cannot be read: %w", event, err)
|
|
}
|
|
page.Last = got.Last
|
|
for _, e := range got.Entries {
|
|
var l delivery.StepLog
|
|
if err := json.Unmarshal(e.Body, &l); err != nil {
|
|
return page, fmt.Errorf("the log %s holds at %d what is no log event: %v", event, e.Sequence, err)
|
|
}
|
|
size := 0
|
|
for _, line := range l.Lines {
|
|
size += len(line) + 1
|
|
}
|
|
if page.Bytes > 0 && page.Bytes+size > LogPageBytes {
|
|
page.Next = e.Sequence
|
|
return page, nil
|
|
}
|
|
if seq > 0 && l.Seq != seq {
|
|
page.Gap = true
|
|
}
|
|
if name := l.Package + " " + l.Test; name != test {
|
|
page.Lines = append(page.Lines, "=== "+strings.TrimSpace(name))
|
|
test = name
|
|
}
|
|
page.Lines = append(page.Lines, l.Lines...)
|
|
page.Bytes += size
|
|
seq = l.Seq + len(l.Lines)
|
|
}
|
|
if got.Next == 0 {
|
|
return page, nil
|
|
}
|
|
from = got.Next
|
|
}
|
|
}
|
|
|
|
// stepToken is one subject token of a run or a step id.
|
|
func stepToken(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// LogStreamOf is the local name of a build module's own log stream, <language>-build-logs (a stream's bus name is
|
|
// mesh-wide): LogStreamOf names it.
|
|
func LogStreamOf(language string) string { return language + "-build-logs" }
|
|
|
|
// LogTool is a build module's tool that reads one step's log from its own stream, {run, step, from}: a failed test's
|
|
// whole output, which the step's result and the checker's verdict keep cut. The stream is the mesh's, so any machine's
|
|
// instance answers for a step of any machine.
|
|
func LogTool(name string, read StreamRead) stdio.Tool {
|
|
return stdio.Tool{Name: name,
|
|
Description: "The full build log of one build step: the whole output of every test that failed in it, which a check's " +
|
|
"verdict and the step's result keep cut to their last lines (\"... (N earlier lines)\"). One page of about 64 KB; " +
|
|
"read on with from = the answer's next until it is 0 (a step still running may say more: read again from next). " +
|
|
"run is the check (its heads hash) or the delivery plan the step ran for, \"none\" when the ask named none; step " +
|
|
"is the step id; both are in a failed verdict (checker_failure, checker_pull). Kept 14 days. Read-only.",
|
|
Input: map[string]any{"type": "object", "required": []string{"run", "step"}, "properties": map[string]any{
|
|
"run": map[string]any{"type": "string", "description": "the check or delivery plan the step ran for, or none"},
|
|
"step": map[string]any{"type": "string", "description": "the step id (the ask's id)"},
|
|
"from": map[string]any{"type": "integer", "description": "the stream sequence to read on from: the last answer's next; 0 is the start"}}},
|
|
Run: func(args map[string]any) (any, error) {
|
|
run, _ := args["run"].(string)
|
|
step, _ := args["step"].(string)
|
|
from, _ := args["from"].(float64)
|
|
if from < 0 {
|
|
return nil, fmt.Errorf("from is a stream sequence, 0 or more")
|
|
}
|
|
return ReadStepLog(read, run, step, uint64(from))
|
|
}}
|
|
}
|