diff --git a/sdk/buildstep/capacity.go b/sdk/buildstep/capacity.go new file mode 100644 index 00000000..098f6570 --- /dev/null +++ b/sdk/buildstep/capacity.go @@ -0,0 +1,7 @@ +package buildstep + +// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how +// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names +// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config) +// lays the node's value into the module's settings file. +const CapacitySetting = "capacity" diff --git a/sdk/buildstep/doc.go b/sdk/buildstep/doc.go new file mode 100644 index 00000000..93c1feb7 --- /dev/null +++ b/sdk/buildstep/doc.go @@ -0,0 +1,5 @@ +// Package buildstep is the contract of a build step every delivery part reads (novox/hq ADR 0593 §1): a step's id, the +// fetch request, a step a previous process left running and its end, a failure's words, a module tree's shape, the +// capacity setting and the launchers' names. The runner the build modules share is the Go build module's own package +// (mesh-catalog modules/mesh-build-go/buildstep), which imports this one. +package buildstep diff --git a/sdk/buildstep/failure.go b/sdk/buildstep/failure.go new file mode 100644 index 00000000..bdea5581 --- /dev/null +++ b/sdk/buildstep/failure.go @@ -0,0 +1,141 @@ +package buildstep + +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/sdkversion" +) + +// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test +// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have +// always used; File is new and an older reader ignores it. +type Failure struct { + // Package is the Go package's import path, or the TypeScript package's directory in the workspace. + Package string `json:"package"` + // Test names the failing test, or the tests that were running when the program stopped; "" when none can be named. + Test string `json:"test,omitempty"` + // File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col). + File string `json:"file,omitempty"` + // Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first + // error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine). + Cause string `json:"cause,omitempty"` + // Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads + // only its first line. + Output string `json:"output,omitempty"` +} + +// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long +// assertion message and stays far under a bus message's or a verdict's bound. +const ( + OutputBytes = 4096 + LineBytes = 1024 +) + +// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides +// its cause ("the build module hid the failure ()"). +func By(module string) string { + return module + " with mesh-sdk " + sdkversion.Version() +} + +// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any +// indent), go test's "ok \t", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output. +func PassingLine(l string) bool { + if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0 + return true + } + t := strings.TrimSpace(l) + switch { + case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"): + return true + } + return tapOk(t) +} + +// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line. +// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract. +func Hidden(f Failure) string { + c := strings.TrimSpace(f.Cause) + switch { + case c == "": + return "it sent no cause" + case PassingLine(c): + return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c)) + } + return "" +} + +// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier +// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no +// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line. +// Passing lines are dropped. +func Clip(lines []string, cause string) string { + own := make([]string, 0, len(lines)) + for _, l := range lines { + if !PassingLine(l) { + own = append(own, l) + } + } + lines = own + size, from := 0, len(lines) + for from > 0 { + n := len(CutLine(lines[from-1])) + 1 + if size+n > OutputBytes && from < len(lines) { + break + } + size += n + from-- + } + kept := make([]string, 0, len(lines)-from+2) + head := strings.TrimSpace(cause) + for i := 0; head == "" && i < from; i++ { + head = strings.TrimSpace(lines[i]) + } + if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) { + kept = append(kept, CutLine(head)) + } + if from > 0 { + kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from)) + } + for _, l := range lines[from:] { + kept = append(kept, CutLine(l)) + } + return strings.Join(kept, "\n") +} + +// CutLine cuts a line to LineBytes, on a character boundary. +func CutLine(l string) string { + if len(l) <= LineBytes { + return l + } + return strings.ToValidUTF8(l[:LineBytes], "") + "..." +} + +// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the +// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a +// heading ("# ", npm's "> script"), and its output the command's lines without passing ones. +func ConsoleFailure(pkg, test, out string) Failure { + var lines []string + for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") { + if t := strings.TrimSpace(l); t != "" && !PassingLine(t) { + lines = append(lines, strings.TrimRight(l, " \t")) + } + } + cause := "" + for _, l := range lines { + if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) { + cause = t + break + } + } + for _, l := range lines { + if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") { + cause = t + } + } + if cause == "" { + cause = test + " failed and wrote nothing that says why" + } + cause = CutLine(cause) + return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)} +} diff --git a/sdk/buildstep/helpers.go b/sdk/buildstep/helpers.go new file mode 100644 index 00000000..d9f0b98d --- /dev/null +++ b/sdk/buildstep/helpers.go @@ -0,0 +1,19 @@ +package buildstep + +import ( + "regexp" + "strings" +) + +// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error. +var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `) + +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") } + +// tapOk says a line is a passing test's "ok N - name", indented or not. +func tapOk(l string) bool { + rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ") + return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9' +} diff --git a/sdk/buildstep/launchers.go b/sdk/buildstep/launchers.go new file mode 100644 index 00000000..975f5603 --- /dev/null +++ b/sdk/buildstep/launchers.go @@ -0,0 +1,14 @@ +package buildstep + +import "git.novox.be/novox/mesh-sdk/go/catalogue" + +// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as +// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says. +const LauncherSuffix = catalogue.LauncherSuffix + +// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root +// (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share +// (R4e, issue 1126), so the name a build writes is the name a reader expects. +func Launchers(language string, entrypoints []string) map[string]string { + return catalogue.Launchers(language, entrypoints) +} diff --git a/sdk/buildstep/launchers_test.go b/sdk/buildstep/launchers_test.go new file mode 100644 index 00000000..ddea9a0d --- /dev/null +++ b/sdk/buildstep/launchers_test.go @@ -0,0 +1,22 @@ +package buildstep + +import ( + "reflect" + "testing" +) + +// A TypeScript bundle's entrypoint x.js is started as x.serve.mjs beside it (novox/hq ADR 0193), the rule mesh-build-node +// and build-agent both apply; an entrypoint that is not .js gets none, and another language gets none. +func TestATypeScriptEntrypointIsStartedByTheLauncherBesideIt(t *testing.T) { + got := Launchers("typescript", []string{"index.js", "tools/x.js", "data.json"}) + want := map[string]string{"index.js": "index.serve.mjs", "tools/x.js": "tools/x.serve.mjs"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Launchers(typescript) = %v, want %v", got, want) + } + if got := Launchers("go", []string{"index.js"}); len(got) != 0 { + t.Fatalf("a Go bundle got launchers: %v", got) + } + if got := Launchers("typescript", nil); len(got) != 0 { + t.Fatalf("a bundle with no entrypoint got launchers: %v", got) + } +} diff --git a/sdk/buildstep/request.go b/sdk/buildstep/request.go new file mode 100644 index 00000000..9cc961ff --- /dev/null +++ b/sdk/buildstep/request.go @@ -0,0 +1,161 @@ +package buildstep + +import ( + "fmt" + "maps" + "path" + "regexp" + "strings" +) + +var ( + requestCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) + requestRepositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`) +) + +// FetchRequest is what a build module's fetch tool is asked: the repository and the full commit, the pull request's +// ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, and, for a build of a +// module whose every artifact names its paths, that module's directory and its named paths (novox/hq ADR 0499 clause 4). +// +// **The paths come from the builder, not from a manifest** (ADR 0495 clause 1; the lead's decision L8 of R4e): the +// builder fills Dir and Paths from the indexer's Module value (Path, and KeptInputs2.Paths' keys), and the build module +// gives the build and test steps a workspace holding those paths, the manifest and its lock files. A request without +// them builds from the module's directory, as before. +type FetchRequest struct { + 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 are the version of each mesh package the build or the check builds against (Ask.Packages, + // novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version + // the repository names, checksum-verified, and names them in its log. + Packages PackageVersions `json:"packages,omitempty"` +} + +// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input. +func FetchRequestProperties() map[string]any { + return map[string]any{ + "dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"}, + "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"}, + "packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, + "description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"}, + } +} + +// Args is r as a tool call's arguments. +func (r FetchRequest) Args() map[string]any { + args := map[string]any{"repository": r.Repository, "commit": r.Commit} + for k, v := range map[string]string{"ref": r.Ref, "base": r.Base, "dir": r.Dir} { + if v != "" { + args[k] = v + } + } + if len(r.Paths) > 0 { + args["paths"] = append([]string(nil), r.Paths...) + } + if len(r.Packages) > 0 { + args["packages"] = map[string]any{} + for p, v := range r.Packages { + args["packages"].(map[string]any)[p] = v + } + } + return args +} + +// ParseFetchRequest reads a fetch tool's arguments; arguments it does not know (a build module's own) are left to the +// caller. It refuses a repository that is not /, a commit or a base that is not 40 hex digits, paths with no +// module directory or a directory with no path, a directory leaving the repository and a path leaving the directory, +// naming each. +func ParseFetchRequest(args map[string]any) (FetchRequest, error) { + var r FetchRequest + r.Repository, _ = args["repository"].(string) + r.Commit, _ = args["commit"].(string) + r.Ref, _ = args["ref"].(string) + r.Base, _ = args["base"].(string) + r.Dir, _ = args["dir"].(string) + switch v := args["paths"].(type) { + case nil: + case []string: + r.Paths = append([]string(nil), v...) + case []any: + for _, p := range v { + s, ok := p.(string) + if !ok { + return FetchRequest{}, fmt.Errorf("paths is a list of strings") + } + r.Paths = append(r.Paths, s) + } + default: + return FetchRequest{}, fmt.Errorf("paths is a list of strings") + } + packages, err := PackagesArg(args["packages"]) + if err != nil { + return FetchRequest{}, err + } + r.Packages = packages + return r, r.Check() +} + +// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives; +// nil for none. +func PackagesArg(v any) (PackageVersions, error) { + out := PackageVersions{} + switch v := v.(type) { + case nil: + return nil, nil + case PackageVersions: + maps.Copy(out, v) + case map[string]string: + maps.Copy(out, v) + case map[string]any: + for p, version := range v { + s, ok := version.(string) + if !ok { + return nil, fmt.Errorf("packages is an object of versions (strings), by package") + } + out[p] = s + } + default: + return nil, fmt.Errorf("packages is an object of versions (strings), by package") + } + if len(out) == 0 { + return nil, nil + } + return out, out.Check() +} + +// Check is what ParseFetchRequest refuses. +func (r FetchRequest) Check() error { + if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { + return fmt.Errorf("%q is not a repository, /", r.Repository) + } + if !requestCommitOK.MatchString(r.Commit) { + return fmt.Errorf("%q is not a full commit (40 hex digits)", r.Commit) + } + if r.Base != "" && !requestCommitOK.MatchString(r.Base) { + return fmt.Errorf("%q is not a trunk commit (40 hex digits)", r.Base) + } + switch { + case len(r.Paths) > 0 && r.Dir == "": + return fmt.Errorf("the request names paths and names no module directory (dir) they are relative to") + case len(r.Paths) == 0 && r.Dir != "": + return fmt.Errorf("the request names a module directory and no path") + } + if r.Dir != "" && (outside(r.Dir) || strings.HasPrefix(r.Dir, "/")) { + return fmt.Errorf("the module directory %q leaves the repository", r.Dir) + } + for _, p := range r.Paths { + if p == "" || strings.HasPrefix(p, "/") || outside(p) || path.Clean(p) == "." { + return fmt.Errorf("the path %q leaves the module's directory or is not a path in it", p) + } + } + return nil +} + +func outside(p string) bool { + c := path.Clean(p) + return c == ".." || strings.HasPrefix(c, "../") +} diff --git a/sdk/buildstep/request_test.go b/sdk/buildstep/request_test.go new file mode 100644 index 00000000..7732329f --- /dev/null +++ b/sdk/buildstep/request_test.go @@ -0,0 +1,64 @@ +package buildstep + +import ( + "reflect" + "strings" + "testing" +) + +// A build's fetch is handed the paths of a module that names them (novox/hq ADR 0499 clause 4): the builder fills them +// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495 +// clause 1). The request survives the trip through a tool call's arguments. +func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { + // And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4). + req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head", + Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}} + args := req.Args() + // A tool call's arguments arrive as JSON: lists of any, objects of any. + args["paths"] = []any{"package.json", "src"} + if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) { + t.Fatalf("the packages are not in the call's arguments: %v", args["packages"]) + } + got, err := ParseFetchRequest(args) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, req) { + t.Fatalf("got %+v, want %+v", got, req) + } + plain, err := ParseFetchRequest(map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40), + "base": strings.Repeat("c", 40), "beside": []any{}}) + if err != nil || plain.Paths != nil || plain.Base != strings.Repeat("c", 40) { + t.Fatalf("a request without paths: %+v, %v", plain, err) + } + + ok := map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40)} + for _, c := range []struct { + set map[string]any + says string + }{ + {map[string]any{"repository": "novox"}, "is not a repository"}, + {map[string]any{"repository": "novox/../x"}, "is not a repository"}, + {map[string]any{"commit": "abc"}, "is not a full commit"}, + {map[string]any{"base": "abc"}, "is not a trunk commit"}, + {map[string]any{"paths": []any{"src"}}, "names no module directory"}, + {map[string]any{"dir": "modules/a", "paths": []any{"../b"}}, "leaves the module's directory"}, + {map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"}, + {map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"}, + {map[string]any{"dir": "modules/a"}, "names a module directory and no path"}, + {map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "main"}}, "neither a full commit nor a Go semantic version"}, + {map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"}, + {map[string]any{"packages": []any{"x"}}, "packages is an object"}, + } { + args := map[string]any{} + for k, v := range ok { + args[k] = v + } + for k, v := range c.set { + args[k] = v + } + if _, err := ParseFetchRequest(args); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%v: %v, wanted a refusal saying %q", c.set, err, c.says) + } + } +} diff --git a/sdk/buildstep/stale.go b/sdk/buildstep/stale.go new file mode 100644 index 00000000..7e0de98e --- /dev/null +++ b/sdk/buildstep/stale.go @@ -0,0 +1,329 @@ +package buildstep + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// RestartReason is why a step a previous process left running is ended. +const RestartReason = "the build module restarted while it ran" + +// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken +// 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 +// (/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) + +// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once. +var bootID = readBootID() + +const noBootID = "no-boot-id" + +func ownerOf(bootID string, pid int, start time.Time) string { + return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36) +} + +// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes +// apart). +func readBootID() string { + b, err := os.ReadFile("/proc/sys/kernel/random/boot_id") + if id := strings.TrimSpace(string(b)); err == nil && id != "" { + return id + } + return noBootID +} + +// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for +// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key +// still at the revision read; both fail with stdio.ErrChanged when another writer was first. +type Scan struct { + Keys func() ([]string, error) + Get func(key string) (json.RawMessage, uint64, error) + Update func(key string, value any, revision uint64) (uint64, error) + Delete func(key string, revision uint64) error +} + +// ScanOf is the Scan of a state the process keeps. +// +// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's +// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is +// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves +// the steps state until its run writes its end, which its asker still reads (a watch takes puts only). +func ScanOf(s stdio.KeptState) Scan { + get := func(key string) (json.RawMessage, uint64, error) { + e, err := s.Get(key) + if err != nil || e == nil { + return nil, 0, err + } + return e.Value, e.Revision, nil + } + return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error { + _, rev, err := get(key) + if err != nil { + return err + } + if rev != revision { + return stdio.ErrChanged + } + return s.Delete(key) + }} +} + +// isAsk says a step is a pulled ask's: its key is . and its tool is " ask" (a hand-started step +// 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) + 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 { + short := func(s string) string { + if len(s) > 12 { + return s[:12] + } + return s + } + str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v } + parts := []string{a.Kind + " ask"} + switch { + case a.Check != nil: + parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ",")) + if a.Check.Run != "" { + parts = append(parts, "run "+a.Check.Run) + } + case a.Build != nil: + parts = append(parts, "module "+a.Build.Module) + if r := str(a.Build.Fetch, "repository"); r != "" { + parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit"))) + } + if a.Build.FP != "" { + parts = append(parts, "fp "+short(a.Build.FP)) + } + case a.Compose != nil: + parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit"))) + } + return strings.Join(parts, ", ") +} + +// 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) + if err != nil { + return err + } + _, err = scan.Update(key, json.RawMessage(raw), revision) + return err +} + +// 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 +// 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 +// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its +// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its +// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running +// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it +// held one of a build node's two slots). +// - 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