From 3036130de248f86fa7969a4022ff654d7a5516d4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:44:40 +0100 Subject: [PATCH 1/4] =?UTF-8?q?mesh-delivery-sdk=20window:=20StepWindow=20?= =?UTF-8?q?and=20WindowHeld,=20the=20one=20rule=20distribution=20and=20the?= =?UTF-8?q?=20builder=20step=20a=20window=20by,=20moved=20with=20their=20t?= =?UTF-8?q?est=20(B5e=20ruling=202;=20ADR=200634=20=C2=A71;=20issue=201966?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- sdk/window/window.go | 64 +++++++++++++++++++++++++++++++++++++++ sdk/window/window_test.go | 41 +++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 sdk/window/window.go create mode 100644 sdk/window/window_test.go diff --git a/sdk/window/window.go b/sdk/window/window.go new file mode 100644 index 00000000..9d192d12 --- /dev/null +++ b/sdk/window/window.go @@ -0,0 +1,64 @@ +package window + +import ( + "slices" + "sort" + "time" +) + +// WindowHeld is the part of a window every window keeper shares (design 51: distribution's old seat name and direct +// writes, the builder's old reference form): whether it is open, the nodes holding it and since when, and when it last +// closed and who held it then. A keeper embeds it in its own record, so its JSON is the record's. +type WindowHeld struct { + Open bool `json:"open"` + Holding []string `json:"holding,omitempty"` + Since map[string]time.Time `json:"since,omitempty"` + Closed time.Time `json:"closed,omitzero"` + // LastHolding are the nodes that held it last, said when it closes. + LastHolding []string `json:"last_holding,omitempty"` +} + +// StepWindow is the one rule every window keeper steps its window by (security-upgrade review F3, the second of a +// kind): named is every node the read named, dropped ones included, and holding those of them that hold the window at +// now. A read that names no node judges nothing, as a first view's empty answer at revision 0 is no empty state (ADR +// 0615): an empty node_haves or a placement not yet read never closes a window, and the window stays as it was. A read +// naming nodes, none holding, closes an open window (closed true, once); one naming a holder opens it, each holder's +// since kept from w. overdue are the holders whose since is at least findingAfter before now (the keeper's finding, +// cleared for every other node); a read naming no node keeps w's holders' overdue as they stand at now. +func StepWindow(w WindowHeld, named, holding []string, now time.Time, findingAfter time.Duration) (next WindowHeld, closed bool, overdue []string) { + now = now.UTC() + if len(named) == 0 { + next = w + for _, n := range w.Holding { + if since, ok := w.Since[n]; ok && now.Sub(since) >= findingAfter { + overdue = append(overdue, n) + } + } + return next, false, overdue + } + if len(holding) == 0 { + next = WindowHeld{Closed: w.Closed, LastHolding: w.LastHolding} + if w.Open { + next.Closed, next.LastHolding = now, slices.Clone(w.Holding) + if next.LastHolding == nil { + next.LastHolding = []string{} + } + return next, true, nil + } + return next, false, nil + } + held := slices.Clone(holding) + sort.Strings(held) + next = WindowHeld{Open: true, Holding: held, Since: map[string]time.Time{}, Closed: w.Closed, LastHolding: w.LastHolding} + for _, n := range held { + since, had := w.Since[n] + if !had { + since = now + } + next.Since[n] = since + if now.Sub(since) >= findingAfter { + overdue = append(overdue, n) + } + } + return next, false, overdue +} diff --git a/sdk/window/window_test.go b/sdk/window/window_test.go new file mode 100644 index 00000000..d585c0e9 --- /dev/null +++ b/sdk/window/window_test.go @@ -0,0 +1,41 @@ +package window + +import ( + "slices" + "testing" + "time" +) + +// The one rule every window keeper steps by (design 51; security-upgrade review F3): a read naming no node judges +// nothing, so an empty node_haves or an unread placement never closes a window. +func TestStepWindow(t *testing.T) { + now := time.Date(2026, 11, 4, 17, 0, 0, 0, time.UTC) + week := 7 * 24 * time.Hour + long := now.Add(-8 * 24 * time.Hour) + open := WindowHeld{Open: true, Holding: []string{"n1"}, Since: map[string]time.Time{"n1": long}} + for _, c := range []struct { + name string + w WindowHeld + named, holding []string + open, closed bool + holds, overdue []string + }{ + {"a read naming no node leaves an open window open, its holder still overdue", open, nil, nil, true, false, []string{"n1"}, []string{"n1"}}, + {"a read naming no node leaves a closed window closed", WindowHeld{Closed: long}, nil, nil, false, false, nil, nil}, + {"a read naming nodes, none holding, closes an open window once", open, []string{"n1", "n2"}, nil, false, true, nil, nil}, + {"a closed window a read finds unheld stays closed, said no more", WindowHeld{Closed: long, LastHolding: []string{"n1"}}, []string{"n1"}, nil, false, false, nil, nil}, + {"a new holder opens it, since now, not overdue", WindowHeld{}, []string{"n1", "n2"}, []string{"n2"}, true, false, []string{"n2"}, nil}, + {"a holder keeps its since and is overdue past the bound", open, []string{"n1", "n2"}, []string{"n2", "n1"}, true, false, []string{"n1", "n2"}, []string{"n1"}}, + } { + next, closed, overdue := StepWindow(c.w, c.named, c.holding, now, week) + if next.Open != c.open || closed != c.closed || !slices.Equal(next.Holding, c.holds) || !slices.Equal(overdue, c.overdue) { + t.Errorf("%s: open %v closed %v holding %v overdue %v", c.name, next.Open, closed, next.Holding, overdue) + } + if closed && (!next.Closed.Equal(now) || !slices.Equal(next.LastHolding, c.w.Holding)) { + t.Errorf("%s: closed at %v, last held by %v", c.name, next.Closed, next.LastHolding) + } + if !c.open && !closed && !next.Closed.Equal(c.w.Closed) { + t.Errorf("%s: its last close moved to %v", c.name, next.Closed) + } + } +} From eaa84387f7392b554da4411dda6612d122efe467 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:48:52 +0100 Subject: [PATCH 2/4] =?UTF-8?q?mesh-delivery-sdk=20buildstep:=20the=20buil?= =?UTF-8?q?d=20ask=20once,=20as=20one=20neutral=20form=20for=20the=20three?= =?UTF-8?q?=20build=20seats=20(the=20part=20build-step's=20StepAsk,=20Fetc?= =?UTF-8?q?h,=20jobs,=20answers,=20kinds,=20languages,=20needs,=20subjects?= =?UTF-8?q?,=20pauses,=20capacity,=20composer=20key),=20one=20conversion?= =?UTF-8?q?=20AskOf=20for=20any=20seat's=20type;=20seatgen=20writes=20a=20?= =?UTF-8?q?package=20per=20seat=20and=20shares=20no=20part,=20so=20the=20f?= =?UTF-8?q?orm=20lives=20here=20(B5e=20ruling=201;=20ADR=200634=20=C2=A76;?= =?UTF-8?q?=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- sdk/buildstep/ask.go | 622 ++++++++++++++++++++++++++++++++++++++ sdk/buildstep/ask_test.go | 261 ++++++++++++++++ sdk/buildstep/pause.go | 107 +++++++ 3 files changed, 990 insertions(+) create mode 100644 sdk/buildstep/ask.go create mode 100644 sdk/buildstep/ask_test.go create mode 100644 sdk/buildstep/pause.go diff --git a/sdk/buildstep/ask.go b/sdk/buildstep/ask.go new file mode 100644 index 00000000..be85a8af --- /dev/null +++ b/sdk/buildstep/ask.go @@ -0,0 +1,622 @@ +package buildstep + +import ( + "encoding/json" + "fmt" + "path" + "slices" + "sort" + "strings" + "time" + + meshkeys "git.novox.be/novox/mesh-sdk/go/keys" +) + +// **The build ask, once.** The three build seats (node-build-go, node-build-image, node-build-node) accept the same ask, +// defined once as the part `build-step` of the Delivery contracts. The generator writes one Go type per seat, so the +// seat packages hold three structurally equal StepAsks; this file is the one neutral form the shared runner and the +// askers use, and AskOf is the one conversion from any seat's (a seat's value and this form are the same JSON, held +// equal by the part's fixtures: the contracts' tests decode each in both). This package imports no seat's contract +// (novox/hq ADR 0634 §6). + +// The kinds of ask: the seat's accepted kinds, in the order a holder pulls them. +const ( + AskDelivery = "delivery" + AskCheck = "check" + AskRecheck = "recheck" +) + +// AskKinds are the accepted kinds of a build seat, in the order a holder pulls them (ADR 0536 §4). +var AskKinds = []string{AskDelivery, AskCheck, AskRecheck} + +// The languages of the build seats: each is one build module's (mesh-build-) and one seat's +// (node-build-). +const ( + AskGo = "go" + AskNode = "node" + AskImage = "image" +) + +// AskLanguages are the build seats' languages. +var AskLanguages = []string{AskGo, AskNode, AskImage} + +// NeedNodeBuildNode is the need of a check whose tests run through the Node build seat's holder on the same node +// (issue 890): a node that holds no Node build module declines the ask. +const NeedNodeBuildNode = "node-build-node" + +// AskNeeds are the needs an ask may name. +var AskNeeds = []string{NeedNodeBuildNode} + +// CheckNeed refuses a need an ask may not name. +func CheckNeed(need string) error { + if !slices.Contains(AskNeeds, need) { + return fmt.Errorf("buildstep: %q is not a need an ask may name (%s)", need, strings.Join(AskNeeds, ", ")) + } + return nil +} + +// Lacks are the needs has does not say its node has; a nil has lacks all. +func Lacks(needs []string, has func(need string) bool) []string { + var out []string + for _, n := range needs { + if has == nil || !has(n) { + out = append(out, n) + } + } + return out +} + +// AskRuns is how many times a holder runs one ask before it ends refused; AskMaxDeliver is the redelivery bound that +// leaves one more for the terminal step (ADR 0525 §5). +const ( + AskRuns = 3 + AskMaxDeliver = AskRuns + 2 +) + +// StepAskVersion is the layout version of an ask. +const StepAskVersion = 1 + +// The levels a check ask names. +const ( + LevelUnit = "unit" + LevelIntegration = "integration" +) + +// The registry seats a build publishes through (ADR 0605 §1). +var RegistrySeats = []string{"npm-package-registry", "go-package-registry", "container-image-registry", "oci-artifact-registry"} + +// NodeNamesArg and NodeNamesEnv carry the node names a check's tests run against. +const ( + NodeNamesArg = "node-names" + NodeNamesEnv = "MESH_NODE_NAMES" +) + +func checkAskKind(kind string) error { + if !slices.Contains(AskKinds, kind) { + return fmt.Errorf("buildstep: the ask kind %q is not delivery, check or recheck", kind) + } + return nil +} + +func checkAskLanguage(language string) error { + if !slices.Contains(AskLanguages, language) { + return fmt.Errorf("buildstep: the ask language %q is not go, node or image", language) + } + return nil +} + +// BuildSeat is the seat of a language's build module. +func BuildSeat(language string) (string, error) { + if err := checkAskLanguage(language); err != nil { + return "", err + } + return "node-build-" + language, nil +} + +// BuildModuleOf is the module of a language's build seat. +func BuildModuleOf(language string) (string, error) { + if err := checkAskLanguage(language); err != nil { + return "", err + } + return "mesh-build-" + language, nil +} + +// AskSubject is the subject a build seat accepts a kind of ask on: the seat's accept subject. +func AskSubject(language, kind string) (string, error) { + seat, err := BuildSeat(language) + if err != nil { + return "", err + } + if err := checkAskKind(kind); err != nil { + return "", err + } + return "mesh.seat." + seat + ".accept." + kind, nil +} + +// ParseAskSubject is the inverse of AskSubject. +func ParseAskSubject(subject string) (language, kind string, err error) { + rest, ok := strings.CutPrefix(subject, "mesh.seat.node-build-") + if !ok { + return "", "", fmt.Errorf("buildstep: %q is not a build seat's accept subject", subject) + } + language, kind, ok = strings.Cut(rest, ".accept.") + if !ok { + return "", "", fmt.Errorf("buildstep: %q is not a build seat's accept subject", subject) + } + if want, err := AskSubject(language, kind); err != nil || want != subject { + return "", "", fmt.Errorf("buildstep: %q is not a build seat's accept subject: %v", subject, err) + } + return language, kind, nil +} + +// AskStream is the work-queue stream of a language's build seat. +func AskStream(language string) (string, error) { + seat, err := BuildSeat(language) + if err != nil { + return "", err + } + return "SEAT_" + strings.ToUpper(strings.ReplaceAll(seat, "-", "_")), nil +} + +// AskWorker is the shared pull worker of a kind on a language's stream. +func AskWorker(language, kind string) (string, error) { + stream, err := AskStream(language) + if err != nil { + return "", err + } + if err := checkAskKind(kind); err != nil { + return "", err + } + return stream + "_" + strings.ToUpper(kind) + "_worker", nil +} + +// AskStepKey is the key of the step of the ask askID on node. +func AskStepKey(node, askID string) (string, error) { + if err := CheckAskID(askID); err != nil { + return "", err + } + return StepKey(node, askID) +} + +// AskStepWatch is the key pattern of an ask's step on any node. +func AskStepWatch(askID string) (string, error) { + if err := CheckAskID(askID); err != nil { + return "", err + } + return "*." + askID, nil +} + +// StepAsk is one ask of a build module: the part's `StepAsk`. +type StepAsk struct { + V int `json:"v"` + ID string `json:"id"` + Kind string `json:"kind"` + Language string `json:"language"` + Asker string `json:"asker"` + At time.Time `json:"at,omitzero"` + Build *BuildJob `json:"build,omitempty"` + Check *CheckJob `json:"check,omitempty"` + Compose *ComposeJob `json:"compose,omitempty"` + // Needs are what a check's tests need of the holder's node (CheckNeed); a holder lacking one declines the ask. + Needs []string `json:"needs,omitempty"` + // Packages pin the mesh packages a build or a check builds against. + Packages PackageVersions `json:"packages,omitempty"` +} + +// Fetch is what a build module's fetch tool is asked, inside an ask: the part's `Fetch`. +type Fetch struct { + V int `json:"v,omitempty"` + Repository string `json:"repository"` + Commit string `json:"commit"` + Ref string `json:"ref,omitempty"` + Base string `json:"base,omitempty"` + Dir string `json:"dir,omitempty"` + Paths []string `json:"paths,omitempty"` + Packages PackageVersions `json:"packages,omitempty"` + Beside []FetchBeside `json:"beside,omitempty"` +} + +// FetchBeside is a sibling repository placed beside the fetched tree. +type FetchBeside struct { + Repository string `json:"repository"` + Commit string `json:"commit"` +} + +// ToolchainUsed is the toolchain a build ran with. +type ToolchainUsed struct { + Language string `json:"language"` + Recipe string `json:"recipe"` + Version string `json:"version"` +} + +// AskArtifact is one artifact of a build ask: built from the fetched tree, or copied from an upstream pin (From). +type AskArtifact struct { + Artifact string `json:"artifact"` + From string `json:"from,omitempty"` + Seat string `json:"seat,omitempty"` + Version string `json:"version,omitempty"` +} + +// BuildJob is a trunk fingerprint's build. +type BuildJob struct { + Module string `json:"module"` + Path string `json:"path,omitempty"` + Fp string `json:"fp,omitempty"` + Fetch *Fetch `json:"fetch,omitempty"` + Artifacts []AskArtifact `json:"artifacts"` + Publish bool `json:"publish,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// CheckJob is the check of one or both levels of a fetched tree. +type CheckJob struct { + Fetch Fetch `json:"fetch"` + Levels []string `json:"levels"` + Kept bool `json:"kept,omitempty"` + Runner string `json:"runner,omitempty"` + RunnerWhen string `json:"runner_when,omitempty"` + Run string `json:"run,omitempty"` + Packages []string `json:"packages,omitempty"` + NodeNames []string `json:"node-names,omitempty"` +} + +// ComposeJob asks one compose-candidate build. +type ComposeJob struct { + Fetch Fetch `json:"fetch"` + Dir string `json:"dir"` + Package string `json:"package"` + Request json.RawMessage `json:"request"` + Reader string `json:"reader,omitempty"` +} + +// AskOf is any seat's ask (or this form) as the neutral StepAsk: the one conversion, since the three seats' types +// are the part's one layout. A value that does not fit the layout is refused. +func AskOf[T any](seatAsk T) (StepAsk, error) { + raw, err := json.Marshal(seatAsk) + if err != nil { + return StepAsk{}, fmt.Errorf("buildstep: an ask does not encode: %w", err) + } + return DecodeStepAsk(raw) +} + +// AskAs is the neutral ask as a seat's type T (the inverse of AskOf). +func AskAs[T any](a StepAsk) (T, error) { + var out T + raw, err := json.Marshal(a) + if err != nil { + return out, err + } + err = json.Unmarshal(raw, &out) + return out, err +} + +func (a StepAsk) check() error { + if err := CheckAskID(a.ID); err != nil { + return err + } + if err := checkAskKind(a.Kind); err != nil { + return err + } + if err := checkAskLanguage(a.Language); err != nil { + return err + } + if err := meshkeys.CheckName("asker", a.Asker); err != nil { + return err + } + if a.At.IsZero() { + return fmt.Errorf("buildstep: the ask %s names no time", a.ID) + } + jobs := 0 + for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil} { + if set { + jobs++ + } + } + switch { + case jobs != 1: + return fmt.Errorf("buildstep: the ask %s holds a build, a check or a compose, exactly one", a.ID) + case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo): + return fmt.Errorf("buildstep: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID) + case a.Build != nil && a.Kind == AskRecheck: + return fmt.Errorf("buildstep: the ask %s is a build on the recheck stream, which carries checks alone", a.ID) + case a.Check != nil && a.Kind == AskDelivery: + return fmt.Errorf("buildstep: the ask %s is a check on the delivery stream, which carries trunk builds alone", a.ID) + case a.Check != nil && a.Language == AskImage: + return fmt.Errorf("buildstep: the ask %s is a check of an image, which has no tests", a.ID) + case len(a.Needs) > 0 && a.Check == nil: + return fmt.Errorf("buildstep: the ask %s names needs and is no check, whose tests alone need a node", a.ID) + case len(a.Packages) > 0 && a.Compose != nil: + return fmt.Errorf("buildstep: the ask %s is a compose and names package versions, which a compose does not take", a.ID) + } + if err := a.Packages.Check(); err != nil { + return fmt.Errorf("the ask %s: %w", a.ID, err) + } + for _, n := range a.Needs { + if err := CheckNeed(n); err != nil { + return fmt.Errorf("the ask %s: %w", a.ID, err) + } + } + switch { + case a.Build != nil: + return a.Build.check(a.ID) + case a.Compose != nil: + return a.Compose.check(a.ID) + } + return a.Check.check(a.ID) +} + +func (c ComposeJob) check(id string) error { + if err := c.Fetch.check(id); err != nil { + return err + } + if c.Dir == "" || c.Dir != path.Clean(c.Dir) || strings.HasPrefix(c.Dir, "/") || c.Dir == ".." || strings.HasPrefix(c.Dir, "../") { + return fmt.Errorf("buildstep: the compose ask %s names no clean directory inside the repository (%q)", id, c.Dir) + } + if c.Package == "" { + return fmt.Errorf("buildstep: the compose ask %s names no package to build", id) + } + if !json.Valid(c.Request) || len(c.Request) == 0 || c.Request[0] != '{' { + return fmt.Errorf("buildstep: the compose ask %s holds no request object", id) + } + return nil +} + +func (b BuildJob) check(id string) error { + if err := meshkeys.CheckName("module", b.Module); err != nil { + return err + } + if len(b.Artifacts) == 0 { + return fmt.Errorf("buildstep: the build ask %s names no artifact", id) + } + fetch := false + for _, a := range b.Artifacts { + if err := meshkeys.CheckName("artifact", a.Artifact); err != nil { + return err + } + if a.Seat != "" && !slices.Contains(RegistrySeats, a.Seat) { + return fmt.Errorf("buildstep: the build ask %s publishes %s through %q, which is no registry seat (%s)", id, + a.Artifact, a.Seat, strings.Join(RegistrySeats, ", ")) + } + if a.Version != "" && a.Seat == "" { + return fmt.Errorf("buildstep: the build ask %s gives %s the version %s and names no seat to publish it through", + id, a.Artifact, a.Version) + } + fetch = fetch || a.From == "" + } + if fetch { + if b.Path == "" { + return fmt.Errorf("buildstep: the build ask %s names no directory (path) for the module %s", id, b.Module) + } + if b.Fetch == nil { + return fmt.Errorf("buildstep: the ask %s fetches no repository /", id) + } + return b.Fetch.check(id) + } + return nil +} + +func (c CheckJob) check(id string) error { + if len(c.Levels) == 0 { + return fmt.Errorf("buildstep: the check ask %s names no level", id) + } + for _, l := range c.Levels { + if l != LevelUnit && l != LevelIntegration { + return fmt.Errorf("buildstep: the level %q is not %s or %s", l, LevelUnit, LevelIntegration) + } + } + for _, p := range c.Packages { + if p == "" || strings.ContainsAny(p, " \t\n") { + return fmt.Errorf("buildstep: the check ask %s names a package %q that is no import path", id, p) + } + } + seen := map[string]bool{} + for _, n := range c.NodeNames { + if err := meshkeys.CheckName("node", n); err != nil || strings.Contains(n, ",") || seen[n] { + return fmt.Errorf("buildstep: the check ask %s names the node %q, which is no node name or named twice", id, n) + } + seen[n] = true + } + return c.Fetch.check(id) +} + +func (f Fetch) check(id string) error { + if f.Repository == "" || !strings.Contains(f.Repository, "/") { + return fmt.Errorf("buildstep: the ask %s fetches no repository /", id) + } + if !askCommitOK.MatchString(f.Commit) { + return fmt.Errorf("buildstep: the ask %s fetches no full commit", id) + } + return nil +} + +// EncodeStepAsk is a as the bytes the asker puts on a build seat's stream: stamped with the version and UTC, checked. +func EncodeStepAsk(a StepAsk) ([]byte, error) { + a.V = StepAskVersion + a.At = a.At.UTC() + if err := a.check(); err != nil { + return nil, err + } + return json.Marshal(a) +} + +// DecodeStepAsk reads an ask, refusing another version, an unknown shape or a malformed ask. +func DecodeStepAsk(data []byte) (StepAsk, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return StepAsk{}, fmt.Errorf("buildstep: an ask is not JSON: %w", err) + } + if probe.V == nil || *probe.V != StepAskVersion { + return StepAsk{}, fmt.Errorf("%w: an ask of version %v, this reader reads %d", ErrUnknownVersion, probe.V, StepAskVersion) + } + var a StepAsk + if err := json.Unmarshal(data, &a); err != nil { + return StepAsk{}, fmt.Errorf("buildstep: an ask does not fit its shape: %w", err) + } + if err := a.check(); err != nil { + return StepAsk{}, err + } + return a, nil +} + +// PackageNeeds is what a package of a check needs of a node. +type PackageNeeds struct { + Package string `json:"package"` + Needs []string `json:"needs"` +} + +// StepDeclined is the result of a step a holder declined: its node lacks what the ask needs. +type StepDeclined struct { + Declined bool `json:"declined"` + Needs []string `json:"needs"` + Lacks []string `json:"lacks"` +} + +// DeclinedOf reads a refused step's result as a decline. +func DeclinedOf(step Step) (StepDeclined, bool) { + if step.State != StepRefused || len(step.Result) == 0 { + return StepDeclined{}, false + } + var d StepDeclined + if err := json.Unmarshal(step.Result, &d); err != nil || !d.Declined { + return StepDeclined{}, false + } + return d, true +} + +// UnmetNeeds are the needs the levels' unmet packages name, sorted, once each. +func UnmetNeeds(levels []LevelAnswer) []string { + seen := map[string]bool{} + var out []string + for _, l := range levels { + for _, u := range l.Unmet { + for _, n := range u.Needs { + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + } + } + sort.Strings(out) + return out +} + +// BuildAnswer is what a build ask's step answers. +type BuildAnswer struct { + Artifacts []AskBuilt `json:"artifacts"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` + Imports []string `json:"imports,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` +} + +// AskBuilt is one artifact a build made. +type AskBuilt struct { + Artifact string `json:"artifact"` + Digest string `json:"digest"` + Reference string `json:"reference,omitempty"` +} + +// CheckAnswer is what a check ask's step answers. +type CheckAnswer struct { + Fetch Fetch `json:"fetch"` + Levels []LevelAnswer `json:"levels,omitempty"` +} + +// ComposeAnswer is what a compose ask's step answers. +type ComposeAnswer struct { + Built bool `json:"built"` + Key string `json:"key,omitempty"` + Verdict json.RawMessage `json:"verdict,omitempty"` +} + +// LevelAnswer is one level of a check. +type LevelAnswer struct { + Level string `json:"level"` + Judged string `json:"judged,omitempty"` + Digest *LevelDigest `json:"digest,omitempty"` + Kept []KeptTaken `json:"kept,omitempty"` + Tests []json.RawMessage `json:"tests,omitempty"` + Unmet []PackageNeeds `json:"unmet,omitempty"` +} + +// LevelDigest is the digest of a level's tests by package. +type LevelDigest struct { + Level string `json:"level,omitempty"` + Packages []PackageDigest `json:"packages,omitempty"` + Beside []string `json:"beside,omitempty"` + WithoutTests []string `json:"without_tests,omitempty"` + JudgedBy string `json:"judged_by,omitempty"` + Needs []PackageNeeds `json:"needs,omitempty"` +} + +// PackageDigest is the digest of one package's tests. +type PackageDigest struct { + Package string `json:"package"` + Digest string `json:"digest"` +} + +// KeptTaken is a package whose kept verdict a level took instead of running its tests. +type KeptTaken struct { + Package string `json:"package"` + Digest string `json:"digest"` + Tests int `json:"tests"` +} + +// UnmarshalJSON reads the packages as the list or as the older map by package. +func (d *LevelDigest) UnmarshalJSON(raw []byte) error { + var v struct { + Level string `json:"level"` + Packages json.RawMessage `json:"packages"` + Beside []string `json:"beside"` + WithoutTests []string `json:"without_tests"` + JudgedBy string `json:"judged_by"` + Needs []PackageNeeds `json:"needs"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return err + } + var pkgs []PackageDigest + if p := strings.TrimSpace(string(v.Packages)); p != "" && p != "null" { + if strings.HasPrefix(p, "{") { + var m map[string]string + if err := json.Unmarshal(v.Packages, &m); err != nil { + return fmt.Errorf("a digest's packages: %w", err) + } + for pkg, digest := range m { + pkgs = append(pkgs, PackageDigest{Package: pkg, Digest: digest}) + } + } else if err := json.Unmarshal(v.Packages, &pkgs); err != nil { + return fmt.Errorf("a digest's packages: %w", err) + } + } + sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Package < pkgs[j].Package }) + *d = LevelDigest{Level: v.Level, Packages: pkgs, Beside: v.Beside, WithoutTests: v.WithoutTests, JudgedBy: v.JudgedBy, Needs: v.Needs} + return nil +} + +// NeedsOf are the needs of a package in the digest. +func (d *LevelDigest) NeedsOf(pkg string) []string { + if d == nil { + return nil + } + for _, n := range d.Needs { + if n.Package == pkg { + return n.Needs + } + } + return nil +} + +// Map is the digest as digest by package. +func (d *LevelDigest) Map() map[string]string { + if d == nil || len(d.Packages) == 0 { + return nil + } + m := make(map[string]string, len(d.Packages)) + for _, p := range d.Packages { + m[p.Package] = p.Digest + } + return m +} diff --git a/sdk/buildstep/ask_test.go b/sdk/buildstep/ask_test.go new file mode 100644 index 00000000..07b2d67a --- /dev/null +++ b/sdk/buildstep/ask_test.go @@ -0,0 +1,261 @@ +package buildstep + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const ( + testCommit = "0123456789abcdef0123456789abcdef01234567" + testAskID = "m2kx9a1b3c4d5e" +) + +func goodBuild() StepAsk { + return StepAsk{ID: testAskID, Kind: AskDelivery, Language: AskGo, Asker: "mesh-delivery-builder", + At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC), + Build: &BuildJob{Module: "nats", Path: "modules/nats", Fetch: &Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, + Artifacts: []AskArtifact{{Artifact: "go"}}}} +} + +func goodCheck() StepAsk { + return StepAsk{ID: testAskID, Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-checker", + At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC), + Check: &CheckJob{Fetch: Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, Levels: []string{LevelUnit}}} +} + +// The ask the three build seats share is one neutral form: it round-trips, and every ill-formed ask is refused, +// naming what is wrong (the rules of the old delivery.StepAsk, now the shared runner's). +func TestAskCheckAndRoundTrip(t *testing.T) { + for _, a := range []StepAsk{goodBuild(), goodCheck()} { + raw, err := EncodeStepAsk(a) + if err != nil { + t.Fatal(err) + } + back, err := DecodeStepAsk(raw) + if err != nil || back.ID != a.ID || back.V != StepAskVersion { + t.Fatalf("round trip: %+v, %v", back, err) + } + } + for _, c := range []struct { + name string + edit func(*StepAsk) + want string + }{ + {"no id", func(a *StepAsk) { a.ID = "x" }, "not an ask id"}, + {"unknown kind", func(a *StepAsk) { a.Kind = "build" }, "ask kind"}, + {"unknown language", func(a *StepAsk) { a.Language = "rust" }, "ask language"}, + {"no time", func(a *StepAsk) { a.At = time.Time{} }, "names no time"}, + {"recheck build", func(a *StepAsk) { a.Kind = AskRecheck }, "recheck stream"}, + {"two jobs", func(a *StepAsk) { a.Check = goodCheck().Check }, "exactly one"}, + {"needs on a build", func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, "names needs"}, + {"unknown registry seat", func(a *StepAsk) { a.Build.Artifacts[0].Seat = "somewhere" }, "no registry seat"}, + {"version without seat", func(a *StepAsk) { a.Build.Artifacts[0].Version = "1.0.0" }, "names no seat"}, + {"no path", func(a *StepAsk) { a.Build.Path = "" }, "names no directory"}, + {"short commit", func(a *StepAsk) { a.Build.Fetch.Commit = "abc" }, "full commit"}, + {"bad package pin", func(a *StepAsk) { a.Packages = PackageVersions{"m": "main"} }, "neither a full commit"}, + } { + a := goodBuild() + c.edit(&a) + if _, err := EncodeStepAsk(a); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want %q", c.name, err, c.want) + } + } + chk := goodCheck() + chk.Check.Levels = []string{"nightly"} + if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "nightly") { + t.Errorf("a level: %v", err) + } + chk = goodCheck() + chk.Language = AskImage + if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "no tests") { + t.Errorf("an image check: %v", err) + } + if _, err := DecodeStepAsk([]byte(`{"v":2}`)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("another version: %v", err) + } +} + +// The seat's value and the neutral form are the same JSON: AskOf takes any seat's type (here its shape, with a +// pointer time), AskAs gives one back. +func TestAskOfAnySeatsType(t *testing.T) { + type seatAsk struct { + V int64 `json:"v,omitempty"` + ID string `json:"id"` + Kind string `json:"kind"` + Lang string `json:"language"` + Asker string `json:"asker"` + At *time.Time `json:"at,omitempty"` + Check *struct { + Fetch Fetch `json:"fetch"` + Levels []string `json:"levels"` + } `json:"check,omitempty"` + } + at := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + s := seatAsk{V: 1, ID: testAskID, Kind: AskCheck, Lang: AskGo, Asker: "mesh-delivery-checker", At: &at} + s.Check = &struct { + Fetch Fetch `json:"fetch"` + Levels []string `json:"levels"` + }{Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, []string{LevelUnit}} + a, err := AskOf(s) + if err != nil || a.Check == nil || !a.At.Equal(at) || a.Check.Fetch.Commit != testCommit { + t.Fatalf("%+v %v", a, err) + } + back, err := AskAs[seatAsk](a) + if err != nil || back.ID != s.ID || !back.At.Equal(at) { + t.Fatalf("%+v %v", back, err) + } + s.Check = nil + if _, err := AskOf(s); err == nil { + t.Error("an ask with no job is accepted") + } +} + +func TestAskSubjectsStreamsAndKeys(t *testing.T) { + for _, l := range AskLanguages { + for _, k := range AskKinds { + subj, err := AskSubject(l, k) + if err != nil { + t.Fatal(err) + } + gl, gk, err := ParseAskSubject(subj) + if err != nil || gl != l || gk != k { + t.Errorf("%s: %s %s %v", subj, gl, gk, err) + } + } + } + if got, _ := AskWorker(AskGo, AskCheck); got != "SEAT_NODE_BUILD_GO_CHECK_worker" { + t.Errorf("worker %q", got) + } + if got, _ := AskStream(AskImage); got != "SEAT_NODE_BUILD_IMAGE" { + t.Errorf("stream %q", got) + } + if _, _, err := ParseAskSubject("mesh.seat.node-build-go.accept.nothing"); err == nil { + t.Error("an unknown kind parses") + } + if _, err := AskSubject("rust", AskCheck); err == nil { + t.Error("an unknown language has a subject") + } + if k, err := AskStepKey("ace", testAskID); err != nil || k != "ace."+testAskID { + t.Errorf("%q %v", k, err) + } + if w, _ := AskStepWatch(testAskID); w != "*."+testAskID { + t.Errorf("watch %q", w) + } + if k, _ := PauseKey("ace"); k != "paused.ace" { + t.Errorf("pause key %q", k) + } + if k, _ := CapacityKey("ace"); k != "capacity.ace" { + t.Errorf("capacity key %q", k) + } + if _, err := CapacityKey("a.b"); err == nil { + t.Error("a dotted node keys") + } +} + +func TestDeclinesNeedsAndLevelDigest(t *testing.T) { + if got := Lacks([]string{NeedNodeBuildNode}, nil); len(got) != 1 { + t.Errorf("a nil has lacks all: %v", got) + } + if got := Lacks([]string{NeedNodeBuildNode}, func(string) bool { return true }); len(got) != 0 { + t.Errorf("%v", got) + } + step := Step{State: StepRefused, Result: json.RawMessage(`{"declined":true,"needs":["node-build-node"],"lacks":["node-build-node"]}`)} + if d, ok := DeclinedOf(step); !ok || d.Lacks[0] != NeedNodeBuildNode { + t.Errorf("%+v %v", d, ok) + } + step.State = StepFailed + if _, ok := DeclinedOf(step); ok { + t.Error("a failed step is a decline") + } + var l LevelAnswer + raw := `{"level":"unit","digest":{"packages":{"b":"2","a":"1"},"needs":[{"package":"a","needs":["node-build-node"]}]},"unmet":[{"package":"a","needs":["node-build-node"]}]}` + if err := json.Unmarshal([]byte(raw), &l); err != nil { + t.Fatal(err) + } + if p := l.Digest.Packages; len(p) != 2 || p[0].Package != "a" || l.Digest.Map()["b"] != "2" || l.Digest.NeedsOf("a")[0] != NeedNodeBuildNode { + t.Errorf("%+v", l.Digest) + } + if got := UnmetNeeds([]LevelAnswer{l, l}); len(got) != 1 { + t.Errorf("%v", got) + } +} + +func TestPauseCapacityAndComposerKey(t *testing.T) { + raw, err := EncodePause(Pause{Paused: true, By: "operator@shanks", At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC)}) + if err != nil { + t.Fatal(err) + } + if p, err := DecodePause(raw); err != nil || !p.Paused || p.V != Version { + t.Fatalf("%+v %v", p, err) + } + if _, err := DecodePause([]byte(`{"v":9}`)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("%v", err) + } + craw, _ := EncodeCapacity(Capacity{Effective: 2, Configured: 2, Why: "fits"}) + if c, err := DecodeCapacity(craw); err != nil || c.Effective != 2 { + t.Fatalf("%+v %v", c, err) + } + tc := ToolchainUsed{Language: "go", Version: "1.26.0", Recipe: "1"} + if k := ComposerKey("novox/r", testCommit, "d", "p", tc); k != ComposerKey("novox/r", testCommit, "d", "p", tc) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 { + t.Errorf("composer key %q", k) + } + if ComposerKey("novox/r", testCommit, "d", "p", tc) == ComposerKey("novox/r", testCommit, "d2", "p", tc) { + t.Error("the directory is not in the key") + } +} + +// The part `build-step` is one layout for the three build seats: each seat's fixtures read strictly in the neutral +// forms (an unknown field, or a field this package lacks, fails). Where the generator cannot share a part, this is +// what holds the three seats' types and this form equal. +func TestTheThreeSeatsFixturesFitTheNeutralForms(t *testing.T) { + strict := func(t *testing.T, raw json.RawMessage, into any) { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(into); err != nil { + t.Error(err) + } + } + seen := 0 + for _, seat := range []string{"node-build-go", "node-build-image", "node-build-node"} { + raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "seats", seat, "fixtures", "states.json")) + if err != nil { + t.Fatal(err) + } + var fixtures []struct { + Name string `json:"name"` + Of string `json:"of"` + Given json.RawMessage `json:"given"` + } + if err := json.Unmarshal(raw, &fixtures); err != nil { + t.Fatal(err) + } + for _, f := range fixtures { + t.Run(seat+"/"+f.Name, func(t *testing.T) { + switch { + case strings.HasPrefix(f.Of, "accept:"): + seen++ + strict(t, f.Given, &StepAsk{}) + case strings.HasPrefix(f.Of, "state:pauses/paused."): + seen++ + strict(t, f.Given, &Pause{}) + case strings.HasPrefix(f.Of, "state:pauses/capacity."): + seen++ + strict(t, f.Given, &Capacity{}) + case strings.HasPrefix(f.Of, "state:steps/"): + seen++ + strict(t, f.Given, &Step{}) + } + }) + } + } + if seen < 12 { + t.Errorf("only %d fixtures read", seen) + } +} diff --git a/sdk/buildstep/pause.go b/sdk/buildstep/pause.go new file mode 100644 index 00000000..9ce027b9 --- /dev/null +++ b/sdk/buildstep/pause.go @@ -0,0 +1,107 @@ +package buildstep + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" +) + +// The states `pauses` of the build seats, whose three seats hold the same layouts (the part `build-step`): a node's +// pause under `paused.`, its capacity under `capacity.`. + +// PausesState is the name of the state. +const PausesState = "pauses" + +// PauseKey is the key of a node's pause. +func PauseKey(node string) (string, error) { + if err := checkNode(node); err != nil { + return "", err + } + return "paused." + node, nil +} + +// CapacityKey is the key of a node's capacity. +func CapacityKey(node string) (string, error) { + if err := checkNode(node); err != nil { + return "", err + } + return "capacity." + node, nil +} + +// Pause is whether a node takes no new ask, and who said so. +type Pause struct { + V int `json:"v"` + Paused bool `json:"paused"` + By string `json:"by,omitempty"` + At time.Time `json:"at,omitzero"` +} + +// EncodePause is p as a pauses value. +func EncodePause(p Pause) ([]byte, error) { + p.V = Version + p.At = p.At.UTC() + return json.Marshal(p) +} + +// DecodePause reads a pause, refusing another version. +func DecodePause(data []byte) (Pause, error) { + var p Pause + if err := decodeVersioned(data, &p, func() int { return p.V }); err != nil { + return Pause{}, err + } + return p, nil +} + +// Capacity is how many steps a node runs at once, and why. +type Capacity struct { + V int `json:"v"` + Effective int `json:"effective"` + Configured int `json:"configured"` + ConfiguredFrom string `json:"configured-from,omitempty"` + QuotaCores float64 `json:"quota-cores,omitempty"` + QuotaFrom string `json:"quota-from"` + CoresPerStep float64 `json:"cores-per-step"` + ByCPU int `json:"by-cpu,omitempty"` + BuildHours string `json:"build-hours,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + BuildHoursFault string `json:"build-hours-fault,omitempty"` + Ineligible string `json:"ineligible,omitempty"` + Why string `json:"why"` + At time.Time `json:"at,omitzero"` +} + +// EncodeCapacity is c as a pauses value. +func EncodeCapacity(c Capacity) ([]byte, error) { + c.V = Version + c.At = c.At.UTC() + return json.Marshal(c) +} + +// DecodeCapacity reads a capacity, refusing another version. +func DecodeCapacity(data []byte) (Capacity, error) { + var c Capacity + if err := decodeVersioned(data, &c, func() int { return c.V }); err != nil { + return Capacity{}, err + } + return c, nil +} + +func decodeVersioned(data []byte, into any, version func() int) error { + if err := json.Unmarshal(data, into); err != nil { + return fmt.Errorf("buildstep: value does not fit its shape: %w", err) + } + if v := version(); v != Version { + return fmt.Errorf("%w: a value of version %d, this reader reads %d", ErrUnknownVersion, v, Version) + } + return nil +} + +// ComposerKey is the key a composer build is kept under: the fetched commit, the directory, the package and the +// toolchain it ran with. +func ComposerKey(repository, commit, dir, pkg string, toolchain ToolchainUsed) string { + sum := sha256.Sum256([]byte(strings.Join([]string{repository, commit, dir, pkg, toolchain.Version, toolchain.Recipe}, "\n"))) + return "composer-" + hex.EncodeToString(sum[:8]) +} From 840a7c5b07bad8d32d91a3dfbc961a7216fe9b4a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:50:31 +0100 Subject: [PATCH 3/4] =?UTF-8?q?mesh-delivery-sdk:=20the=20runner's=20stale?= =?UTF-8?q?-step=20part=20moves=20to=20package=20buildrun,=20so=20buildste?= =?UTF-8?q?p=20(layouts,=20keys,=20codecs)=20and=20the=20builder's=20publi?= =?UTF-8?q?shed=20builds=20package=20reach=20no=20bus;=20a=20test=20each?= =?UTF-8?q?=20refuses=20it=20(B6e=20finding=205;=20ADR=200634=20=C2=A71;?= =?UTF-8?q?=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../builds/busfree_test.go | 21 ++++++ sdk/buildrun/keys_test.go | 52 ++++++++++++++ sdk/{buildstep => buildrun}/stale.go | 70 ++++++++++--------- sdk/{buildstep => buildrun}/stale_test.go | 51 +++++++------- sdk/buildstep/busfree_test.go | 33 +++++++++ 5 files changed, 169 insertions(+), 58 deletions(-) create mode 100644 modules/mesh-delivery-builder/builds/busfree_test.go create mode 100644 sdk/buildrun/keys_test.go rename sdk/{buildstep => buildrun}/stale.go (83%) rename sdk/{buildstep => buildrun}/stale_test.go (69%) create mode 100644 sdk/buildstep/busfree_test.go diff --git a/modules/mesh-delivery-builder/builds/busfree_test.go b/modules/mesh-delivery-builder/builds/busfree_test.go new file mode 100644 index 00000000..6d4af86b --- /dev/null +++ b/modules/mesh-delivery-builder/builds/busfree_test.go @@ -0,0 +1,21 @@ +package builds + +import ( + "os/exec" + "strings" + "testing" +) + +// The builder's published value is read by modules that never touch the bus (mesh-host's composer tool): nothing it +// imports reaches the bus library. +func TestThePackageReachesNoBus(t *testing.T) { + out, err := exec.Command("go", "list", "-deps", ".").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, p := range strings.Fields(string(out)) { + if strings.HasPrefix(p, "github.com/nats-io/") { + t.Errorf("builds depends on %s", p) + } + } +} diff --git a/sdk/buildrun/keys_test.go b/sdk/buildrun/keys_test.go new file mode 100644 index 00000000..8ce937d1 --- /dev/null +++ b/sdk/buildrun/keys_test.go @@ -0,0 +1,52 @@ +package buildrun + +import ( + "encoding/json" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// keys is a steps state in memory: every value put, in order, per key, and a signal for each end. +type keys struct { + mu sync.Mutex + puts map[string][]buildstep.Step + ended chan string +} + +func newKeys() *keys { return &keys{puts: map[string][]buildstep.Step{}, ended: make(chan string, 16)} } + +func (k *keys) put(key string, value any) (uint64, error) { + step, err := buildstep.DecodeStep(value.(json.RawMessage)) + if err != nil { + return 0, err + } + k.mu.Lock() + k.puts[key] = append(k.puts[key], step) + n := uint64(len(k.puts[key])) + k.mu.Unlock() + if step.State != buildstep.StepRunning { + k.ended <- key + } + return n, nil +} + +func (k *keys) last(key string) buildstep.Step { + k.mu.Lock() + defer k.mu.Unlock() + s := k.puts[key] + return s[len(s)-1] +} + +func (k *keys) waitEnd(t *testing.T) string { + t.Helper() + select { + case key := <-k.ended: + return key + case <-time.After(3 * time.Second): + t.Fatal("the step never ended on its key") + return "" + } +} diff --git a/sdk/buildstep/stale.go b/sdk/buildrun/stale.go similarity index 83% rename from sdk/buildstep/stale.go rename to sdk/buildrun/stale.go index 7e0de98e..6e0b6075 100644 --- a/sdk/buildstep/stale.go +++ b/sdk/buildrun/stale.go @@ -1,10 +1,11 @@ -package buildstep +package buildrun import ( "context" "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "os" "strconv" "strings" @@ -20,7 +21,7 @@ const RestartReason = "the build module restarted while it ran" // for this process's, which may still run it, so EndOne refuses it. var Boot = time.Now() -// Owner is this process as a step's owner (Step.Owner): `..`, the node's boot id +// Owner is this process as a step's owner (buildstep.Step.Owner): `..`, the node's boot id // (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart // that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's. var Owner = ownerOf(bootID, os.Getpid(), Boot) @@ -84,15 +85,15 @@ func ScanOf(s stdio.KeptState) Scan { // has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was // running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key. // A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound -// has passed (StepBound), when it is nobody's. -func isAsk(key string, s Step) bool { - _, _, err := ParseAskStepKey(key) +// has passed (buildstep.StepBound), when it is nobody's. +func isAsk(key string, s buildstep.Step) bool { + _, _, err := buildstep.ParseAskStepKey(key) return err == nil && strings.HasSuffix(s.Tool, " ask") } // AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and // fingerprint. -func AskWhat(a Ask) string { +func AskWhat(a buildstep.Ask) string { short := func(s string) string { if len(s) > 12 { return s[:12] @@ -122,9 +123,9 @@ func AskWhat(a Ask) string { } // endRunning writes step ended failed with why, only while its key is still at revision. -func endRunning(scan Scan, key string, step Step, revision uint64, why string) error { - step.State, step.Reason, step.Ended, step.Result = StepFailed, why, now().UTC(), nil - raw, err := EncodeStep(step) +func endRunning(scan Scan, key string, step buildstep.Step, revision uint64, why string) error { + step.State, step.Reason, step.Ended, step.Result = buildstep.StepFailed, why, now().UTC(), nil + raw, err := buildstep.EncodeStep(step) if err != nil { return err } @@ -134,7 +135,7 @@ func endRunning(scan Scan, key string, step Step, revision uint64, why string) e // EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys // of node are read; a value that is no step is left alone. -// - A hand-started step with an owner (Step.Owner) that is not this process (Owner) is ended failed with +// - A hand-started step with an owner (buildstep.Step.Owner) that is not this process (Owner) is ended failed with // RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would // write its end (issue 1890, note 21). // - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the @@ -146,7 +147,7 @@ func endRunning(scan Scan, key string, step Step, revision uint64, why string) e // - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it. // - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before // `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its -// start is older than StepBound, and then ended "past the step bound: its asker stopped waiting at