Compare commits

..
Author SHA1 Message Date
jschoubben 831f34e59a mesh-build-go: the real-go integration tests expect the tests to run after a failed gofmt or vet (ADR 0591)
mesh/delivery-group group fix/check-every-failure checking: 2 of 2 member(s) ready
mesh/delivery closed: the pull request closed unmerged
mesh/check checking 831f34e59ac7 on c52afdf88b84 | novox/mesh-catalog#596 in group fix/check-every-failure
The group's check named TestUnitLevelRunsTheRealGoOnTheFixtureRepositories and
TestAnUnformattedFileFailsTheRealUnitLevel: both asserted the old stop after the lint.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 17:00:30 +01:00
jschoubben 86be014907 mesh-build-go, mesh-build-node: Pin mesh-sdk at 43788bb, the group's head: a check ask runs every level after a failure
mesh/delivery-group group fix/check-every-failure rejected: composed together: the checker's verdict at the members' heads (468d3ec8bcb2): integration: meshbui…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (2 tests): integration: meshbuildgo/cmd/mes… | 86be0149073c on c52afdf88b84 | judged by the candidate's mesh-build-go at a42cbc56ec2e
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 16:33:12 +01:00
jschoubben 06358f94bd mesh-build-go: a unit level whose gofmt or vet fails still runs its tests, so one check names every failure
The operator, 2026-11-02: a failed check showed only its first fault, and a group needed a run per fault.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 16:32:28 +01:00
480 changed files with 2597 additions and 14989 deletions
-26
View File
@@ -10,9 +10,6 @@
// (`mesh-controller.<name>`, `seat:mesh-controller.<verb>`, `mesh-controller_<bucket>`), anywhere in the manifest; // (`mesh-controller.<name>`, `seat:mesh-controller.<verb>`, `mesh-controller_<bucket>`), anywhere in the manifest;
// - a manifest string that is the bare module name `mesh-controller` outside the module's own `config`, where a value // - a manifest string that is the bare module name `mesh-controller` outside the module's own `config`, where a value
// is the module's data (an issue owner naming the controller's repository is no dependency), not an address; // is the module's data (an issue owner naming the controller's repository is no dependency), not an address;
// - a go.mod of a module or of a check requiring the controller's repository (its module path, github.com/novox/mesh-controller,
// or the forge's git.novox.be/novox/mesh-controller): the repository is archived once its last module is delivered
// elsewhere (novox/hq ADR 0593 §11), so nothing here may be built from it;
// - a line of any module's file naming one of the controller's buckets (`mesh-controller_`): its buckets are removed // - a line of any module's file naming one of the controller's buckets (`mesh-controller_`): its buckets are removed
// after the controller's end (ADR 0590 §6), so a reader of one reads nothing. A test (`_test.go`, `.test.ts`) reads // after the controller's end (ADR 0590 §6), so a reader of one reads nothing. A test (`_test.go`, `.test.ts`) reads
// no live bucket, its names are its own data, and a `testdata` directory is a copy of text from elsewhere (a design, // no live bucket, its names are its own data, and a `testdata` directory is a copy of text from elsewhere (a design,
@@ -43,9 +40,6 @@ const (
// address is a string naming the controller's seat, one of its states, verbs or events, or one of its buckets. // address is a string naming the controller's seat, one of its states, verbs or events, or one of its buckets.
var address = regexp.MustCompile(`(^|[^A-Za-z0-9_-])(seat:)?mesh-controller([._][A-Za-z0-9])`) var address = regexp.MustCompile(`(^|[^A-Za-z0-9_-])(seat:)?mesh-controller([._][A-Za-z0-9])`)
// repository is a go.mod line naming the controller's repository by either of its module paths.
var repository = regexp.MustCompile(`(github\.com|git\.novox\.be)/novox/mesh-controller(/|\s|$)`)
// Refusals is each reason the check gives over the tree at root, sorted; none when it passes. // Refusals is each reason the check gives over the tree at root, sorted; none when it passes.
func Refusals(root string) ([]string, error) { func Refusals(root string) ([]string, error) {
var out []string var out []string
@@ -71,26 +65,6 @@ func Refusals(root string) ([]string, error) {
"consumes, waits on or uses it; name the seat that holds the job now (novox/hq ADR 0590 §5)", module, s)) "consumes, waits on or uses it; name the seat that holds the job now (novox/hq ADR 0590 §5)", module, s))
} }
} }
mods, err := filepath.Glob(filepath.Join(root, "*", "*", "go.mod"))
if err != nil {
return nil, err
}
sort.Strings(mods)
for _, f := range mods {
if rel, _ := filepath.Rel(root, f); !strings.HasPrefix(rel, "modules"+string(filepath.Separator)) && !strings.HasPrefix(rel, "checks"+string(filepath.Separator)) {
continue
}
raw, err := os.ReadFile(f)
if err != nil {
return nil, err
}
for n, l := range strings.Split(string(raw), "\n") {
if repository.MatchString(l) {
rel, _ := filepath.Rel(root, f)
out = append(out, fmt.Sprintf("%s:%d: requires the controller's repository: it is archived once its last module is built elsewhere, so nothing here is built from it (novox/hq ADR 0593 §11)", filepath.ToSlash(rel), n+1))
}
}
}
buckets, err := bucketLines(root) buckets, err := bucketLines(root)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -33,9 +33,6 @@ func TestRefusalsJudgeATree(t *testing.T) {
{"a requirement", map[string]string{"modules/d/module.json": `{"module":"d","requires":[{"name":"x","from":"mesh-controller"}]}`}, `d: names "mesh-controller"`}, {"a requirement", map[string]string{"modules/d/module.json": `{"module":"d","requires":[{"name":"x","from":"mesh-controller"}]}`}, `d: names "mesh-controller"`},
{"the controller's own module", map[string]string{"modules/mesh-controller/module.json": `{"module":"mesh-controller"}`}, "mesh-controller: the controller is no module of the catalogue"}, {"the controller's own module", map[string]string{"modules/mesh-controller/module.json": `{"module":"mesh-controller"}`}, "mesh-controller: the controller is no module of the catalogue"},
{"a bucket in code", map[string]string{"modules/e/cmd/e/main.go": "package main\nconst b = \"mesh-controller_assignments\"\n"}, "modules/e/cmd/e/main.go:2: names the controller's bucket"}, {"a bucket in code", map[string]string{"modules/e/cmd/e/main.go": "package main\nconst b = \"mesh-controller_assignments\"\n"}, "modules/e/cmd/e/main.go:2: names the controller's bucket"},
{"a requirement of the controller's repository", map[string]string{"modules/h/go.mod": "module h\n\nrequire github.com/novox/mesh-controller v0.0.0\n"}, "modules/h/go.mod:3: requires the controller's repository"},
{"its forge path in a check", map[string]string{"checks/i/go.mod": "module i\n\nrequire (\n\tgit.novox.be/novox/mesh-controller/modules/route-proxy v0.1.0\n)\n"}, "checks/i/go.mod:4"},
{"another repository of the forge passes", map[string]string{"modules/j/go.mod": "module j\n\nrequire git.novox.be/novox/mesh-sdk/go v0.1.0\n"}, ""},
{"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""}, {"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""},
{"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""}, {"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""},
{"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""}, {"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""},
+1 -1
View File
@@ -2,4 +2,4 @@ module checks/credentialform
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+2 -13
View File
@@ -111,24 +111,13 @@ func forgeRequirements(dir string) ([]requirement, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out)) return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
} }
var mod struct { var mod struct{ Require []requirement }
Require []requirement
Replace []struct{ Old, New requirement }
}
if err := json.Unmarshal(out, &mod); err != nil { if err := json.Unmarshal(out, &mod); err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %w", err) return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
} }
// A requirement the go.mod replaces to a directory is a package of the module's own repository (novox/hq ADR 0593 §1): it
// is in the tree under check, so it is fetched from nowhere.
local := map[string]bool{}
for _, r := range mod.Replace {
if r.New.Version == "" {
local[r.Old.Path] = true
}
}
var on []requirement var on []requirement
for _, r := range mod.Require { for _, r := range mod.Require {
if strings.HasPrefix(r.Path, forge) && !local[r.Path] { if strings.HasPrefix(r.Path, forge) {
on = append(on, r) on = append(on, r)
} }
} }
-25
View File
@@ -175,28 +175,3 @@ func TestTheRefOfAVersion(t *testing.T) {
} }
} }
} }
// ADR 0593 §1: a module requires a sibling package of its own repository and replaces it to the directory beside it; that
// package is no fetch from the forge (it is in the tree under check), so it is never looked for in a clone beside the check,
// and the module builds with the SDK taken from there.
func TestASiblingPackageReplacedToItsDirectoryIsNotFetched(t *testing.T) {
offline(t)
beside, commit := sdkBeside(t)
version := "v0.1.14-0.20261012082113-" + commit[:12]
root := t.TempDir()
sibling := "git.novox.be/novox/mesh-catalog/modules/builder/runner"
write(t, filepath.Join(root, "builder", "runner", "go.mod"), "module "+sibling+"\n\ngo 1.22\n\nrequire git.novox.be/novox/mesh-sdk/go "+version+"\n")
write(t, filepath.Join(root, "builder", "runner", "run.go"), "package runner\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\nfunc Run() string { return sdk.Hello() }\n")
dir := filepath.Join(root, "user")
write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/user\n\ngo 1.22\n\nrequire (\n\t"+sibling+" v0.0.0\n"+
"\tgit.novox.be/novox/mesh-sdk/go "+version+"\n)\n\nreplace "+sibling+" => ../builder/runner\n")
write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \""+sibling+"\"\n\nfunc main() { println(runner.Run()) }\n")
modfile, err := resolve(dir, beside, t.TempDir())
if err != nil {
t.Fatalf("resolve: %v", err)
}
if modfile == "" {
t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch the SDK from")
}
builds(t, dir, modfile)
}
+4 -4
View File
@@ -1,4 +1,4 @@
// Package failurekit refuses a build module whose tests do not run the build runner's conformance kit (issue 1901, its thorough // Package failurekit refuses a build module whose tests do not run mesh-sdk's conformance kit (issue 1901, its thorough
// fix): a module that claims a node-build-* seat and serves a test step must hold a test that calls conformance.Run, so // fix): a module that claims a node-build-* seat and serves a test step must hold a test that calls conformance.Run, so
// its parse of the test runner's output is held to the failure contract (a failing test named with its cause, never a // its parse of the test runner's output is held to the failure contract (a failing test named with its cause, never a
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is // passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
@@ -71,7 +71,7 @@ func Judge(root string) ([]Judged, error) {
} }
if !runs { if !runs {
refused = append(refused, fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+ refused = append(refused, fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+
"(mesh-build-go buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test)) "(mesh-sdk go/buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test))
} }
} }
// Every build module keeps steps, so every one runs the left-running case, test step or not (issue 1890). // Every build module keeps steps, so every one runs the left-running case, test step or not (issue 1890).
@@ -81,7 +81,7 @@ func Judge(root string) ([]Judged, error) {
} }
if !sweeps { if !sweeps {
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+ refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat)) "(mesh-sdk go/buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
} }
j.Refused = strings.Join(refused, "; ") j.Refused = strings.Join(refused, "; ")
out = append(out, j) out = append(out, j)
@@ -121,7 +121,7 @@ func runsTheKit(dir, call string) (bool, error) {
return err return err
} }
found = strings.Contains(string(raw), call) && found = strings.Contains(string(raw), call) &&
strings.Contains(string(raw), `"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance"`) strings.Contains(string(raw), `"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"`)
return nil return nil
}) })
return found, err return found, err
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// cases is refused, and a module that claims no node-build-* seat is not judged. // cases is refused, and a module that claims no node-build-* seat is not judged.
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) { func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
root := t.TempDir() root := t.TempDir()
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n" const imp = "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/buildstep/conformance\"\n\n"
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n" const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
for path, body := range map[string]string{ for path, body := range map[string]string{
+1 -1
View File
@@ -2,4 +2,4 @@ module checks/health
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+1 -1
View File
@@ -2,4 +2,4 @@ module checks/manifests
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
-44
View File
@@ -5,8 +5,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
) )
// The tree's manifests are ones a judge can read, and no module is named twice. A row is a catalogue of its own: a // The tree's manifests are ones a judge can read, and no module is named twice. A row is a catalogue of its own: a
@@ -50,45 +48,3 @@ func TestEveryManifestDecodesAndNamesOneModule(t *testing.T) {
t.Fatalf("the catalogue holds manifests no judge can read:\n%s", strings.Join(real, "\n")) t.Fatalf("the catalogue holds manifests no judge can read:\n%s", strings.Join(real, "\n"))
} }
} }
// No manifest of this catalogue claims the seat mesh-catalog: the indexer of the delivery holds it, and the module that
// held it before has dropped its claim (ADR 0604 §6). A row is a tree of its own: a manifest claiming it is named.
func TestNoManifestClaimsTheSeatMeshCatalog(t *testing.T) {
claimants := func(root string) []string {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
t.Fatal(err)
}
var out []string
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
m, err := catalogue.Decode(raw)
if err != nil {
continue // Problems names it
}
for _, c := range m.Claims {
if c.Name == "mesh-catalog" {
out = append(out, m.Module)
}
}
}
return out
}
row := t.TempDir()
if err := os.MkdirAll(filepath.Join(row, "modules", "a"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(row, "modules", "a", "module.json"),
[]byte(`{"module":"a","version":"1","claims":[{"name":"mesh-catalog","scope":"mesh"}]}`), 0o644); err != nil {
t.Fatal(err)
}
if got := claimants(row); len(got) != 1 || got[0] != "a" {
t.Fatalf("a manifest claiming the seat is named, got %v", got)
}
if got := claimants(filepath.Join("..", "..")); len(got) != 0 {
t.Errorf("the catalogue's manifests %v claim the seat mesh-catalog: the indexer of the delivery holds it", got)
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ module checks/readable
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/removedpackages
go 1.22
-166
View File
@@ -1,166 +0,0 @@
// Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that
// narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left
// mesh-sdk's buildstep for mesh-build-go's package.
//
// A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the
// pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin.
// This check refuses it at once, from the source alone:
//
// - an import of a package in Removed (or one under it);
// - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is
// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep).
//
// It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out).
//
// go run . <catalogue root>
//
// Exits 1 on a finding. The lists are the release's: a package that goes later is added here with the change.
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const sdk = "git.novox.be/novox/mesh-sdk/go"
// Removed are the packages of mesh-sdk that left it, each with where its logic is now.
var Removed = map[string]string{
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu",
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance",
"delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules",
"compose": "the node-engine, which alone composes (ADR 0593 §6)",
"compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)",
"compose/fromruntime": "the node-engine, which alone composes (ADR 0593 §6)",
"compose/judge": "the assigner's own package",
"takein": "the node-engine, which alone composes (ADR 0593 §6)",
"validate": "the node-engine, which alone composes (ADR 0593 §6)",
"facts": "nothing: the controller they served is ended (ADR 0590)",
"example": "nothing: no module imports it (ADR 0593 §12)",
"packageguard": "nothing: a test helper of mesh-sdk's own",
}
// BuildstepContract are the names mesh-sdk's buildstep keeps: the failure contract, the fetch request, the stale-step sweep,
// the step id and the module tree shape the delivery parts read.
var BuildstepContract = map[string]bool{
"AskWhat": true, "Boot": true, "By": true, "CapacitySetting": true, "Clip": true, "ConsoleFailure": true, "CutLine": true,
"EndOne": true, "EndStale": true, "EndStaleAtStart": true, "EndStaleRound": true, "Failure": true, "FetchRequest": true,
"FetchRequestProperties": true, "Hidden": true, "Launchers": true, "LauncherSuffix": true, "LineBytes": true,
"ModuleTree": true, "NewID": true, "OutputBytes": true, "Owner": true, "PackagesArg": true, "ParseFetchRequest": true,
"PassingLine": true, "RestartReason": true, "Scan": true, "ScanOf": true, "StaleAttempts": true, "StaleRetry": true,
"StaleServed": true,
}
// Findings is every refusal in the Go sources under root, sorted.
func Findings(root string) ([]string, error) {
var out []string
for _, top := range []string{"modules", "checks"} {
err := filepath.WalkDir(filepath.Join(root, top), func(path string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.IsDir() {
switch d.Name() {
case "testdata", "vendor", "node_modules", ".mesh-build":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
rel, _ := filepath.Rel(root, path)
found, err := fileFindings(path, filepath.ToSlash(rel))
if err != nil {
return err
}
out = append(out, found...)
return nil
})
if err != nil {
return nil, err
}
}
sort.Strings(out)
return out, nil
}
func fileFindings(path, rel string) ([]string, error) {
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution)
if err != nil {
return nil, fmt.Errorf("%s: %w", rel, err)
}
var out []string
buildstep := ""
for _, imp := range file.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err != nil {
continue
}
sub, ok := strings.CutPrefix(p, sdk+"/")
if !ok {
continue
}
for pkg, to := range Removed {
if sub == pkg || strings.HasPrefix(sub, pkg+"/") {
out = append(out, fmt.Sprintf("%s: imports %s, which left mesh-sdk; it is %s (novox/hq ADR 0593 §1)", rel, p, to))
break
}
}
if sub == "buildstep" {
buildstep = "buildstep"
if imp.Name != nil {
buildstep = imp.Name.Name
}
}
}
if buildstep == "" || buildstep == "_" || buildstep == "." {
return out, nil
}
seen := map[string]bool{}
ast.Inspect(file, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != buildstep || !sel.Sel.IsExported() || BuildstepContract[sel.Sel.Name] || seen[sel.Sel.Name] {
return true
}
seen[sel.Sel.Name] = true
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
return true
})
return out, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: removedpackages <catalogue root>")
os.Exit(2)
}
found, err := Findings(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("no source imports a package mesh-sdk gave up")
}
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ADR 0593 §1: a source that imports a package mesh-sdk gave up, or names the build runner through mesh-sdk's buildstep, is
// refused naming where it went; the contract that stays, the runner's own package and a testdata copy are not.
func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) {
const head = "package x\n\nimport (\n"
for _, row := range []struct {
name, file, src string
want string // empty: clean
}{
{"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""},
{"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"},
{"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"},
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"},
{"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"},
{"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"},
{"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""},
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""},
{"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, row.file)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(row.src), 0o644); err != nil {
t.Fatal(err)
}
got, err := Findings(root)
if err != nil {
t.Fatal(err)
}
if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) {
t.Fatalf("findings = %q, want one containing %q", all, row.want)
}
})
}
}
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2).
func TestTheCatalogueImportsNothingMeshSDKGaveUp(t *testing.T) {
got, err := Findings(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
for _, f := range got {
t.Error(f)
}
}
-6
View File
@@ -1,6 +0,0 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every Go source of the modules and of the checks, whose imports are read.
modules
checks
+6
View File
@@ -36,7 +36,13 @@ import (
// composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no // composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no
// manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5). // manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5).
var composition = map[string]string{ var composition = map[string]string{
"catalogue/consumers.go:consumersPlaceholderProblems": "composition: rendering a consumer's template",
"catalogue/collections.go:collectedPlaceholderProblems": "composition: placeholders a contribution renders",
"catalogue/environment_into.go:placeholderProblems": "composition: placeholders a contribution renders",
"catalogue/seat_contributions.go:seatPlaceholderProblems": "composition: placeholders a contribution renders",
"catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template", "catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template",
"catalogue/setting_takes.go:takenSettingsProblems": "composition: whether stored settings still compose",
"catalogue/template.go:HeldTemplateProblem": "composition: the take-in refusal of a resolved build",
} }
// Problems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for // Problems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for
+1 -1
View File
@@ -2,7 +2,7 @@ module adwaita
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -61,7 +61,7 @@ record names leaves its platform manifests unrecorded with it.
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not **What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before. make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
definition the catalogue holds (the seat mesh-catalog's verbs `modules`, then `module` for each, eight at definition the catalogue holds (`mesh-catalog.catalog_modules`, then `catalog_module` for each, eight at
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings, a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
under each of the store's names: `artifact-store://`, the store's address as the controller answers it, under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
@@ -23,11 +23,10 @@ import (
// manifest's. // manifest's.
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE" const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
// CatalogueModules and CatalogueModule are the verbs of the seat mesh-catalog these definitions are read through // CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through.
// (ADR 0604 §3); the answers are catalogue.CatalogModules and catalogue.CatalogOne.
const ( const (
CatalogueModules = "seat:mesh-catalog.modules" CatalogueModules = "mesh-catalog.catalog_modules"
CatalogueModule = "seat:mesh-catalog.module" CatalogueModule = "mesh-catalog.catalog_module"
) )
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what // DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
@@ -280,8 +279,11 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
return pins, "" return pins, ""
} }
} }
// The verb takes no commit: it answers the trunk's module, and the answer names the commit it is at. args := map[string]any{"module": m.Module}
raw, err := c.Ask(CatalogueModule, map[string]any{"module": m.Module}) if m.Commit != "" {
args["commit"] = m.Commit
}
raw, err := c.Ask(CatalogueModule, args)
if err != nil { if err != nil {
return nil, "could not be asked: " + err.Error() return nil, "could not be asked: " + err.Error()
} }
@@ -291,7 +293,6 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
} }
var one struct { var one struct {
Module *struct { Module *struct {
Commit string `json:"commit"`
Manifest json.RawMessage `json:"manifest"` Manifest json.RawMessage `json:"manifest"`
} `json:"module"` } `json:"module"`
Error string `json:"error"` Error string `json:"error"`
@@ -300,11 +301,9 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer") return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
} }
pins := PinsIn(one.Module.Manifest, names) pins := PinsIn(one.Module.Manifest, names)
// Kept by the commit the answer names, which may be newer than the list's: a definition and its commit never if m.Commit != "" {
// change together.
if at := firstOf(one.Module.Commit, m.Commit); at != "" {
c.mu.Lock() c.mu.Lock()
c.kept[m.Module+"@"+at+"|"+nameKey] = pins c.kept[key] = pins
c.mu.Unlock() c.mu.Unlock()
} }
return pins, "" return pins, ""
@@ -211,7 +211,7 @@ func TestReferencesReadTheDefinitionsUnderTheStoresOlderNames(t *testing.T) {
} }
} }
if before != 2 || after != 2 { if before != 2 || after != 2 {
t.Errorf("asked the seat's module %d times, then %d: each definition at its commit is read once", before, after) t.Errorf("asked catalog_module %d times, then %d: each definition at its commit is read once", before, after)
} }
} }
@@ -366,8 +366,8 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
if a.failing[name] { if a.failing[name] {
return nil, fmt.Errorf("no answer") return nil, fmt.Errorf("no answer")
} }
if _, has := args["commit"]; has { if args["commit"] != "c-"+name {
return nil, fmt.Errorf("asked %s at %v: the seat's verb takes no commit", name, args["commit"]) return nil, fmt.Errorf("asked %s at %v", name, args["commit"])
} }
manifest, has := a.definitions[name] manifest, has := a.definitions[name]
if !has { if !has {
@@ -375,8 +375,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
break break
} }
// The catalogue's tool answers through the runtime as the protocol's content list. // The catalogue's tool answers through the runtime as the protocol's content list.
raw, _ := json.Marshal(catalogue.CatalogOne{Module: catalogue.CatalogModule{ raw, _ := json.Marshal(map[string]any{"module": map[string]any{"module": name, "manifest": json.RawMessage(manifest)}})
CatalogEntry: catalogue.CatalogEntry{Module: name, Commit: "c-" + name}, Manifest: json.RawMessage(manifest)}})
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}}) return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
case "collect": case "collect":
if args["confirm"] == "true" { if args["confirm"] == "true" {
+1 -1
View File
@@ -3,7 +3,7 @@ module artifactstoretools
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+2 -2
View File
@@ -19,8 +19,8 @@
], ],
"invokes": [ "invokes": [
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-act",
"seat:mesh-catalog.modules", "mesh-catalog.catalog_modules",
"seat:mesh-catalog.module" "mesh-catalog.catalog_module"
], ],
"reads": [ "reads": [
"mesh-delivery-assigner.wants", "mesh-delivery-assigner.wants",
+1 -1
View File
@@ -2,7 +2,7 @@ module asuszephyrusg14
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module avahi
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module blueman
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module bluetooth
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -3,7 +3,7 @@ module brother
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -2,7 +2,7 @@ module claude-code
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require gopkg.in/yaml.v3 v3.0.1 require gopkg.in/yaml.v3 v3.0.1
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -3,7 +3,7 @@ module claude-licence-manager
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+1 -1
View File
@@ -2,7 +2,7 @@ module clipmenu
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module cups
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -3,7 +3,7 @@ module dbus
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/godbus/dbus/v5 v5.1.0 github.com/godbus/dbus/v5 v5.1.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
+1 -1
View File
@@ -2,7 +2,7 @@ module deskchannel
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -33,7 +33,7 @@ All six only read.
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` | | `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` | | `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` | | `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name With `unit` (a unit name such as `session-c1.scope`, or a cgroup path) it ranks that unit's processes instead (pid, command, cpu_pct, share_pct of the unit's busy time, threads), from `/proc/<pid>/stat` | `top`, `systemd-cgtop` | | `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
### How each number is read ### How each number is read
@@ -1,151 +0,0 @@
package main
import (
"fmt"
"io/fs"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// clockTicks is USER_HZ, the unit of utime and stime in /proc/<pid>/stat: 100 on every Linux the mesh runs.
const clockTicks = 100.0
// ProcessCPU is one process of a unit and its share of CPU over the window (cpu_top with unit set).
type ProcessCPU struct {
PID int `json:"pid"`
Command string `json:"command"` // comm, then the first 120 characters of the command line
CPUPct float64 `json:"cpu_pct"` // % of one CPU: 100 = one CPU busy the whole window
SharePct float64 `json:"share_pct"` // this process's share of the unit's busy time
Threads int `json:"threads"`
raw float64
}
// procTicks is a process's utime+stime ticks and thread count from its /proc/<pid>/stat, whose comm
// field may hold spaces and parentheses, so the fields are counted from the last ")".
func parseProcStat(s string) (comm string, ticks uint64, threads int, start string, ok bool) {
l, r := strings.Index(s, "("), strings.LastIndex(s, ")")
if l < 0 || r < l {
return "", 0, 0, "", false
}
f := strings.Fields(s[r+1:])
if len(f) < 20 {
return "", 0, 0, "", false
}
ut, e1 := strconv.ParseUint(f[11], 10, 64)
st, e2 := strconv.ParseUint(f[12], 10, 64)
th, e3 := strconv.Atoi(f[17])
if e1 != nil || e2 != nil || e3 != nil {
return "", 0, 0, "", false
}
return s[l+1 : r], ut + st, th, f[19], true
}
// cgroupOfUnit is the cgroup path (relative to /sys/fs/cgroup) a unit name or a path names.
func (m *Machine) cgroupOfUnit(unit string) (string, error) {
base := m.path("/sys/fs/cgroup")
if strings.Contains(unit, "/") {
rel := strings.Trim(strings.TrimPrefix(unit, "/sys/fs/cgroup"), "/")
if strings.Contains(rel, "..") || filepath.Clean("/"+rel) != "/"+rel {
return "", fmt.Errorf("cgroup %q is not a path under /sys/fs/cgroup", unit)
}
if rel == "" || !m.exists("/sys/fs/cgroup/"+rel) {
return "", fmt.Errorf("no cgroup %q", unit)
}
return rel, nil
}
var found []string
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err == nil && d.IsDir() && d.Name() == unit {
rel, _ := filepath.Rel(base, p)
found = append(found, filepath.ToSlash(rel))
}
return nil
})
switch len(found) {
case 0:
return "", fmt.Errorf("no unit %q in the cgroup tree", unit)
case 1:
return found[0], nil
}
return "", fmt.Errorf("unit %q is %d cgroups (%s): give the cgroup path", unit, len(found), strings.Join(found, ", "))
}
type procReading struct {
ticks uint64
comm string
threads int
pid int
start string // starttime, to tell a reused pid
}
// unitProcs reads every process in a cgroup and its child cgroups: its ticks, name and threads.
func (m *Machine) unitProcs(cgroup string) map[int]procReading {
out := map[int]procReading{}
base := m.path("/sys/fs/cgroup/" + cgroup)
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || d.Name() != "cgroup.procs" {
return nil
}
for _, f := range strings.Fields(m.read(strings.TrimPrefix(p, filepath.Clean(m.Root)))) {
pid, err := strconv.Atoi(f)
if err != nil {
continue
}
comm, ticks, th, start, ok := parseProcStat(m.read("/proc/" + f + "/stat"))
if ok {
out[pid] = procReading{ticks: ticks, comm: comm, threads: th, pid: pid, start: start}
}
}
return nil
})
return out
}
// commandLine is a process's program path (argv[0], cut to 120 characters), never its arguments:
// they can carry a password.
func (m *Machine) commandLine(pid int) string {
s, _, _ := strings.Cut(m.read("/proc/"+strconv.Itoa(pid)+"/cmdline"), "\x00")
s, _, _ = strings.Cut(s, " ") // a process that rewrites its title puts its whole command line here
s, _, _ = strings.Cut(s, "\t")
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > 120 {
s = string(r[:120])
}
return s
}
// topProcessCPU ranks a unit's processes by CPU between two readings; a process missing from either
// reading (started or ended in the window) is skipped.
func (m *Machine) topProcessCPU(a, b map[int]procReading, window time.Duration, limit int) []ProcessCPU {
var out []ProcessCPU
var sum float64
for pid, end := range b {
start, ok := a[pid]
if !ok || end.start != start.start || end.ticks <= start.ticks {
continue
}
pct := float64(end.ticks-start.ticks) / clockTicks / window.Seconds() * 100
sum += pct
out = append(out, ProcessCPU{PID: pid, Command: end.comm, Threads: end.threads, raw: pct})
}
sort.Slice(out, func(i, j int) bool {
if out[i].raw != out[j].raw {
return out[i].raw > out[j].raw
}
return out[i].PID < out[j].PID
})
if len(out) > limit {
out = out[:limit]
}
for i := range out {
out[i].CPUPct = round1(out[i].raw)
out[i].SharePct = round1(out[i].raw / sum * 100)
if cl := m.commandLine(out[i].PID); cl != "" {
out[i].Command += ": " + cl
}
}
return out
}
@@ -180,52 +180,3 @@ func TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows(t *testing.T) {
t.Errorf("the user session's init.scope: %+v", byCgroup) t.Errorf("the user session's init.scope: %+v", byCgroup)
} }
} }
// TestCPUTopRanksAUnitsProcesses is the unit argument: a session scope with a child cgroup, ranked by
// the utime+stime each process used over a 2 s window; one process ends, one starts, one comm holds ")".
func TestCPUTopRanksAUnitsProcesses(t *testing.T) {
f := aCPUHog(t)
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/cgroup.procs", "10\n11\n12\n")
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/app/cgroup.procs", "13\n")
st := func(pid int, comm string, ticks, threads int) {
f.then("/proc/"+itoa(pid)+"/stat", itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 "+itoa(ticks)+" 0 0 0 20 0 "+itoa(threads)+" 0 1 1 1\n")
}
for pid, c := range map[int][2]string{10: {"firefox", "1"}, 11: {"sh (x)", "1"}, 12: {"gone", "1"}} {
f.file("/proc/"+itoa(pid)+"/stat", itoa(pid)+" ("+c[0]+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 "+c[1]+" 0 1 1 1\n")
}
f.file("/proc/13/stat", "13 (deep) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 4 0 1 1 1\n")
f.file("/proc/10/cmdline", "/usr/bin/firefox\x00--new-window\x00")
f.file("/proc/11/cmdline", "mysql\x00--password\x00hunter2\x00")
f.file("/proc/13/cmdline", "/usr/bin/tool --password hunter2 -x") // a rewritten title: one string, spaces, no NUL
st(10, "firefox", 150, 40) // 75 % of one CPU over 2 s
st(11, "sh (x)", 40, 1) // 20 %
st(13, "deep", 10, 4) // 5 %, in the child cgroup
f.then("/sys/fs/cgroup/user.slice/session-c1.scope/cgroup.procs", "10\n11\n14\n")
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/app/cgroup.procs", "13\n15\n")
f.file("/proc/15/stat", "15 (old) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 1 1\n")
f.then("/proc/15/stat", "15 (reused) S 1 1 1 0 -1 0 0 0 0 0 900 0 0 0 20 0 1 0 77 1 1\n") // new starttime: another process
st(14, "newborn", 500, 1) // not in the first reading: skipped
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": "session-c1.scope", "seconds": 2.0, "limit": 3.0})
if err != nil {
t.Fatal(err)
}
top := out.(map[string]any)["top"].([]ProcessCPU)
if len(top) != 3 || top[2].PID != 13 || top[2].CPUPct != 5 || top[0].PID != 10 || top[0].CPUPct != 75 || top[0].SharePct != 75 || top[0].Threads != 40 ||
top[0].Command != "firefox: /usr/bin/firefox" || top[1].PID != 11 || top[1].CPUPct != 20 || top[1].Command != "sh (x): mysql" || top[2].Command != "deep: /usr/bin/tool" {
t.Errorf("processes of the unit: %+v", top)
}
for _, p := range top {
if p.PID == 15 || strings.Contains(p.Command, "hunter2") {
t.Errorf("a reused pid was counted, or an argument leaked: %+v", p)
}
}
for _, u := range []string{"../../etc", "/sys/fs/cgroup/../../proc", "user.slice/../.."} {
if _, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": u}); err == nil {
t.Errorf("cgroup path %q escaping the root was accepted", u)
}
}
if _, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": "nothing.scope"}); err == nil {
t.Error("an unknown unit was accepted")
}
}
+1 -23
View File
@@ -90,14 +90,11 @@ func Tools(m *Machine) []stdio.Tool {
Description: "What used the most CPU over a few seconds, from each unit's cgroup cpu.stat, which needs no root: the machine's " + Description: "What used the most CPU over a few seconds, from each unit's cgroup cpu.stat, which needs no root: the machine's " +
"CPU in use (as % of all CPUs and as CPUs busy, from /proc/stat), then by=unit (the default) each systemd unit and " + "CPU in use (as % of all CPUs and as CPUs busy, from /proc/stat), then by=unit (the default) each systemd unit and " +
"container ranked by CPU, busiest first, with the busy time no unit accounts for; by=container only the containers, " + "container ranked by CPU, busiest first, with the busy time no unit accounts for; by=container only the containers, " +
"by name. With unit (a systemd unit such as session-c1.scope, or its cgroup path) it ranks that unit's processes instead, CPU by process, the top process: " + "by name. Replaces top, systemd-cgtop. (r)",
"pid, command, cpu_pct, share_pct of the unit's busy time and threads, from /proc/<pid>/stat, needing no root; a desktop session is one unit, " +
"and this names the program in it. Replaces top, systemd-cgtop. (r)",
Input: map[string]any{ Input: map[string]any{
"seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, at most 10)"}, "seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, at most 10)"},
"limit": map[string]any{"type": "integer", "description": "how many units or containers to name (default 15, at most 50)"}, "limit": map[string]any{"type": "integer", "description": "how many units or containers to name (default 15, at most 50)"},
"by": map[string]any{"type": "string", "enum": []string{"unit", "container"}, "description": "what to rank (default unit)"}, "by": map[string]any{"type": "string", "enum": []string{"unit", "container"}, "description": "what to rank (default unit)"},
"unit": map[string]any{"type": "string", "description": "a systemd unit name (session-c1.scope) or cgroup path: rank that unit's processes by CPU instead"},
}, },
Run: func(args map[string]any) (any, error) { Run: func(args map[string]any) (any, error) {
n, err := count(args, "seconds", 3, 1, 10) n, err := count(args, "seconds", 3, 1, 10)
@@ -121,25 +118,6 @@ func Tools(m *Machine) []stdio.Tool {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if unit := text(args, "unit"); unit != "" {
cg, err := m.cgroupOfUnit(unit)
if err != nil {
return nil, err
}
pa := m.unitProcs(cg)
m.Wait(w)
bc, err := parseCPU(m.read("/proc/stat"))
if err != nil {
return nil, err
}
return map[string]any{
"window_s": w.Seconds(),
"by": "process",
"cgroup": cg,
"cpu": machineCPUOver(ac, bc),
"top": m.topProcessCPU(pa, m.unitProcs(cg), w, limit),
}, nil
}
a := m.unitCPU() a := m.unitCPU()
m.Wait(w) m.Wait(w)
bc, err := parseCPU(m.read("/proc/stat")) bc, err := parseCPU(m.read("/proc/stat"))
+1 -1
View File
@@ -3,7 +3,7 @@ module diskload
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -2,7 +2,7 @@ module dmenu
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module dnsmasq
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module docker-compose
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module docker
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module dunst
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module fail2ban
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module feh
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module flatpak
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module fonts
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module forticlient
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+17 -25
View File
@@ -559,14 +559,8 @@ export class GiteaClient {
* *
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages * **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did * run past a bound): the call fails naming the repository and the commit, never answering the directories it did
* see, since a directory left out would be taken for one without a module. With `files`, a list of directories (the * see, since a directory left out would be taken for one without a module. Reads only. */
* root as `""`), the answer also carries, of each, every blob under it, recursively, as its path relative to the async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
* directory with its git object id (the indexer digests the files of a mesh package from it, novox/hq ADR 0593 §2); a
* directory the commit lacks fails naming it. Reads only. */
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>, files?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; files?: Record<string, Record<string, string>> }> {
for (const dir of files ?? []) {
if (dir !== "" && dir.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`files: ${JSON.stringify(dir)} is not a plain relative directory`);
}
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
const entries: any[] = []; const entries: any[] = [];
@@ -593,22 +587,7 @@ export class GiteaClient {
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`); if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
trees[dir] = tree; trees[dir] = tree;
} }
let filesOf: Record<string, Record<string, string>> | undefined; if (paths === undefined) return { root, trees };
if (files !== undefined) {
// **The files of whole directories** (novox/hq ADR 0593 §2): every blob under each, from the same listing. The listing
// is whole (a cut one failed above), so a directory absent from it is absent at the commit.
filesOf = {};
for (const dir of files) {
if (dir !== "" && dirOf.get(dir) === undefined) throw new Error(`the directory ${dir} is not in ${owner}/${repo} at ${sha}`);
const prefix = dir === "" ? "" : `${dir}/`;
const blobs: Record<string, string> = {};
for (const e of entries) {
if (e?.type === "blob" && typeof e.path === "string" && e.path.startsWith(prefix)) blobs[e.path.slice(prefix.length)] = String(e.sha);
}
filesOf[dir] = blobs;
}
}
if (paths === undefined) return filesOf === undefined ? { root, trees } : { root, trees, files: filesOf };
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a // **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails // directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
// naming both, and nothing is answered in part. // naming both, and nothing is answered in part.
@@ -623,7 +602,7 @@ export class GiteaClient {
named[dir][p] = id; named[dir][p] = id;
} }
} }
return filesOf === undefined ? { root, trees, paths: named } : { root, trees, paths: named, files: filesOf }; return { root, trees, paths: named };
} }
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -672,6 +651,19 @@ export class GiteaClient {
return out; return out;
} }
/** Every status of one context on a commit, newest first, as the forge keeps each one it was given: what tells
* when the check now pending on a head was opened, after a restart (novox/hq issue 456). */
async contextHistory(owner: string, repo: string, sha: string, context: string): Promise<HeadCheck[]> {
const raw = await this.request<any>(`${repoPath(owner, repo)}/statuses/${commitSegment(sha)}?sort=highestindex&limit=50`);
const out: HeadCheck[] = [];
for (const s of Array.isArray(raw) ? raw : []) {
if (s?.context !== context) continue;
out.push({ state: String(s.status ?? s.state ?? ""), description: String(s.description ?? ""),
updated_at: String(s.created_at ?? s.updated_at ?? "") });
}
return out;
}
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit /** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */ * it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> { async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
+181 -12
View File
@@ -1,21 +1,91 @@
// **One writer of a head's mesh/merge-gate** (novox/hq issue 446). // **One writer of a head's mesh/merge-gate, and the verdict lands last** (novox/hq issue 446).
// //
// A pending check says where it is: "waiting in the build queue since …" when the head is announced. The forge keeps only // A pending check says where it is: "waiting in the build queue since …" when the head is announced, "building on
// the newest status of a context, so two writers cannot order their writes; this module is the one writer of // <machine> since …" when a build machine takes the check — the build seat's `started` (novox/hq ADR 0157) — and the
// mesh/merge-gate, and every write of one head's gate goes through one chain, in the order it was asked. A recheck's // verdict when the controller says `checked`. The forge keeps only the newest status of a context, so a pending word
// pending (novox/hq issue 456) and a trunk's move (ADR 0323 §4) take their turns on the same chain. No verdict is written // written after the verdict hides it: a pass reads pending and the merge waits for ever; a failure hides under
// here any more: the controller's `checked` is no event (ADR 0571 clause 13), and main requires the checker's mesh/check. // "building". Two writers cannot order their writes, and a writer that reads the forge before writing races the other
// between its read and its write. So this module is the one writer of mesh/merge-gate, and every write of one head's
// gate goes through one chain: a verdict is marked the moment it is heard, before it waits its turn, and a "building"
// still waiting behind or taking its turn after that mark is dropped, never written.
//
// **A recheck's pending is written here too** (novox/hq issue 456). mesh-delivery's recheck puts the gate back to
// pending through the gitea_commit_status tool, which this module serves from the watcher's own bundle so that it
// reaches this writer: the pending clears the verdict mark, so the new run's "building" is said, and queues behind an
// old verdict in flight, so that verdict lands first. A verdict is weighed by the ask it answers — the controller's
// checked.id, `build-<unix nanoseconds>` of when the check was asked — against when the check now pending was opened:
// an older run's verdict, redelivered after its write failed, is dropped, never written over the pending. After a
// restart, when the check was opened is read from the forge's own history of the gate.
import { CHECK_CONTEXT, pendingWords } from "./pulls.js"; import { CHECK_CONTEXT, pendingWords } from "./pulls.js";
import type { CommitStatus, HeadCheck } from "./client.js"; import type { CommitStatus, HeadCheck } from "./client.js";
/** What the gate writer needs of the forge: a status set, and a head's statuses as the forge holds them. */ /** The build seat's `started`, as mesh-controller's link.BuildStart publishes it. */
export interface BuildStarted {
id?: string;
repository?: string;
path?: string;
ref?: string;
on?: string;
at?: string;
}
/** What the gate writer needs of the forge: a status set, and a head's gate as the forge holds it. */
export interface GateForge { export interface GateForge {
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>; setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
/** The head's mesh/merge-gate, newest: its state and when it was set; undefined when it has none. */
gateOf(owner: string, repo: string, sha: string): Promise<{ state: string; updated_at?: string } | undefined>;
/** The head's mesh/merge-gate statuses, every one the forge keeps, newest first (novox/hq issue 456). */
gateHistory(owner: string, repo: string, sha: string): Promise<GateStatus[]>;
/** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */ /** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */
headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>>; headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>>;
} }
/** One status of a head's gate as the forge keeps it: its state, its words, and when it was set. */
export interface GateStatus {
state: string;
description?: string;
updated_at?: string;
}
/**
* When a check was asked, from its id as mesh-controller's link.NewBuildID writes it: `build-<unix nanoseconds>`.
* Undefined for an id of any other shape, whose ask time is not known (novox/hq issue 456).
*/
export function askedAt(id: string | undefined): number | undefined {
const m = /^build-(\d{1,20})$/.exec(String(id ?? ""));
if (!m) return undefined;
const ms = Number(BigInt(m[1]) / 1_000_000n);
return ms > 0 ? ms : undefined;
}
/**
* When the check now pending on a head was opened, from the gate's history, newest first: the newest status, past the
* "building" words this writer says only inside an open check, when it is pending; undefined when the gate holds a
* verdict, holds nothing, or its time cannot be read (novox/hq issue 456).
*/
export function openedAt(history: GateStatus[]): number | undefined {
for (const s of history) {
if (s.state !== "pending") return undefined;
if ((s.description ?? "").startsWith(BUILDING)) continue;
const t = Date.parse(s.updated_at ?? "");
return Number.isNaN(t) ? undefined : t;
}
return undefined;
}
/** How far a build machine's clock may stand behind this one's when a start is weighed against the pending words. */
const CLOCK_SLACK_MS = 60_000;
/** How the words of a check a machine is running begin. */
const BUILDING = "building on ";
/** The words of a check a machine is running, in the time the operator reads. */
export function buildingWords(on: string, at: Date): string {
const two = (n: number) => String(n).padStart(2, "0");
return `${BUILDING}${on} since ${two(at.getHours())}:${two(at.getMinutes())}`;
}
/** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */ /** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */
export function clonedAs(url: string | undefined, owner: string, repo: string): boolean { export function clonedAs(url: string | undefined, owner: string, repo: string): boolean {
const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, ""); const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, "");
@@ -24,6 +94,12 @@ export function clonedAs(url: string | undefined, owner: string, repo: string):
} }
interface Head { interface Head {
/** When the pending words were last set by this writer; undefined for a head this process did not announce. */
pendingAt?: number;
/** A verdict was heard for the head: nothing pending is written after it. */
verdict: boolean;
/** Which verdict set the mark: one dropped in its turn takes the mark back only while it is still its own. */
mark?: object;
/** The chain the head's writes take their turns on. */ /** The chain the head's writes take their turns on. */
chain: Promise<void>; chain: Promise<void>;
} }
@@ -42,7 +118,7 @@ export class GateWriter {
const key = `${owner}/${repo}@${sha}`.toLowerCase(); const key = `${owner}/${repo}@${sha}`.toLowerCase();
let h = this.heads.get(key); let h = this.heads.get(key);
if (!h) { if (!h) {
h = { chain: Promise.resolve() }; h = { verdict: false, chain: Promise.resolve() };
this.heads.set(key, h); this.heads.set(key, h);
// Kept for the heads still in play; the oldest go first, past what any forge holds open at once. // Kept for the heads still in play; the oldest go first, past what any forge holds open at once.
if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string); if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string);
@@ -68,17 +144,36 @@ export class GateWriter {
pending(owner: string, repo: string, sha: string): Promise<void> { pending(owner: string, repo: string, sha: string): Promise<void> {
const h = this.head(owner, repo, sha); const h = this.head(owner, repo, sha);
const at = this.now(); const at = this.now();
this.opened(h, at);
return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () => return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () =>
this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) })); this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) }));
} }
/** A new check of the head opened now: no verdict heard for it yet, and nothing older than it is its own. */
private opened(h: Head, at: Date): void {
h.pendingAt = at.getTime();
h.verdict = false;
h.mark = undefined;
}
/** /**
* A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): it takes its turn after every * A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): on mesh/merge-gate it opens
* write already asked for, so it lands over them. Any other status of the merge check takes its turn on the same * a new check of the head — the verdict mark cleared, so the new run's "building" is said — and takes its turn
* chain. A write that fails fails the caller. * after every write already asked for, an old verdict in flight included, so it lands over that verdict. Any
* other status of the merge check (mesh/repo-check, which the verdict also writes) takes its turn on the same
* chain. A write that fails fails the caller, and the head is as it was before it.
*/ */
rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status)); const h = this.head(owner, repo, sha);
const write = () => this.forge.setCommitStatus(owner, repo, sha, status);
if (status.context !== CHECK_CONTEXT || status.state !== "pending") return this.turn(h, write);
const before = { pendingAt: h.pendingAt, verdict: h.verdict, mark: h.mark };
const at = this.now();
this.opened(h, at);
return this.turn(h, write).catch((err) => {
if (h.pendingAt === at.getTime() && !h.verdict) Object.assign(h, before);
throw err;
});
} }
/** /**
@@ -100,4 +195,78 @@ export class GateWriter {
} }
}).then(() => written); }).then(() => written);
} }
/**
* A build machine took an ask of this head: said while no verdict was heard, and only for the check now pending —
* a start from before the pending words were set (a run before a recheck, the stream's history) is not this check's.
* A head this process did not announce (it restarted since) is weighed against the forge's own gate: said only while
* that is pending, and not for a start older than it.
*/
started(owner: string, repo: string, sha: string, b: BuildStarted): Promise<boolean> {
const at = b.at ? new Date(b.at) : new Date(NaN);
if (!b.on || Number.isNaN(at.getTime())) return Promise.resolve(false);
const h = this.head(owner, repo, sha);
let said = false;
return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say where its check is`, async () => {
if (h.verdict) return;
let since = h.pendingAt;
if (since === undefined) {
const gate = await this.forge.gateOf(owner, repo, sha);
// No gate on the forge: none was written (its base does not require it, issue 1301), and nothing is said.
if (!gate || gate.state !== "pending") return;
since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined;
}
if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return;
if (h.verdict) return; // heard while the forge was read
await this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT,
description: buildingWords(b.on as string, at) });
said = true;
}).then(() => said);
}
/**
* The verdict: marked at once, so no pending word is written after it, then written in its turn — last. A write
* that fails fails the caller, so the event is offered again.
*
* `id` is the ask it answers (checked.id): a verdict of a check asked before the check now pending was opened is
* an older run's — redelivered after its write failed, or heard late — and is dropped, never written over the
* pending (novox/hq issue 456). When this process opened no check of the head (it restarted since), the opening
* is read from the forge's history of the gate in the verdict's turn; a history that cannot be read writes the
* verdict, as one with no readable id is: a verdict is never lost to what cannot be weighed.
*/
verdict(owner: string, repo: string, sha: string, write: () => Promise<void>, id?: string): Promise<void> {
const h = this.head(owner, repo, sha);
const asked = askedAt(id);
const head = `${owner}/${repo}@${sha.slice(0, 8)}`;
if (asked !== undefined && h.pendingAt !== undefined && older(asked, h.pendingAt)) {
console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check now pending; not written`);
return Promise.resolve();
}
const before = h.verdict;
const mark = {};
h.verdict = true;
h.mark = mark;
return this.turn(h, async () => {
if (asked !== undefined && h.pendingAt === undefined) {
let opened: number | undefined;
try {
opened = openedAt(await this.forge.gateHistory(owner, repo, sha));
} catch (err) {
console.error(`[gitea] ${head}: the gate's history could not be read, so the verdict of ${id} is written ` +
`unweighed — ${err instanceof Error ? err.message : err}`);
}
if (opened !== undefined && older(asked, opened)) {
if (h.mark === mark) h.verdict = before;
console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check the forge holds pending; not written`);
return;
}
}
await write();
});
}
}
/** Whether a check asked at `asked` was asked before a check opened at `opened`, past what the clocks may differ by. */
function older(asked: number, opened: number): boolean {
return asked < opened - CLOCK_SLACK_MS;
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module gitea
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+121 -6
View File
@@ -9,9 +9,13 @@
// //
// module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239) // module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239)
// //
// It consumes no event of the controller: no controller judges a merge (ADR 0524 §5, ADR 0571 clause 13), and the two older // Once consumed mesh-controller.checked — a pull request's merge check, judged — and set it as the head
// statuses, `mesh/merge-gate` and `mesh/repo-check`, are written for no base that main requires (issue 1301); main // commit's statuses (setVerdict, kept below for a revert, and bound to no event since ADR 0571 clause 13): `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
// requires `mesh/check`, the checker's, everywhere, hq included since ADR 0527 §2. // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since
// ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing
// publishes a merge check's verdict, this module consumes no event of the controller, and these two are written for no base.
// //
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and // Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands // writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
@@ -49,13 +53,14 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js"; import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { keepMainRules, mainRulesStep } from "./mainrules.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, headKey, requiredOf, writesGate, type Announced } from "./pulls.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained,
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js";
import { actOn, announceNewHeads, startPass } from "./watch.js"; import { actOn, announceNewHeads, startPass } from "./watch.js";
import { GateWriter } from "./gate.js"; import { GateWriter } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, readSiblings, siblingsFile, weighSiblings } from "./siblings.js"; import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindInBackground, bindUntilHeld } from "./bind.js"; import { bindInBackground, bindUntilHeld } from "./bind.js";
import { announcementOf, weighTrunks } from "./trunk.js"; import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
import { ReadAll, readAllTools } from "./readall.js"; import { ReadAll, readAllTools } from "./readall.js";
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js"; import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js"; import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
@@ -351,11 +356,121 @@ async function siblingMoves(client: GiteaClient, repository: string, number?: nu
} }
} }
// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one
// that is not a pass also leaves the check's own account as a comment, so the reason is read where the
// change is reviewed. An error — the check could not run — is the forge's `error`, never a success.
async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise<void> {
const c = (event.body ?? {}) as Checked;
if (!c.group && c.owner && c.repo && c.took) {
// How long it took, for the merge tool to say how long the next one may (novox/hq ADR 0323 §5).
keepTook(tookFile(process.env.MESH_GITEA_STATE_DIR), `${c.owner}/${c.repo}`,
{ took: c.took, at: new Date().toISOString(), head: c.commit });
}
if (c.group) {
// A delivery group's composed check (novox/hq ADR 0239, ADR 0403 §1): its verdict is each member's merge gate, at the
// head it read; the group's own status, mesh/delivery-group, is its owner's to say.
await setGroupVerdict(client, c);
return;
}
if (!c.owner || !c.repo || !c.commit || !c.verdict) {
console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored");
return;
}
// The siblings this verdict read are kept for the member (ADR 0382 §5); a verdict that read none removes the record.
const siblings = siblingsOf(c.siblings);
if (c.number && noteVerdict(siblingNamed, `${c.owner}/${c.repo}#${c.number}`.toLowerCase(), c.commit, siblings)) {
keepSiblings(siblingsRecord, siblingNamed);
}
// A sibling that moved while the check ran is weighed once this verdict has landed, in the head's own turn after it.
if (siblings.length) {
for (const repository of new Set(siblings.map((s) => s.repository))) {
void Promise.resolve().then(() => siblingMoves(client, repository));
}
}
// Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239).
let target: string | undefined;
let base: string | undefined;
if (c.number) {
const pull = await client.getPullRequest(c.owner, c.repo, c.number).catch(() => undefined);
target = pull?.html_url || undefined;
base = pull?.base || undefined;
}
// **A verdict judged against a trunk commit that is no longer the trunk's head is out of date when it lands**
// (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of
// its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits.
const trunk = base ?? c.plan?.base;
// Only the statuses the base still requires are written (issue 1301): on hq, mesh/repo-check alone.
const required = trunk ? requiredOf(await client.appliedProtection(c.owner, c.repo, trunk).catch(() => undefined)) : undefined;
const kept = maintained(required);
if (c.trunk && trunk) {
const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined);
if (head && !sameCommit(c.trunk, head)) {
const description = outOfDateWords(c.trunk, head);
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]
.filter((x) => kept.includes(x))) {
await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description,
...(target ? { target_url: target } : {}) });
}
// Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date.
const said = commentFor(c, await repoCheckFacts(client, c, trunk), required);
if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description));
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` +
`${description}`);
return;
}
}
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record<string, { description?: string }>)))[CHECK_CONTEXT]?.description : undefined;
for (const status of statusesFor(c, facts, gateNow, required)) {
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
}
const comment = composedGate(c) ? null : commentFor(c, facts, required);
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
}
/** **The composed verdict sets each member's gate** (novox/hq ADR 0403 §1, issue 978), in each head's own turn of the gate
* writer (gate.ts), judged against the trunk as a member's own verdict is: one that names a trunk commit that is no longer
* the trunk's head is out of date when it lands, and said so, pending. */
async function setGroupVerdict(client: GiteaClient, c: Checked): Promise<void> {
// The base branch each member merges into, read with its pull request below: the composed verdict names the siblings of
// each member from it (issue 1049).
const bases = new Map<string, string>();
for (const m of c.members ?? []) {
if (!m.owner || !m.repo || !m.commit || !c.verdict) {
console.error(`[gitea] a composed verdict of ${c.group} names a member with no repository, commit or verdict; ignored`);
continue;
}
await gates.verdict(m.owner, m.repo, m.commit, async () => {
const pull = m.number ? await client.getPullRequest(m.owner, m.repo, m.number).catch(() => undefined) : undefined;
const target = pull?.html_url || undefined;
if (m.number && pull?.base) bases.set(`${m.owner}/${m.repo}#${m.number}`, pull.base);
let status = groupGateStatus(c, m);
if (m.trunk && pull?.base) {
const head = await client.branchHead(m.owner, m.repo, pull.base).catch(() => undefined);
if (head && !sameCommit(m.trunk, head)) status = { state: "pending", context: CHECK_CONTEXT, description: outOfDateWords(m.trunk, head) };
}
await client.setCommitStatus(m.owner, m.repo, m.commit, target ? { ...status, target_url: target } : status);
console.log(`[gitea] ${m.owner}/${m.repo}#${m.number ?? "?"} at ${m.commit.slice(0, 8)}: the group ${c.group}'s composed check ${c.verdict}, ` +
`its merge gate ${status.state}`);
}, c.id);
}
// The siblings of each member are the other members at the heads and base commits this verdict composed (issue 1049): a
// sibling's own merge of such a head is no move of the member's base. Kept for what the merge tool and the siblings'
// weighing read; nothing is run.
if (c.verdict && c.members?.length) {
const members = c.members.map((m) => ({ ...m, base: m.number ? bases.get(`${m.owner}/${m.repo}#${m.number}`) : undefined }));
if (noteComposed(siblingNamed, members)) keepSiblings(siblingsRecord, siblingNamed);
}
}
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, // **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last // where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. // (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
const gates = new GateWriter({ const gates = new GateWriter({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT],
gateHistory: (owner, repo, sha) => (gitea as GiteaClient).contextHistory(owner, repo, sha, CHECK_CONTEXT),
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha), headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
}); });
+5 -4
View File
@@ -20,7 +20,7 @@ import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js"; import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { checkGatesMerge } from "./mainrules.js"; import { checkGatesMerge } from "./mainrules.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js"; import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/; const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
@@ -47,7 +47,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
"optionally deleting the source branch afterwards. Refused " + "optionally deleting the source branch afterwards. Refused " +
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " + "otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " + "reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
"the trunk's head is asked once: ask again at the same head " + "the trunk's head is asked once, with how long the last check of the repository took: ask again at the same head " +
"once it passes — the tool never merges by itself.", "once it passes — the tool never merges by itself.",
input: { input: {
owner: { type: "string", description: "the repository owner" }, owner: { type: "string", description: "the repository owner" },
@@ -163,6 +163,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined; const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})` const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})`
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against"; : at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
const head = twelve(pull.head_sha), now = twelve(trunkHead); const head = twelve(pull.head_sha), now = twelve(trunkHead);
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own // **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was // check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
@@ -175,7 +176,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
if (at.onItsWay) { if (at.onItsWay) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
`${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` + `${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` +
`asked again. Ask again at ${head} once both statuses pass: this tool never merges by itself ` + `asked again; ${took}. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
`(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`); `(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`);
} }
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An // Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
@@ -202,7 +203,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
`${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`); `${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`);
} }
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
`${now}. The check of head ${head} merged into trunk ${now} was asked. Ask again at ${head} once both ` + `${now}. The check of head ${head} merged into trunk ${now} was asked; ${took}. Ask again at ${head} once both ` +
`statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` + `statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` +
`move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` + `move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` +
`again with mesh-delivery.recheck. Nothing was merged`); `again with mesh-delivery.recheck. Nothing was merged`);
+277 -7
View File
@@ -1,15 +1,17 @@
// A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull // A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull
// request's head moves, and the status contexts the check is kept under. // request's head moves, and what it sets as the pull request's status when the mesh says the verdict.
// //
// **Before merge, never after.** Each new head of an open pull request is announced as `pull.updated`, and the mesh // **Before merge, never after.** Every check the mesh had ran after a merge, on a machine: a manifest the
// checks it against every machine of its facts before it merges. The verdict is the checker's `mesh/check`, which this // node-engine refuses (issue 236), an identity a real machine's name made too long (263). So each new head
// module writes from the checker's owed state (check.ts); the controller's `checked`, which once set the two older // of an open pull request is announced as `pull.updated`; the controller asks the build seat to check it
// statuses, is no event any more (ADR 0571 clause 13). // against every machine of the mesh's facts; and the verdict comes back as the controller's `checked`,
// which this sets as the head commit's status — and, when it is not a pass, as a comment saying why.
// //
// Pure functions here, so they are tested without a forge. // Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
import type { GiteaPull, GiteaRepo } from "./client.js"; import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js";
import { movedSince } from "./client.js"; import { movedSince } from "./client.js";
import { UNKNOWN_MARK, besideWords, siblingsOf } from "./siblings.js";
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */ /** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
export const CHECK_CONTEXT = "mesh/merge-gate"; export const CHECK_CONTEXT = "mesh/merge-gate";
@@ -57,6 +59,88 @@ export function pendingWords(announced: Date): string {
return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`; return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`;
} }
/** One layer of a check, judged. */
export interface Layer {
verdict: string;
summary: string;
/** The modules a merge of the change would move, as the controller's planner reckons it… */
modules?: string[];
/** …and those it would build after them because they stand on them. */
dependents?: string[];
}
/** What the controller says as `checked` (mesh-controller internal/link, Checked). */
export interface Checked {
owner: string;
repo: string;
number?: number;
commit: string;
verdict: string;
summary: string;
report?: string;
id: string;
on?: string;
/** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A
* controller from before the layers says neither, and its verdict is the gate's. */
gate?: Layer;
"repo-check"?: Layer;
/** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */
plan?: ChangePlan;
/** Set on a delivery group's composed check (novox/hq ADR 0239): its verdict is each member's merge gate (ADR 0403 §1,
* issue 978), set in setGroupVerdict, never read as this head's own. */
group?: string;
/** The members a composed check read, each at its head and the trunk commit it was merged onto (ADR 0323 §6). */
members?: { owner: string; repo: string; number?: number; commit: string; trunk?: string }[];
/** The trunk commit the head was merged into and judged as, twelve characters (novox/hq ADR 0323 §3); absent from a
* controller whose build seat judged the head alone. Beside are the other repositories' commits the check read,
* by directory; took how long it ran. */
trunk?: string;
beside?: Record<string, string>;
took?: string;
/** The other members of its delivery group the check read beside the head, each at its head merged onto its base
* commit (novox/hq ADR 0382 §4); absent when it read none, and from a controller from before the rule. */
siblings?: { repository: string; number: number; head: string; base: string; base_commit: string }[];
/** Why the siblings could not be learned (mesh-delivery did not answer), when that is so. */
siblings_unknown?: string;
/** Where the judge came from, when it came from the group (ADR 0382 §3): said in the account. */
judge_from?: string;
}
/** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */
export interface ChangePlan {
repository: string;
base: string;
head: string;
moved?: string[];
dependents?: string[];
new?: string[];
unread?: string[];
tiers?: string[][];
machines?: { machine: string; receives?: string[]; waits?: string[] }[];
steps?: string[];
summary: string;
}
/** Whether a plan builds anything: a change that touches the mesh's graph. */
function builds(plan?: ChangePlan): boolean {
return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0);
}
/** A delivery plan (the controller's ChangePlan) as a person reads it on the pull request. */
export function planText(plan: ChangePlan): string {
const lines = [`**Delivery plan** — ${plan.summary}`];
(plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`));
for (const m of plan.machines ?? []) {
const parts: string[] = [];
if (m.receives?.length) parts.push(`receives ${m.receives.join(", ")}`);
if (m.waits?.length) parts.push(`waits for a person: ${m.waits.join(", ")}`);
lines.push(`- ${m.machine}: ${parts.join("; ")}`);
}
for (const step of plan.steps ?? []) lines.push(`- ${step}`);
if (plan.unread?.length) lines.push(`- read by no module's build: ${plan.unread.join(", ")}`);
return lines.join("\n");
}
/** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */ /** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */
export type Announced = Record<string, string>; export type Announced = Record<string, string>;
@@ -92,11 +176,197 @@ export function reposToLook(repos: GiteaRepo[], floor: string, pulls: PullSeen[]
return repos.filter((r) => moved.has(r.full_name)); return repos.filter((r) => moved.has(r.full_name));
} }
// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a
// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no
// administrator override — will not merge past one. So the controller's `warning`, a note and never a
// question, is set as `success` with the note in its description; only what a person must decide is a
// `failure`, with why. The mapping, verdict by verdict:
//
// pass → success
// warning (a note: rebuild width, a problem
// already so on the base, a script's own) → success, "pass, with a note: …"
// repo-check, no merge-check.sh, not required → success, "no repository check defined"
// repo-check, no merge-check.sh, required (or
// the protection unreadable) → failure: a person adds one, or lifts the requirement
// fail → failure
// error, or no verdict → error — never a success
/** The forge's state for a verdict: an error is the forge's `error`, never a success; a warning is a note. */
function stateOf(verdict: string): CommitStatus["state"] {
return verdict === "pass" || verdict === "warning" ? "success" : verdict === "fail" ? "failure" : "error";
}
/** How a verdict is said in a status's description: a warning as the pass with a note it is. */
function saidAs(verdict: string): string {
return verdict === "warning" ? "pass, with a note" : verdict || "error";
}
function described(verdict: string, summary: string, modules?: string[], dependents?: string[]): string {
// The forge keeps a short description; the rest is the comment's.
let d = `${saidAs(verdict)}: ${summary}`;
if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`;
return clipped(d);
}
export function clipped(d: string): string { export function clipped(d: string): string {
d = d.replace(/\s+/g, " ").trim(); d = d.replace(/\s+/g, " ").trim();
return d.length > 140 ? d.slice(0, 139) + "…" : d; return d.length > 140 ? d.slice(0, 139) + "…" : d;
} }
/** What the forge says of a repository's own check beyond the controller's verdict, asked by index.ts only
* when the verdict is a warning: whether the head holds a merge-check.sh, and whether the base branch's
* protection requires mesh/repo-check. Unknown is undefined. */
export interface RepoCheckFacts {
defined?: boolean;
required?: boolean;
}
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
// required would let an untested repository merge. The base's protection is the one the forge applies, read
// from the branch: the rules' own list is a repository administrator's, which the tools' account is not
// (novox/hq ADR 0365).
export async function repoCheckFacts(
forge: Pick<GiteaClient, "holdsFile" | "appliedProtection">, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
if (c["repo-check"]?.verdict !== "warning") return {};
const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
if (defined !== false) return { defined };
const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined);
if (rule === undefined) return { defined };
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
return { defined, required };
}
/** The description of a required repository check that is not defined. */
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
"a person adds one, or lifts the requirement from the branch's protection";
/** The repository check's status: its verdict, unless the repository defines no check at all — then
* success where the check is not required, and a failure for a person where it is (or may be). */
function repoStatus(repo: Layer, facts: RepoCheckFacts): CommitStatus {
if (repo.verdict === "warning" && facts.defined === false) {
return facts.required === false
? { state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" }
: { state: "failure", context: REPO_CHECK_CONTEXT, description: clipped(UNDEFINED_REQUIRED) };
}
return { state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) };
}
/** Whether the repository check is a failure a person must read: failed, could not run, or required and
* not defined. */
function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
return !!repo && repoStatus(repo, facts).state !== "success";
}
/** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say /** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say
* nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */ * nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */
export const WAITS_FOR_COMPOSED = "waits for its group's composed check"; export const WAITS_FOR_COMPOSED = "waits for its group's composed check";
/** The forge's status for the gate. */
export function statusFor(c: Checked): CommitStatus {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
// With a plan, the status says what the change does and how it was judged: "pass: builds gitea → anchor;
// no bus step; every machine composes…".
const description = builds(c.plan)
? described(gate.verdict, `${c.plan!.summary}; ${gate.summary}`)
: described(gate.verdict, gate.summary, gate.modules, gate.dependents);
return judgedFirst(c, { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description });
}
/**
* **A member's gate from its group's composed verdict** (novox/hq ADR 0403 §1, issue 978): the composed check, which
* composed every machine with all the members' heads, sets each member's `mesh/merge-gate` at the head it read. The
* description names what it judged first, as a member's own gate does (ADR 0323 §3), and the group.
*/
export function groupGateStatus(c: Checked, m: NonNullable<Checked["members"]>[number]): CommitStatus {
const said = described(c.verdict, `group ${c.group} composed: ${c.summary}`);
const first = m.trunk ? `head ${m.commit.slice(0, 12)} merged into trunk ${m.trunk.slice(0, 12)} — ` : "";
return { state: stateOf(c.verdict), context: CHECK_CONTEXT, description: clipped(first + said) };
}
/** Whether a verdict's gate layer is the group's composed check's (ADR 0403 §1): no gate was run for the head alone. */
export function composedGate(c: Checked): boolean {
return c.gate?.verdict === "composed";
}
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said; of them, only those the
* base requires (`required`, the base's rule: maintained). */
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string, required?: string[]): CommitStatus[] {
const kept = maintained(required);
return allStatusesFor(c, facts, gateNow).filter((s) => kept.includes(s.context));
}
function allStatusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] {
if (composedGate(c)) {
// A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
const out: CommitStatus[] = [];
if (!(gateNow ?? "").includes(" composed: ")) {
out.push({ state: "pending", context: CHECK_CONTEXT, description: WAITS_FOR_COMPOSED });
}
const repo = c["repo-check"];
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
return out;
}
const out = [statusFor(c)];
const repo = c["repo-check"];
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
return out;
}
/** **A status names what its verdict judged, first** (novox/hq ADR 0323 §3): "head 1a2b3c4d5e6f merged into trunk
* 7a8b9c0d1e2f — pass: …", so the forge's 140 characters never clip it away; the trunk's move is weighed by it. A
* verdict that names no trunk commit is said as before. */
function judgedFirst(c: Checked, status: CommitStatus): CommitStatus {
// The siblings it read, named after the commits (ADR 0382 §4), so the clip never takes them either.
const siblings = siblingsOf(c.siblings);
// A verdict that could not learn them says so on the status too (clause 1: it is still checked, and merges).
const mark = siblings.length ? besideWords(siblings) : c.siblings_unknown ? UNKNOWN_MARK : "";
if (!c.trunk) return mark ? { ...status, description: clipped(`${mark} — ${status.description}`) } : status;
return { ...status, description: clipped(`head ${c.commit.slice(0, 12)} merged into trunk ${c.trunk.slice(0, 12)}${mark ? " " + mark : ""} — ${status.description}`) };
}
/** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a
* change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the
* repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing,
* is said by its statuses alone, not by a comment on every push. */
export function commentFor(c: Checked, facts: RepoCheckFacts = {}, required?: string[]): string | null {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
// A base that no longer requires the gate (issue 1301) is told nothing of it: its verdict is the repository's own check.
const withGate = writesGate(required);
const repo = c["repo-check"];
const wrong = repoWrong(repo, facts);
if ((!withGate || gate.verdict === "pass") && !wrong && !builds(c.plan)) return null;
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
if (c.trunk) {
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and what was read beside it.
const beside = Object.entries(c.beside ?? {}).map(([d, sha]) => `${d} \`${sha}\``).join(", ");
lines.splice(1, 0, `Judged: head \`${c.commit.slice(0, 12)}\` merged into trunk \`${c.trunk.slice(0, 12)}\`` +
(beside ? `; read beside it: ${beside}` : ""));
}
const siblings = siblingsOf(c.siblings);
if (siblings.length) {
lines.splice(c.trunk ? 2 : 1, 0, `Siblings read beside it: ` + siblings.map((s) =>
`${s.repository}#${s.number} \`${s.head.slice(0, 12)}\` merged onto \`${s.base_commit.slice(0, 12)}\` (${s.base})`).join(", ") +
(c.judge_from ? `; judged by ${c.judge_from}` : ""));
} else if (c.siblings_unknown) {
lines.splice(c.trunk ? 2 : 1, 0, `Siblings could not be learned: ${c.siblings_unknown}`);
}
if (withGate) {
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
}
if (repo) {
const st = repoStatus(repo, facts);
lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false
? `**${st.state === "success" ? "PASS" : "FAIL"}** — ${st.description.replace(/^fail: /, "")}`
: `**${saidAs(repo.verdict).toUpperCase()}** — ${repo.summary}`));
}
if (builds(c.plan)) lines.push("", planText(c.plan!));
const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : "";
const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : "";
return lines.join("\n") + ran + report;
}
+42
View File
@@ -119,6 +119,48 @@ export function keepSiblings(file: string | null, record: SiblingRecord): void {
} }
} }
/** A member's latest verdict, kept: its siblings when it read some, else the entry removed. Answers whether it changed. */
export function noteVerdict(record: SiblingRecord, key: string, head: string, siblings: SiblingNamed[]): boolean {
if (siblings.length === 0) {
if (!(key in record)) return false;
delete record[key];
return true;
}
record[key] = { head, siblings };
return true;
}
/** One member of a composed verdict, as the group's composed check read it: its pull request, the head it judged, the
* trunk commit that head was merged onto, and the branch the pull request merges into. */
export interface ComposedMember {
owner: string;
repo: string;
number?: number;
commit: string;
trunk?: string;
base?: string;
}
/**
* **A group's composed verdict names, for each member, the other members at the heads and base commits it composed**
* (novox/hq ADR 0403 §5, issue 1049): the composed check read every member's head together, so it is the verdict to trust
* for the siblings. Without this, the record held what a member's OWN verdict named, which went stale when a sibling got
* a new head; the sibling's own merge of the head the composed check judged then read as another head's merge, put the
* member's gate back to pending, and the group merged in two passes. A member the verdict cannot name (no pull request
* number, trunk commit or base branch), or a verdict of fewer than two such members, changes nothing. Answers whether
* the record changed.
*/
export function noteComposed(record: SiblingRecord, members: ComposedMember[]): boolean {
const named = members.filter((m) => m.owner && m.repo && m.number && m.commit && m.trunk && m.base);
if (named.length < 2 || named.length !== members.length) return false;
for (const m of named) {
const siblings = named.filter((o) => o !== m).map((o): SiblingNamed => ({
repository: `${o.owner}/${o.repo}`, number: o.number!, head: o.commit, base: o.base!, base_commit: o.trunk! }));
record[memberKey(m.owner, m.repo, m.number!)] = { head: m.commit, siblings };
}
return true;
}
// --- what a sibling's move is --------------------------------------------------------------------------------- // --- what a sibling's move is ---------------------------------------------------------------------------------
/** What weighing a sibling reads of the forge. */ /** What weighing a sibling reads of the forge. */
-55
View File
@@ -221,58 +221,3 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
server.close(); server.close();
} }
}); });
test("files: every blob under each asked directory, recursively, as its path relative to it with the blob's object id (ADR 0593 §2)", async () => {
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const got = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", ""]);
assert.deepEqual(got.files, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": { "c/module.json": h("9") },
"": { "README.md": h("1"), "module.json": h("2"), "modules/a/module.json": h("5"), "modules/a/main.go": h("6"), "modules/b/c/module.json": h("9"), "modules/d/notes.md": h("b") },
});
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).files, undefined, "no files asked, none answered");
} finally {
server.close();
}
});
test("a directory of files the commit lacks, or spelled badly, or a listing the forge cut, fails and answers nothing", async () => {
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/gone"]), (err: Error) => {
assert.match(err.message, /modules\/gone/);
return true;
});
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, ["README.md"]), /README\.md/, "a file is no directory");
for (const bad of ["../x", "/abs", "a//b", "modules/a/", "."]) {
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, [bad]), /files/, bad);
}
} finally {
server.close();
}
const cut = await serve([{ sha: COMMIT, tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]);
try {
await assert.rejects(cut.client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a"]), /cut/);
} finally {
cut.server.close();
}
});
test("gitea_commit_trees takes files as a list of directories and refuses another shape before the forge is asked", async () => {
const { getGiteaTools } = await import("../tools/index.ts");
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
assert.ok("files" in tool.input, "the tool names the argument");
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, files: ["modules/a"] } as any);
assert.deepEqual(got.files, { "modules/a": { "main.go": h("6"), "module.json": h("5") } });
const before = asked.length;
for (const files of ["modules/a", { "modules/a": [] }, [1], ["../x"]]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, files } as any), /files/, JSON.stringify(files));
}
assert.equal(asked.length, before, "the forge was asked for a refused call");
} finally {
server.close();
}
});
+258 -16
View File
@@ -1,20 +1,32 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
// **One writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, a recheck's pending and // **A pending check says where it is, and the verdict lands last** (novox/hq issue 446). This module is the one writer
// a trunk's move, each write of a head in the order it was asked. // of a head's mesh/merge-gate: the announcement's pending words, "building on <machine> since …" when the build seat
// says a machine took the check, and the verdict. The forge keeps the newest status of a context, so a pending word
// written after the verdict hides it — a pass reads pending, a failure hides under "building".
const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3"; const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3";
// The build seat's `started` as a machine said it for novox/hq#309's check on 2026-10-11 (its log, read with the
// controller's `builds --log build-1791680941048622697`, opens at 03:09:01 on shanks); only the forge's address
// replaced. The shape is mesh-controller's link.BuildStart.
const startedOnShanks = { id: "build-1791680941048622697", repository: "http://127.0.0.1:20000/novox/hq.git",
ref: SHA, on: "shanks", at: "2026-10-11T01:09:01.204319Z" };
/** A forge that keeps the newest status per context, as gitea does, and whose writes take as long as a test says. */ /** A forge that keeps the newest status per context, as gitea does, and whose writes take as long as a test says. */
function aForge() { function aForge() {
const kept: Record<string, { state: string; description: string; updated_at: string }> = {}; const kept: Record<string, { state: string; description: string; updated_at: string }> = {};
const written: string[] = []; const written: string[] = [];
/** Every mesh/merge-gate status the forge was given, newest first, as gitea keeps each one. */
const history: { state: string; description: string; updated_at: string }[] = [];
let failNext = 0; let failNext = 0;
let hold: Promise<void> | null = null; let hold: Promise<void> | null = null;
let release = () => {}; let release = () => {};
let readHold: Promise<void> | null = null;
let readRelease = () => {};
return { return {
kept, written, kept, written, history,
/** The next writes fail: a request the forge did not take. */ /** The next writes fail: a request the forge did not take. */
failWrites(n = 1) { failWrites(n = 1) {
failNext = n; failNext = n;
@@ -27,9 +39,6 @@ function aForge() {
hold = null; hold = null;
release(); release();
}, },
async headChecks() {
return {};
},
async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) { async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) {
if (hold) await hold; if (hold) await hold;
if (failNext > 0) { if (failNext > 0) {
@@ -37,35 +46,226 @@ function aForge() {
throw new Error("the forge answered 502"); throw new Error("the forge answered 502");
} }
kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() }; kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() };
if (s.context === "mesh/merge-gate") history.unshift(kept[s.context]);
written.push(`${s.state} ${s.description}`); written.push(`${s.state} ${s.description}`);
}, },
/** Reads answer what the forge held when asked, as late as the reads are held: the answer of a request in flight. */
holdReads() {
readHold = new Promise<void>((r) => (readRelease = r));
},
releaseReads() {
readHold = null;
readRelease();
},
async gateOf(_o: string, _r: string, _sha: string) {
const held = kept["mesh/merge-gate"] ? { ...kept["mesh/merge-gate"] } : undefined;
if (readHold) await readHold;
return held;
},
async gateHistory(_o: string, _r: string, _sha: string) {
return history.map((h) => ({ ...h }));
},
}; };
} }
// **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back /** The clock of the test: the head announced at 01:08:50Z. */
// to pending through the gitea_commit_status tool. const announcedAt = () => new Date("2026-10-11T01:08:50Z");
test("a build machine taking the check is said on the head: building on which machine, since the local time", async () => {
const { GateWriter, buildingWords } = await import("../gate.ts");
const forge = aForge();
const gates = new GateWriter(forge, announcedAt);
await gates.pending("novox", "hq", SHA);
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true);
assert.equal(forge.kept["mesh/merge-gate"].state, "pending");
assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(startedOnShanks.at)));
assert.equal(buildingWords("shanks", new Date(2026, 9, 11, 3, 9, 1)), "building on shanks since 03:09");
});
test("the verdict heard while a 'building' write is in flight lands last and stays", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
const gates = new GateWriter(forge, announcedAt);
await gates.pending("novox", "hq", SHA);
forge.holdWrites(); // the 'building' request is in flight when the verdict comes
const building = gates.started("novox", "hq", SHA, startedOnShanks);
await new Promise((r) => setImmediate(r));
const verdict = gates.verdict("novox", "hq", SHA, () =>
forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" }));
forge.release();
await Promise.all([building, verdict]);
assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass",
`the verdict is not the newest status: ${forge.written.join(" | ")}`);
});
// The race the review of mesh-catalog#202 reproduced: a writer reads the forge's gate (pending), the verdict is written,
// then the writer writes "building" — and the pass reads pending until the next push. A head this process did not
// announce is the one case where the forge is read before writing.
test("the verdict written while a start's read of the forge is in flight stays the newest status", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" };
const gates = new GateWriter(forge, announcedAt);
forge.holdReads();
const building = gates.started("novox", "hq", SHA, startedOnShanks);
await new Promise((r) => setImmediate(r));
const verdict = gates.verdict("novox", "hq", SHA, () =>
forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" }));
await new Promise((r) => setImmediate(r));
forge.releaseReads();
await Promise.all([building, verdict]);
assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass",
`the verdict is not the newest status: ${forge.written.join(" | ")}`);
});
test("the verdict heard while a 'building' write still waits its turn: the 'building' is never written", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
const gates = new GateWriter(forge, announcedAt);
forge.holdWrites(); // the pending words are still being written
const pending = gates.pending("novox", "hq", SHA);
const building = gates.started("novox", "hq", SHA, startedOnShanks);
const verdict = gates.verdict("novox", "hq", SHA, () =>
forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "fail: 0 of 4 compose" }));
forge.release();
await Promise.all([pending, verdict]);
assert.equal(await building, false);
assert.equal(forge.kept["mesh/merge-gate"].state, "failure");
assert.ok(!forge.written.some((w) => w.includes("building")), forge.written.join(" | "));
});
test("a start heard after the verdict writes nothing", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
const gates = new GateWriter(forge, announcedAt);
await gates.pending("novox", "hq", SHA);
await gates.verdict("novox", "hq", SHA, () =>
forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" }));
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
assert.equal(forge.kept["mesh/merge-gate"].state, "success");
});
test("a start from before the check now pending (a run before a recheck, the stream's history) writes nothing", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
let now = announcedAt();
const gates = new GateWriter(forge, () => now);
await gates.pending("novox", "hq", SHA);
await gates.verdict("novox", "hq", SHA, () =>
forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "error" }));
now = new Date("2026-10-11T01:30:00Z"); // checked again: the recheck's pending, as the tool writes it (issue 456)
await gates.rechecked("novox", "hq", SHA, { state: "pending", context: "mesh/merge-gate", description: "checking again: flaky" });
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false, "the first run's start");
assert.equal(forge.kept["mesh/merge-gate"].description, "checking again: flaky");
assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, at: "2026-10-11T01:31:00Z" }), true,
"the second run's start");
});
// **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back
// to pending through the gitea_commit_status tool; before, that write bypassed the head's chain: the first run's
// verdict mark stayed set, so the new run never said "building", and an old run's verdict could land over the pending.
/** The ask of a check, as the controller's link.NewBuildID names it: when it was asked, in unix nanoseconds. */
const askOf = (at: string) => `build-${BigInt(Date.parse(at)) * 1_000_000n}`;
const firstRun = askOf("2026-10-11T01:09:01Z");
const recheckAt = new Date("2026-10-11T01:30:00Z"); const recheckAt = new Date("2026-10-11T01:30:00Z");
const secondRun = askOf("2026-10-11T01:30:02Z");
const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" }; const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" };
const writeOf = (forge: ReturnType<typeof aForge>, state: string, description: string) => () =>
forge.setCommitStatus("novox", "hq", SHA, { state, context: "mesh/merge-gate", description });
const gateOf = (forge: ReturnType<typeof aForge>) => const gateOf = (forge: ReturnType<typeof aForge>) =>
`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`; `${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`;
test("a repository as the mesh clones it is told from another", async () => { test("an old run's verdict redelivered after a recheck's pending is not written over it", async () => {
const { clonedAs } = await import("../gate.ts"); const { GateWriter } = await import("../gate.ts");
assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true); const forge = aForge();
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false); let now = announcedAt();
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false); const gates = new GateWriter(forge, () => now);
await gates.pending("novox", "hq", SHA);
forge.failWrites(); // the first run's verdict is not taken: the event is offered again
await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun));
now = recheckAt;
await gates.rechecked("novox", "hq", SHA, checkingAgain);
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); // redelivered
assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | "));
// The recheck's own verdict is written.
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail: 0 of 4 compose"), secondRun);
assert.equal(gateOf(forge), "failure fail: 0 of 4 compose");
}); });
test("a recheck the forge does not take fails its caller, and the next write still takes its turn", async () => { test("an old run's verdict in flight when the recheck comes lands before the recheck's pending", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
let now = announcedAt();
const gates = new GateWriter(forge, () => now);
await gates.pending("novox", "hq", SHA);
forge.holdWrites(); // the first run's verdict is being written when the recheck comes
const verdict = gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
await new Promise((r) => setImmediate(r));
now = recheckAt;
const pending = gates.rechecked("novox", "hq", SHA, checkingAgain);
forge.release();
await Promise.all([verdict, pending]);
assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | "));
});
test("a rechecked head says where its new run is: building on which machine", async () => {
const { GateWriter, buildingWords } = await import("../gate.ts");
const forge = aForge();
let now = announcedAt();
const gates = new GateWriter(forge, () => now);
await gates.pending("novox", "hq", SHA);
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
now = recheckAt;
await gates.rechecked("novox", "hq", SHA, checkingAgain);
const at = "2026-10-11T01:30:03Z";
assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, id: secondRun, at }), true);
assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(at)));
});
test("the recheck's own verdict is written, even asked a little before the pending by another machine's clock", async () => {
const { GateWriter } = await import("../gate.ts"); const { GateWriter } = await import("../gate.ts");
const forge = aForge(); const forge = aForge();
const gates = new GateWriter(forge, () => recheckAt); const gates = new GateWriter(forge, () => recheckAt);
await gates.rechecked("novox", "hq", SHA, checkingAgain);
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), askOf("2026-10-11T01:29:30Z"));
assert.equal(gateOf(forge), "success pass");
// A verdict whose ask cannot be read is written: a verdict is never lost to what cannot be weighed.
await gates.rechecked("novox", "hq", SHA, checkingAgain);
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), "an id of another shape");
assert.equal(gateOf(forge), "failure fail");
});
test("after a restart, an old run's verdict is weighed against the check the forge's history holds open", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
// What the forge kept before this process started: the announcement, the first run's verdict never written,
// the recheck's pending, and the second run's "building" over it.
forge.history.unshift({ state: "pending", description: "waiting in the build queue since 03:08", updated_at: "2026-10-11T01:08:50Z" });
forge.history.unshift({ state: "pending", description: "checking again: main moved on", updated_at: "2026-10-11T01:30:00Z" });
forge.history.unshift({ state: "pending", description: "building on shanks since 03:30", updated_at: "2026-10-11T01:30:05Z" });
forge.kept["mesh/merge-gate"] = forge.history[0];
const gates = new GateWriter(forge, announcedAt);
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
assert.equal(forge.kept["mesh/merge-gate"].description, "building on shanks since 03:30", forge.written.join(" | "));
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), secondRun);
assert.equal(gateOf(forge), "failure fail");
});
test("a recheck the forge does not take fails its caller, and the head is as it was", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
let now = announcedAt();
const gates = new GateWriter(forge, () => now);
await gates.pending("novox", "hq", SHA); await gates.pending("novox", "hq", SHA);
forge.failWrites(); forge.failWrites();
await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun));
now = recheckAt;
forge.failWrites();
await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain)); await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain));
await gates.rechecked("novox", "hq", SHA, checkingAgain); // The recheck was refused, so its check is not asked: the first run's verdict, offered again, is the head's.
assert.equal(gateOf(forge), "pending checking again: main moved on"); await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
assert.equal(gateOf(forge), "success pass");
}); });
test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => { test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => {
@@ -78,15 +278,57 @@ test("the status tool writes the merge check's pending through the writer, and t
setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`), setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`),
gates, gates,
}); });
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" }); await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" });
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" }); await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" });
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" }); await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" });
assert.equal(gateOf(forge), "pending checking again: x"); assert.equal(gateOf(forge), "pending checking again: x");
assert.equal(forge.kept["mesh/repo-check"].state, "pending"); assert.equal(forge.kept["mesh/repo-check"].state, "pending");
assert.deepEqual(direct, ["mesh/delivery success"]); assert.deepEqual(direct, ["mesh/delivery success"]);
// The redelivered first run's verdict is the writer's to drop.
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
assert.equal(gateOf(forge), "pending checking again: x");
// A head named short is another head to the writer: refused, nothing sent. // A head named short is another head to the writer: refused, nothing sent.
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }), await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }),
/whole sha/); /whole sha/);
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }), await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }),
/set by its verdict only/); /set by its verdict only/);
}); });
test("when a check was asked is read from its id; the opening of the check now pending from the gate's history", async () => {
const { askedAt, openedAt } = await import("../gate.ts");
assert.equal(askedAt("build-1791680941048622697"), Date.parse("2026-10-11T01:09:01.048Z"));
assert.equal(askedAt("build-mesh-catalog"), undefined);
assert.equal(askedAt(undefined), undefined);
assert.equal(openedAt([{ state: "pending", description: "building on ace since 03:31", updated_at: "2026-10-11T01:31:00Z" },
{ state: "pending", description: "checking again: x", updated_at: "2026-10-11T01:30:00Z" }]), Date.parse("2026-10-11T01:30:00Z"));
assert.equal(openedAt([{ state: "success", description: "pass", updated_at: "2026-10-11T01:30:00Z" }]), undefined);
assert.equal(openedAt([]), undefined);
});
test("a head this process did not announce (it restarted) is said only while the forge holds its gate pending", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
forge.kept["mesh/merge-gate"] = { state: "success", description: "pass", updated_at: "2026-10-11T01:20:00Z" };
const gates = new GateWriter(forge, announcedAt);
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" };
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true);
});
test("a repository as the mesh clones it is told from another", async () => {
const { clonedAs } = await import("../gate.ts");
assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true);
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false);
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false);
});
// Issue 1301: a build machine's start on a head that holds no mesh/merge-gate (its base requires none, so none was written)
// says nothing: no "building on" status appears on a main that does not require the gate.
test("a start on a head with no gate on the forge writes nothing", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
const gates = new GateWriter(forge, announcedAt);
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
assert.equal(forge.kept["mesh/merge-gate"], undefined);
});
+9 -7
View File
@@ -179,7 +179,7 @@ const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("
const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-")); const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-"));
// The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through. // The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through.
const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s),
headChecks: async () => ({}) }); gateOf: async () => undefined, gateHistory: async () => [], headChecks: async () => ({}) });
const throughTheWriter: string[] = []; const throughTheWriter: string[] = [];
const gates = { rechecked: (o: string, r: string, sha: string, s: any) => { const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`); throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`);
@@ -298,15 +298,18 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su
// **The merge tool refuses a verdict whose trunk commit is not the trunk's head, and asks the check itself** (novox/hq // **The merge tool refuses a verdict whose trunk commit is not the trunk's head, and asks the check itself** (novox/hq
// ADR 0323 §5, issue 341): node-engine #66 merged at 00:54 on a verdict of 00:49 that judged its head alone, after #65 // ADR 0323 §5, issue 341): node-engine #66 merged at 00:54 on a verdict of 00:49 that judged its head alone, after #65
// had moved the trunk. Both commits are named; one check is asked, the announcement a new head makes, and not a second // had moved the trunk. Both commits are named; one check is asked, the announcement a new head makes, and not a second
// while it is on its way; and once the verdict judges // while it is on its way; the refusal says how long the last check of the repository took; and once the verdict judges
// the trunk's head and passes, the merge goes through — asked again, never by itself. // the trunk's head and passes, the merge goes through — asked again, never by itself.
test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => { test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
const { keepTook, tookFile } = await import("../dist/trunk.js");
keepTook(tookFile(stateDir), "novox/mesh-catalog", { took: "4m31s", at: "2026-10-12T14:49:00Z" });
state.trunk = MOVED; // another merge moved the trunk after the verdict state.trunk = MOVED; // another merge moved the trunk after the verdict
await assert.rejects(merge(), (err: Error) => { await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /is not merged: its verdict was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b/); assert.match(err.message, /is not merged: its verdict was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b/);
assert.match(err.message, /check of head abc1234def56 merged into trunk 3c4d5e6f7a8b was asked/); assert.match(err.message, /check of head abc1234def56 merged into trunk 3c4d5e6f7a8b was asked/);
assert.match(err.message, /the last check of novox\/mesh-catalog took 4m31s/);
assert.match(err.message, /never merges by itself/); assert.match(err.message, /never merges by itself/);
return true; return true;
}); });
@@ -326,7 +329,7 @@ test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk
assert.equal(state.statuses.find((s) => s.context === "mesh/repo-check")!.state, "pending"); assert.equal(state.statuses.find((s) => s.context === "mesh/repo-check")!.state, "pending");
// Asked again while that check is on its way: refused, and nothing is asked a second time. // Asked again while that check is on its way: refused, and nothing is asked a second time.
await assert.rejects(merge(), /already asked and not yet answered .*not asked again.*mesh-delivery\.recheck/); await assert.rejects(merge(), /already asked and not yet answered .*not asked again; the last check of novox\/mesh-catalog took 4m31s.*mesh-delivery\.recheck/);
assert.equal(state.emitted.length, 1, "no second check while one is on its way"); assert.equal(state.emitted.length, 1, "no second check while one is on its way");
state.statuses = [{ ...gate, description: "building on ace since 15:02" }, ...state.statuses.slice(1)]; state.statuses = [{ ...gate, description: "building on ace since 15:02" }, ...state.statuses.slice(1)];
await assert.rejects(merge(), /not asked again/); await assert.rejects(merge(), /not asked again/);
@@ -731,7 +734,7 @@ test("gitea_merge_pull_request refuses a head the Approve did not name", async (
// the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its // the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its
// base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does. // base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does.
test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => { test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => {
const { keepSiblings, siblingsFile, weighSiblings } = await import("../dist/siblings.js"); const { keepSiblings, siblingsFile, noteComposed, weighSiblings } = await import("../dist/siblings.js");
const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD]; const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD];
const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40); const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40);
const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`; const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`;
@@ -744,9 +747,8 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea
const record: Record<string, any> = { "novox/mesh-catalog#7": { head: REVIEWED, const record: Record<string, any> = { "novox/mesh-catalog#7": { head: REVIEWED,
siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } }; siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } };
// The composed verdict then judged both new heads. // The composed verdict then judged both new heads.
Object.assign(record, { noteComposed(record, [{ owner: "novox", repo: "mesh-catalog", number: 7, commit: REVIEWED, trunk: TRUNK.slice(0, 12), base: "main" },
"novox/mesh-catalog#7": { head: REVIEWED, siblings: [{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: HOST_BASE.slice(0, 12) }] }, { owner: "novox", repo: "mesh-host", number: 114, commit: SIBLING_HEAD, trunk: HOST_BASE.slice(0, 12), base: "main" }]);
"novox/mesh-host#114": { head: SIBLING_HEAD, siblings: [{ repository: "novox/mesh-catalog", number: 7, head: REVIEWED, base: "main", base_commit: TRUNK.slice(0, 12) }] } });
// The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what // The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what
// that weighing decides, through the writer. // that weighing decides, through the writer.
const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE }; const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE };
+153 -2
View File
@@ -29,6 +29,92 @@ test("a pull request retargeted keeps its head and is announced again, into its
assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk")); assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk"));
}); });
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
assert.equal(statusFor({ ...base, verdict: "pass", summary: "every machine composes" }).state, "success");
// A warning is a note, never a blocking state: the forge combines `warning` as a failure (issue 293).
const wide = statusFor({ ...base, verdict: "warning", summary: "a merge rebuilds 14 module(s)" });
assert.equal(wide.state, "success");
assert.match(wide.description, /^pass, with a note: a merge rebuilds 14 module/);
assert.equal(statusFor({ ...base, verdict: "fail", summary: "x" }).state, "failure");
assert.equal(statusFor({ ...base, verdict: "error", summary: "the check could not run" }).state, "error");
assert.equal(statusFor({ ...base, verdict: "", summary: "" }).state, "error", "no verdict is no pass");
const long = statusFor({ ...base, verdict: "fail", summary: "y".repeat(500) });
assert.ok(long.description.length <= 140 && long.context === CHECK_CONTEXT);
assert.equal(commentFor({ ...base, verdict: "pass", summary: "fine" }), null, "a pass leaves no comment");
const said = commentFor({ ...base, verdict: "fail", summary: "lemurs refused", report: "fails:\n - ```x```" }) ?? "";
assert.match(said, /mesh\/merge-gate`: \*\*FAIL\*\*/);
assert.match(said, /01234567/);
assert.match(said, /builds --log build-1/);
assert.ok(!said.slice(said.indexOf("```") + 3, said.lastIndexOf("```")).includes("```"), "the report cannot close its own block");
});
test("each layer is its own status: the gate with the modules it judged, the repository's own check beside it", async () => {
const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" };
const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes",
gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"], dependents: ["node-tools"] },
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } });
assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]);
assert.match(both[0].description, /gitea, keycloak \+1 dependent/);
assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" },
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/);
// Nothing of the graph touched, no script: a pass and a warning, and no comment on every push.
const quiet = { ...base, verdict: "pass", summary: "the change touches no module of the mesh's graph",
gate: { verdict: "pass", summary: "the change touches no module of the mesh's graph" },
"repo-check": { verdict: "warning", summary: "the repository declares no merge-check.sh" } };
assert.deepEqual(statusesFor(quiet, { defined: false, required: false }).map((s) => [s.state, s.description]),
[["success", "pass: the change touches no module of the mesh's graph"], ["success", "no repository check defined"]]);
assert.equal(commentFor(quiet, { defined: false, required: false }), null);
// A repository outside the mesh, touching nothing: the gate alone, a pass.
assert.equal(statusesFor({ ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" } }).length, 1);
// A controller from before the layers: its verdict is the gate's.
assert.deepEqual(statusesFor({ ...base, verdict: "warning", summary: "wide" }).map((s) => [s.context, s.state]),
[[CHECK_CONTEXT, "success"]]);
});
// **A required status is success or it blocks** (novox/hq issue 293): branch protection requires
// mesh/merge-gate (and mesh/repo-check on the core repositories) with no administrator override, and the
// forge combines `warning` as a failure. No verdict ever sets `warning` on either; a note is a success that
// says it; only what a person must decide is a failure, with why.
test("no verdict sets warning on a required check; only a person's decision fails", async () => {
const { statusesFor, commentFor, UNDEFINED_REQUIRED, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "photos", number: 3, commit: "0123456789abcdef", id: "build-2" };
const noScript = { verdict: "warning", summary: "the repository declares no merge-check.sh" };
const quiet = { ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" }, "repo-check": noScript };
for (const verdict of ["pass", "warning", "fail", "error", ""]) {
for (const facts of [{}, { defined: true }, { defined: false }, { defined: false, required: true }, { defined: false, required: false }]) {
const c = { ...base, verdict, summary: "s", gate: { verdict, summary: "s" }, "repo-check": { verdict, summary: "s" } };
for (const s of statusesFor(c, facts)) assert.notEqual(s.state, "warning", `${verdict} ${JSON.stringify(facts)} → ${s.context}`);
}
}
// A repository without a merge-check.sh where repo-check is not required: success, said.
assert.deepEqual(statusesFor(quiet, { defined: false, required: false })[1],
{ state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" });
// Where it is required — or the protection could not be read — a person decides: a failure, with why.
for (const facts of [{ defined: false, required: true }, { defined: false }]) {
const s = statusesFor(quiet, facts)[1];
assert.equal(s.state, "failure");
assert.ok(s.description.length <= 140 && UNDEFINED_REQUIRED.startsWith(s.description.replace(/…$/, "")));
assert.match(s.description, /^fail: mesh\/repo-check is required/);
assert.match(commentFor(quiet, facts) ?? "", /mesh\/repo-check`: \*\*FAIL\*\* — mesh\/repo-check is required/);
}
// A script that defines its check and said a warning itself: a note, a success.
const noted = statusesFor({ ...quiet, "repo-check": { verdict: "warning", summary: "2 tests skipped" } }, { defined: true })[1];
assert.deepEqual([noted.state, noted.description], ["success", "pass, with a note: 2 tests skipped"]);
// The gate's notes — a wide rebuild, a problem already so on the base — are successes that say so.
const already = statusesFor({ ...base, verdict: "warning", summary: "s",
gate: { verdict: "warning", summary: "the module check's problems were all so on main already" } })[0];
assert.deepEqual([already.state, already.description],
["success", "pass, with a note: the module check's problems were all so on main already"]);
assert.match(commentFor({ ...base, verdict: "warning", summary: "wide", gate: { verdict: "warning", summary: "wide" } }) ?? "",
/PASS, WITH A NOTE/);
});
test("a commit status is set on the commit, under the merge check's context", async () => { test("a commit status is set on the commit, under the merge check's context", async () => {
const { GiteaClient } = await import("../client.ts"); const { GiteaClient } = await import("../client.ts");
let seen: { path: string; body: any } | null = null; let seen: { path: string; body: any } | null = null;
@@ -54,6 +140,24 @@ test("a commit status is set on the commit, under the merge check's context", as
assert.equal(seen!.body.context, "mesh/merge-gate"); assert.equal(seen!.body.context, "mesh/merge-gate");
}); });
test("a change plan is the gate's result: said on the status and, when it builds something, as a comment", async () => {
const { statusFor, commentFor } = await import("../pulls.ts");
const plan = {
repository: "novox/mesh-catalog", base: "main", head: "0123456789abcdef", moved: ["gitea"],
tiers: [["gitea"]], machines: [{ machine: "anchor", receives: ["gitea"] }], steps: [],
summary: "builds gitea → anchor; no bus step",
};
const c = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "b", verdict: "pass",
summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan };
assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes");
const said = commentFor(c) ?? "";
assert.match(said, /Delivery plan\*\* — builds gitea → anchor/);
assert.match(said, /- anchor: receives gitea/);
// A plan that builds nothing, passing: the statuses say it, no comment.
const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } };
assert.equal(commentFor(nothing), null);
});
// **A pending check says where it is** (novox/hq issue 446): announced, it waits in the build queue, since the time the // **A pending check says where it is** (novox/hq issue 446): announced, it waits in the build queue, since the time the
// operator reads — not "mapping this head onto its module graph" for the ten minutes of a controller check. // operator reads — not "mapping this head onto its module graph" for the ten minutes of a controller check.
test("a head announced is pending in the build queue, since the local time it was announced", async () => { test("a head announced is pending in the build queue, since the local time it was announced", async () => {
@@ -62,10 +166,48 @@ test("a head announced is pending in the build queue, since the local time it wa
assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00"); assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00");
}); });
// **A group's composed verdict is each member's merge gate** (novox/hq ADR 0403 §1, issue 978): set at the head the
// composed check read, naming the trunk commit it was merged onto, the group, and what the check said.
test("a composed verdict sets every member's gate at its own head, naming its trunk and the group", async () => {
const { groupGateStatus, CHECK_CONTEXT } = await import("../pulls.ts");
const c = { owner: "", repo: "", commit: "", id: "build-1", group: "feat/x", verdict: "pass", summary: "both compose",
members: [{ owner: "novox", repo: "mesh-controller", number: 1, commit: "c".repeat(40), trunk: "a".repeat(40) },
{ owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40) }] };
const [first, second] = c.members.map((m) => groupGateStatus(c, m));
assert.equal(first.context, CHECK_CONTEXT);
assert.equal(first.state, "success");
assert.match(first.description, /^head cccccccccccc merged into trunk aaaaaaaaaaaa — pass: group feat\/x composed: both compose/);
assert.equal(second.state, "success");
assert.match(second.description, /^pass: group feat\/x composed: both compose/, "no trunk named, none said");
// A composed check that failed is every member's failure, never a success.
assert.equal(groupGateStatus({ ...c, verdict: "fail", summary: "a and b do not compose" }, c.members[0]).state, "failure");
assert.equal(groupGateStatus({ ...c, verdict: "error", summary: "could not run" }, c.members[0]).state, "error");
});
// A member's own verdict whose gate layer is the group's composed check's sets its repository check only, and never the
// gate as an error: the gate stays pending until the composed verdict lands, in either order (ADR 0403 §1, issue 978).
test("a member's own verdict with a composed gate layer leaves the gate to the composed verdict, in either order", async () => {
const { statusesFor, composedGate, groupGateStatus, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
const c = { owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40), id: "build-1", verdict: "pass", summary: "x",
gate: { verdict: "composed", summary: "judged by its group" }, "repo-check": { verdict: "pass", summary: "suite passed" } };
assert.ok(composedGate(c));
// Before the composed verdict: the repository check, and the gate pending, saying it waits.
const before = statusesFor(c, {}, undefined);
assert.deepEqual(before.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "pending"], [REPO_CHECK_CONTEXT, "success"]]);
assert.match(before[0].description, /waits for its group's composed check/);
assert.ok(!before.some((s) => s.state === "error"));
// After it: the standing composed gate is left alone.
const standing = groupGateStatus({ ...c, group: "feat/x", members: [] }, { owner: "novox", repo: "mesh-catalog", commit: c.commit }).description;
const after = statusesFor(c, {}, standing);
assert.deepEqual(after.map((s) => [s.context, s.state]), [[REPO_CHECK_CONTEXT, "success"]]);
// A verdict with an ordinary gate is as before.
assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false);
});
// Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main // Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main
// requires mesh/repo-check alone, so no mesh/merge-gate is written on its heads. // requires mesh/repo-check alone: its verdict sets mesh/repo-check and no mesh/merge-gate, and its comment names no gate.
test("a base's required statuses decide which of the controller's statuses are written", async () => { test("a base's required statuses decide which of the controller's statuses are written", async () => {
const { maintained, writesGate, CHECK_STATUS } = await import("../pulls.ts"); const { maintained, writesGate, statusesFor, commentFor, CHECK_STATUS } = await import("../pulls.ts");
assert.equal(CHECK_STATUS, "mesh/check"); assert.equal(CHECK_STATUS, "mesh/check");
assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before"); assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]); assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]);
@@ -75,6 +217,15 @@ test("a base's required statuses decide which of the controller's statuses are w
assert.equal(writesGate(["mesh/repo-check"]), false); assert.equal(writesGate(["mesh/repo-check"]), false);
assert.equal(writesGate(["mesh/check"]), false); assert.equal(writesGate(["mesh/check"]), false);
assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true); assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true);
const c = { owner: "novox", repo: "hq", number: 5, commit: "a".repeat(40), id: "x", verdict: "warning", summary: "no script",
trunk: "b".repeat(12), "repo-check": { verdict: "pass", summary: "ok" } } as any;
const hq = statusesFor(c, {}, undefined, ["mesh/repo-check"]);
assert.deepEqual(hq.map((s: any) => s.context), ["mesh/repo-check"]);
assert.match(hq[0].description, /merged into trunk bbbbbbbbbbbb/);
assert.deepEqual(statusesFor(c).map((s: any) => s.context), ["mesh/merge-gate", "mesh/repo-check"], "unread: as before");
assert.deepEqual(statusesFor(c, {}, undefined, ["mesh/check"]), []);
const comment = commentFor(c, {}, ["mesh/repo-check"]);
assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? "");
}); });
test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => { test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => {
+61 -9
View File
@@ -10,9 +10,10 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, readSiblings, siblingOutOfDate, siblingsFile, import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile,
siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js"; siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js";
import { weighTrunks } from "../dist/trunk.js"; import { weighTrunks } from "../dist/trunk.js";
import { commentFor, statusesFor } from "../dist/pulls.js";
const h = (c: string) => c.repeat(40); const h = (c: string) => c.repeat(40);
const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f"); const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f");
@@ -21,11 +22,52 @@ const verdict = { owner: "novox", repo: "mesh-controller", number: 346, commit:
trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc", trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc",
gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } }; gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } };
test("the siblings a verdict names are kept per member, and read back from where the holder keeps its state", () => { test("both statuses name the siblings the verdict read, keeping the verdict word and the start of its reason within 140 characters", () => {
// A realistic controller summary for a failure, long enough that the forge clips it.
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module on ace claims after this change";
const c = { ...verdict, gate: { verdict: "fail", summary: reason }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/link/check.go:145:2: undefined" } };
const [gate, repo] = statusesFor(c);
for (const s of [gate, repo]) {
assert.ok(s.description.startsWith(`head ${MEMBER_HEAD.slice(0, 12)} merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — fail: `), s.description);
assert.ok(s.description.length <= 140, `${s.description.length} characters`);
assert.ok(namesSiblings(s.description));
}
assert.match(gate.description, /fail: 2 of 4 machines do not compose: ace: mesh-host/, gate.description);
assert.match(repo.description, /fail: its merge-check\.sh failed: internal/, repo.description);
assert.equal(besideWords([...siblingsOf([sibling]), ...siblingsOf([{ ...sibling, number: 115 }])]), "beside mesh-host#114@bbbbbbbb +1 more");
const comment = commentFor({ ...verdict, gate: { verdict: "fail", summary: "no" } })!;
assert.match(comment, /Siblings read beside it: novox\/mesh-host#114 `bbbbbbbbbbbb` merged onto `cccccccccccc` \(main\); judged by sibling novox\/mesh-controller#346 merge/);
});
test("a verdict that could not learn its siblings says so on the status and in the comment, and names none", () => {
const { siblings, ...unknown } = { ...verdict, siblings_unknown: "mesh-delivery did not answer" };
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module claims";
for (const s of statusesFor({ ...unknown, gate: { verdict: "fail", summary: reason } })) {
assert.match(s.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 siblings unknown — (fail|pass): /, s.description);
assert.ok(s.description.length <= 140);
assert.equal(namesSiblings(s.description), false, "it read no siblings, so the merge tool asks for none");
}
assert.match(commentFor({ ...unknown, gate: { verdict: "fail", summary: "no" } })!, /Siblings could not be learned: mesh-delivery did not answer/);
});
test("a verdict without siblings is said exactly as before (an older controller)", () => {
const { siblings, judge_from, ...old } = verdict;
const [gate] = statusesFor(old);
assert.equal(namesSiblings(gate.description), false);
assert.match(gate.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 — pass: /);
assert.deepEqual(siblingsOf(undefined), []);
assert.deepEqual(siblingsOf([{ repository: "novox/x" }]), [], "a sibling named without its head is none");
});
test("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => {
const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-"))); const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-")));
const record: SiblingRecord = { "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } }; const record: SiblingRecord = {};
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), false);
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, siblingsOf([sibling])), true);
keepSiblings(file, record); keepSiblings(file, record);
assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state"); assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state");
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), true);
assert.deepEqual(record, {});
assert.deepEqual(readSiblings(null), {}); assert.deepEqual(readSiblings(null), {});
}); });
@@ -237,10 +279,6 @@ const composedMembers = () => [
{ owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, trunk: "111111111111", base: "main" }, { owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, trunk: "111111111111", base: "main" },
{ owner: "novox", repo: "mesh-host", number: 114, commit: HOST_NEW, trunk: HOST_BASE.slice(0, 12), base: "main" }, { owner: "novox", repo: "mesh-host", number: 114, commit: HOST_NEW, trunk: HOST_BASE.slice(0, 12), base: "main" },
]; ];
/** The record a group's composed verdict once left: for each member, the other members at the heads and base commits it composed. */
const composedRecord = (members: ReturnType<typeof composedMembers>): SiblingRecord => Object.fromEntries(members.map((m) => [
`${m.owner}/${m.repo}#${m.number}`.toLowerCase(), { head: m.commit, siblings: members.filter((o) => o !== m).map((o) => ({
repository: `${o.owner}/${o.repo}`, number: o.number, head: o.commit, base: o.base, base_commit: o.trunk })) }]));
const staleRecord = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } }); const staleRecord = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } });
test("without the composed verdict's siblings, a sibling's own merge of its new head is a move (the pin of issue 1049)", async () => { test("without the composed verdict's siblings, a sibling's own merge of its new head is a move (the pin of issue 1049)", async () => {
@@ -252,7 +290,9 @@ test("without the composed verdict's siblings, a sibling's own merge of its new
}); });
test("a composed verdict names the siblings of each member at the heads and base commits it composed, so a sibling's own merge of its new head is no move (issue 1049)", async () => { test("a composed verdict names the siblings of each member at the heads and base commits it composed, so a sibling's own merge of its new head is no move (issue 1049)", async () => {
const record = composedRecord(composedMembers()); const { noteComposed } = await import("../dist/siblings.js");
const record = staleRecord();
assert.equal(noteComposed(record, composedMembers()), true);
assert.deepEqual(record["novox/mesh-controller#346"], { head: MEMBER_HEAD, assert.deepEqual(record["novox/mesh-controller#346"], { head: MEMBER_HEAD,
siblings: [{ repository: "novox/mesh-host", number: 114, head: HOST_NEW, base: "main", base_commit: HOST_BASE.slice(0, 12) }] }); siblings: [{ repository: "novox/mesh-host", number: 114, head: HOST_NEW, base: "main", base_commit: HOST_BASE.slice(0, 12) }] });
assert.deepEqual(record["novox/mesh-host#114"], { head: HOST_NEW, assert.deepEqual(record["novox/mesh-host#114"], { head: HOST_NEW,
@@ -265,9 +305,21 @@ test("a composed verdict names the siblings of each member at the heads and base
assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true); assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true);
// A merge of any other head, or onto any other base, is still a move. // A merge of any other head, or onto any other base, is still a move.
const other = world(); const other = world();
const again = composedRecord(composedMembers()); const again: SiblingRecord = {};
noteComposed(again, composedMembers());
Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW }); Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
other.parents = [HOST_MOVED, HOST_NEW]; other.parents = [HOST_MOVED, HOST_NEW];
await weigh(other, again); await weigh(other, again);
assert.equal(other.written.length, 2); assert.equal(other.written.length, 2);
}); });
test("a composed verdict keeps nothing for a member it cannot name, and a group of one is none (issue 1049)", async () => {
const { noteComposed } = await import("../dist/siblings.js");
const record: SiblingRecord = {};
const [a, b] = composedMembers();
assert.equal(noteComposed(record, [a]), false);
assert.equal(noteComposed(record, [a, { ...b, number: undefined }]), false, "a member with no pull request number");
assert.equal(noteComposed(record, [a, { ...b, trunk: undefined }]), false, "a member with no trunk commit");
assert.equal(noteComposed(record, [a, { ...b, base: undefined }]), false, "a member with no base branch");
assert.deepEqual(record, {});
});
+11
View File
@@ -17,6 +17,7 @@ import { join } from "node:path";
import { MintedToken } from "../dist/token.js"; import { MintedToken } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js"; import { GiteaClient } from "../dist/client.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js"; import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
import { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin"; const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this"; const PASSWORD = "the-vault-minted-this";
@@ -363,6 +364,16 @@ test("the manifest hands the tools their account, and the Go forge bundle holds
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token. // Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => {
const client = fresh(await environment());
const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) };
const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any;
// main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see.
assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true });
// A base nothing guards: not required, so the head's repository check passes rather than fails.
assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false });
});
test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => { test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => {
const env = await environment(); const env = await environment();
const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] }; const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] };
+56 -1
View File
@@ -6,13 +6,39 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { checkOnItsWay, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js"; import { checkOnItsWay, lateVerdictComment, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js";
import { GateWriter } from "../dist/gate.js"; import { GateWriter } from "../dist/gate.js";
import { statusesFor, commentFor } from "../dist/pulls.js";
const HEAD = "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const HEAD = "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const TRUNK = "a2c2c35bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const TRUNK = "a2c2c35bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const MOVED = "d28ca95ccccccccccccccccccccccccccccccccc"; const MOVED = "d28ca95ccccccccccccccccccccccccccccccccc";
test("both statuses say the head and the trunk commit the verdict judged, first, whatever the summary's length", () => {
const c = { owner: "novox", repo: "mesh-host", number: 66, commit: HEAD, verdict: "pass", summary: "x", id: "build-1",
trunk: TRUNK.slice(0, 12), beside: { "mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210" },
gate: { verdict: "pass", summary: "4 of 4 compose ".repeat(20) },
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/store/store.go:145:2: Declared redeclared" } };
const [gate, repo] = statusesFor(c);
for (const s of [gate, repo]) {
assert.ok(s.description.startsWith("head dfb1415aaaaa merged into trunk a2c2c35bbbbb — "), s.description);
assert.ok(s.description.length <= 140, `${s.description.length} characters`);
assert.equal(trunkNamed(s.description), "a2c2c35bbbbb");
}
assert.equal(gate.state, "success");
assert.equal(repo.state, "failure");
const comment = commentFor(c)!;
assert.match(comment, /Judged: head `dfb1415aaaaa` merged into trunk `a2c2c35bbbbb`; read beside it: mesh-catalog `0123456789ab`, mesh-lab `ba9876543210`/);
// A verdict of a build seat from before the rule names no trunk, and is said as before.
const [old] = statusesFor({ ...c, trunk: undefined, gate: { verdict: "pass", summary: "every machine composes" } });
assert.equal(old.description, "pass: every machine composes");
assert.equal(trunkNamed(old.description), undefined);
// ADR 0548 §8: a carried verdict's line names the head on the new trunk first, so the merge tool merges it on that trunk.
assert.equal(trunkNamed(`pass ${HEAD.slice(0, 12)} on ${MOVED.slice(0, 12)} | carried from ${TRUNK.slice(0, 12)}, mesh-catalog 0123456789ab | unit 0 run, 4 kept`),
MOVED.slice(0, 12));
});
test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => { test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => {
const verdict = (trunk: string, state = "success") => ({ const verdict = (trunk: string, state = "success") => ({
"mesh/merge-gate": { state, description: `head ${HEAD.slice(0, 12)} merged into trunk ${trunk.slice(0, 12)} — pass: x` }, "mesh/merge-gate": { state, description: `head ${HEAD.slice(0, 12)} merged into trunk ${trunk.slice(0, 12)} — pass: x` },
@@ -60,6 +86,8 @@ function aForge(pulls: { number: number; base: string; head: string }[], heads:
writes.push(`${sha} ${s.context} ${s.state}`); writes.push(`${sha} ${s.context} ${s.state}`);
statuses.set(sha, { ...statuses.get(sha), [s.context]: { state: s.state, description: s.description } }); statuses.set(sha, { ...statuses.get(sha), [s.context]: { state: s.state, description: s.description } });
}, },
gateOf: async () => undefined,
gateHistory: async () => [],
headChecks: async (_o: string, _r: string, sha: string) => statuses.get(sha) ?? {}, headChecks: async (_o: string, _r: string, sha: string) => statuses.get(sha) ?? {},
}; };
return { forge, writes, statuses }; return { forge, writes, statuses };
@@ -109,6 +137,24 @@ test("a trunk that cannot be read is weighed again on the next pass, never recor
assert.equal(trunks["novox/mesh-host:main"], TRUNK, "not recorded: the next pass weighs it again"); assert.equal(trunks["novox/mesh-host:main"], TRUNK, "not recorded: the next pass weighs it again");
}); });
test("the trunk's move takes its turn after a verdict in flight, and weighs what the forge holds then", async () => {
const heads: Record<string, string> = { main: TRUNK };
const { forge, statuses } = aForge([{ number: 1, base: "main", head: HEAD }], heads);
const gates = new GateWriter(forge);
// A verdict judged against the trunk's new head, in flight when the move is weighed: it lands first, and is left.
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
const verdictLanded = gates.verdict("novox", "mesh-host", HEAD, async () => {
await held;
statuses.set(HEAD, { "mesh/merge-gate": { state: "success", description: `head x merged into trunk ${MOVED.slice(0, 12)} — pass` } });
});
const moved = gates.outOfDate("novox", "mesh-host", HEAD, (checks) => outOfDateStatuses(checks, MOVED));
release();
await verdictLanded;
assert.deepEqual(await moved, [], "a verdict judged against the trunk's head is not put out of date");
assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success");
});
test("a verdict judged against a trunk newer than the pass's first read is not put out of date", async () => { test("a verdict judged against a trunk newer than the pass's first read is not put out of date", async () => {
const NEWER = "ffffffffffffffffffffffffffffffffffffffff"; const NEWER = "ffffffffffffffffffffffffffffffffffffffff";
const heads: Record<string, string> = { main: TRUNK }; const heads: Record<string, string> = { main: TRUNK };
@@ -122,6 +168,15 @@ test("a verdict judged against a trunk newer than the pass's first read is not p
assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success", "a fresh verdict was put out of date"); assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success", "a fresh verdict was put out of date");
}); });
test("a verdict that landed out of date keeps its account on the pull request", () => {
const words = outOfDateWords(TRUNK, MOVED);
const own = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "fail" }, "**Merge check** at `dfb1415a`\n\nthe report", words);
assert.match(own, /the report/);
assert.match(own, /\*\*Out of date when it landed\*\*: out of date: judged against trunk a2c2c35bbbbb/);
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
});
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => { test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR // novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release // 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
+2 -14
View File
@@ -79,17 +79,6 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
return out; return out;
} }
/** The `files` argument of gitea_commit_trees: a list of directories, each plain ("" is the root; no empty, `.` or `..`
* segment, not absolute, no trailing slash); anything else is refused before the forge is asked. Absent is absent. */
function filesDirs(raw: unknown): string[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("files is a list of directories");
for (const d of raw as string[]) {
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`files: ${JSON.stringify(d)} is not a plain relative directory`);
}
return raw as string[];
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin, source)); return namesChecked(allGiteaTools(gitea, admin, source));
} }
@@ -733,20 +722,19 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
}, },
{ {
name: "gitea_commit_trees", name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With files, a list of directories, the answer also carries files, {directory: {path relative to it: git blob id}}, every blob under each directory, recursively, from the same listing (novox/hq ADR 0593 §2); a directory the commit lacks fails naming it. With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
input: { input: {
owner: { type: "string", description: "the repository owner" }, owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" }, repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit, its full 40-hex sha" }, sha: { type: "string", description: "the commit, its full 40-hex sha" },
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" }, paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
files: { type: "array", description: "optional, a list of directories (the root is the empty name): every blob under each, recursively, is answered under files as {directory: {path relative to it: git blob id}}" },
}, },
run: async (args) => { run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim(); const sha = String(args.sha ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha"); if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), filesDirs(args.files)); return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
}, },
}, },
{ {
+55 -1
View File
@@ -10,7 +10,7 @@
// Nothing is run for it: a recheck, a new head, or the merge tool asks the check again; // Nothing is run for it: a recheck, a new head, or the merge tool asks the check again;
// - **the merge tool** (tools/index.ts) compares the trunk commit the head's verdict names with the trunk's head at // - **the merge tool** (tools/index.ts) compares the trunk commit the head's verdict names with the trunk's head at
// the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a // the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a
// new head makes — and never merges by itself after. // new head makes — and says how long the last check of that repository took. It never merges by itself after.
// //
// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged // Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict // ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
@@ -19,6 +19,8 @@
// //
// Pure functions and two small files of the module's own state, so this is tested without a forge. // Pure functions and two small files of the module's own state, so this is tested without a forge.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js"; import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js"; import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
@@ -110,6 +112,14 @@ export function askedWords(at: Date, trunkHead: string): string {
`against trunk ${twelve(trunkHead)}`; `against trunk ${twelve(trunkHead)}`;
} }
/** The comment a verdict that landed out of date leaves (ADR 0323 §7: kept and shown): its own account when it has
* one, else what it judged and said, and that it is out of date. */
export function lateVerdictComment(c: { commit: string; trunk?: string; verdict: string }, account: string | null,
outOfDate: string): string {
const own = account ?? `**Merge check** at \`${c.commit.slice(0, 8)}\`: ${judgedWords(c.commit, c.trunk ?? "")}, ${c.verdict}`;
return `${own}\n\n**Out of date when it landed**: ${outOfDate}`;
}
// --- a trunk's move, weighed -------------------------------------------------------------------------------------- // --- a trunk's move, weighed --------------------------------------------------------------------------------------
/** What weighing a trunk's move reads of the forge. */ /** What weighing a trunk's move reads of the forge. */
@@ -171,6 +181,50 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru
return changed; return changed;
} }
// --- how long the last check of a repository took -------------------------------------------------------------
/** How long one repository's last check took, and when it was heard: kept by the watcher, read by the merge tool. */
export interface Took {
took: string;
at: string;
head?: string;
}
/** The file the watcher keeps each repository's last check's duration in, beside its other records. */
export function tookFile(stateDir: string | undefined): string | null {
return stateDir ? `${stateDir.replace(/\/+$/, "")}/checks-took.json` : null;
}
function readTookAll(file: string | null): Record<string, Took> {
if (!file || !existsSync(file)) return {};
try {
return JSON.parse(readFileSync(file, "utf8")) as Record<string, Took>;
} catch {
return {};
}
}
/** Keep how long a repository's check took. A record that cannot be written is said, never fatal to the verdict. */
export function keepTook(file: string | null, repository: string, took: Took): void {
if (!file || !took.took) return;
try {
const all = readTookAll(file);
all[repository.toLowerCase()] = took;
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file + ".tmp", JSON.stringify(all));
renameSync(file + ".tmp", file);
} catch (err) {
console.error(`[gitea] how long ${repository}'s check took could not be kept — ${err instanceof Error ? err.message : err}`);
}
}
/** How long a repository's last check took, in words for the merge tool's refusal. */
export function tookWords(file: string | null, repository: string): string {
const t = readTookAll(file)[repository.toLowerCase()];
if (!t) return `how long a check of ${repository} takes is not known yet: no verdict of it was heard since this was kept`;
return `the last check of ${repository} took ${t.took} (heard ${t.at})`;
}
// --- the announcement of a head, shared by the watcher and the merge tool --------------------------------------- // --- the announcement of a head, shared by the watcher and the merge tool ---------------------------------------
/** `pull.updated` for one open pull request's head: what the controller asks the build seat to check (novox/hq to-be /** `pull.updated` for one open pull request's head: what the controller asks the build seat to check (novox/hq to-be
+1 -1
View File
@@ -2,7 +2,7 @@ module gnomekeyring
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module hostname
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module i3
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=

Some files were not shown because too many files have changed in this diff Show More