Compare commits

..
446 changed files with 2163 additions and 37202 deletions
-1
View File
@@ -27,4 +27,3 @@ modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-cli
modules/brother/cmd/brother/brother
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
modules/mesh-config/mesh-config
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/imagebuild
go 1.22
-92
View File
@@ -1,92 +0,0 @@
// Command imagebuild refuses any manifest but modules/buildah providing the rootless-image-build provision
// (novox/hq ADR 0487 §3, issue 1704): the image builder's tool is buildah alone, and docker, whose daemon holds
// the machine's root, never stands in for it. A second provider would let a node compose mesh-build-image over
// something that is not the rootless builder it was designed against.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// Provision is the provision only Provider may provide.
const Provision = "rootless-image-build"
// Provider is the one module allowed to provide it.
const Provider = "buildah"
// Finding is one manifest refused: a module other than buildah providing the provision.
type Finding struct {
Module string
}
// Check reads every manifest under root/modules and answers which ones it refuses, and whether buildah provides.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
provided := false
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Provides []struct {
Name string `json:"name"`
} `json:"provides"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, p := range m.Provides {
if p.Name != Provision {
continue
}
if m.Module == Provider {
provided = true
} else {
out = append(out, Finding{Module: m.Module})
}
break
}
}
if !provided {
out = append(out, Finding{Module: Provider})
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
if f.Module == Provider {
fmt.Printf("%s does not provide %s (novox/hq ADR 0487 §3)\n", Provider, Provision)
continue
}
fmt.Printf("%s provides %s: only %s may, and docker never (novox/hq ADR 0487 §3)\n", f.Module, Provision, Provider)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("imagebuild: rootless-image-build is provided only by buildah")
}
-74
View File
@@ -1,74 +0,0 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func catalogue(t *testing.T, manifests map[string]string) string {
t.Helper()
root := t.TempDir()
for name, body := range manifests {
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
const buildah = `{"module":"buildah","provides":[{"name":"rootless-image-build","reach":"machine"}]}`
func TestOnlyBuildahProvidesTheRootlessImageBuild(t *testing.T) {
root := catalogue(t, map[string]string{"buildah": buildah, "go": `{"module":"go","provides":[{"name":"go-toolchain"}]}`})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("found %v, want none", got)
}
}
func TestDockerAndAnySecondProviderAreRefused(t *testing.T) {
root := catalogue(t, map[string]string{
"buildah": buildah,
"docker": `{"module":"docker","provides":[{"name":"rootless-image-build"}]}`,
"other": `{"module":"other","provides":[{"name":"x"},{"name":"rootless-image-build"}]}`,
})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := []Finding{{Module: "docker"}, {Module: "other"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("found %v, want %v", got, want)
}
}
func TestBuildahMustProvideIt(t *testing.T) {
root := catalogue(t, map[string]string{"buildah": `{"module":"buildah"}`})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if want := []Finding{{Module: "buildah"}}; !reflect.DeepEqual(got, want) {
t.Errorf("found %v, want %v", got, want)
}
}
// The catalogue as it stands: this is the check itself, run here too.
func TestTheCatalogueGivesTheProvisionToBuildahAlone(t *testing.T) {
got, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("the catalogue's providers of rootless-image-build are wrong: %v", got)
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/imageplace
go 1.22
-91
View File
@@ -1,91 +0,0 @@
// Command imageplace refuses an image artifact whose Dockerfile is not a file inside its own module's directory
// (novox/hq ADR 0487 §1, issue 1707): the image builder builds a module from that module's directory alone, so
// a `from` that leaves it (../other/Dockerfile) or names a file that is not there could never build.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Finding is one image refused: Module's artifact Artifact names From, and Why says what is wrong with it.
type Finding struct {
Module, Artifact, From, Why string
}
// Check reads every manifest under root/modules and answers which images it refuses.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Build struct {
Artifacts []struct {
Name string `json:"name"`
Kind string `json:"kind"`
From string `json:"from"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
dir := filepath.Dir(path)
for _, a := range m.Build.Artifacts {
if a.Kind != "image" {
continue
}
f := Finding{Module: m.Module, Artifact: a.Name, From: a.From}
clean := filepath.Clean(a.From)
switch {
case a.From == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)):
f.Why = "is not a path inside the module's own directory"
default:
info, err := os.Stat(filepath.Join(dir, clean))
if err != nil || info.IsDir() {
f.Why = "is not a file in the module's directory"
}
}
if f.Why != "" {
out = append(out, f)
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s: image %s builds from %q, which %s (novox/hq ADR 0487 §1)\n", f.Module, f.Artifact, f.From, f.Why)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("imageplace: every image builds from a Dockerfile in its own module's directory")
}
-84
View File
@@ -1,84 +0,0 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// module writes modules/<name>/module.json with one image artifact from `from`, and the files named.
func module(t *testing.T, root, name, from string, files ...string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + name + `","build":{"artifacts":[{"name":"server","kind":"image","from":"` + from + `"},{"name":"files","kind":"archive","from":"../elsewhere"}]}}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
for _, f := range files {
p := filepath.Join(dir, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("FROM scratch\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
func TestADockerfileInTheModulesOwnDirectoryPasses(t *testing.T) {
root := t.TempDir()
module(t, root, "own", "Dockerfile", "Dockerfile")
module(t, root, "nested", "image/Dockerfile", "image/Dockerfile")
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("found %v, want none", got)
}
}
func TestADockerfileOutsideTheModulesDirectoryFails(t *testing.T) {
root := t.TempDir()
module(t, root, "other", "Dockerfile", "Dockerfile")
module(t, root, "leaves", "../other/Dockerfile")
module(t, root, "absolute", "/etc/Dockerfile")
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
var mods []string
for _, f := range got {
mods = append(mods, f.Module)
}
if want := []string{"absolute", "leaves"}; !reflect.DeepEqual(mods, want) {
t.Errorf("refused %v (%v), want %v", mods, got, want)
}
}
func TestAMissingDockerfileFails(t *testing.T) {
root := t.TempDir()
module(t, root, "gone", "Dockerfile")
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Module != "gone" {
t.Errorf("found %v, want gone refused", got)
}
}
// The catalogue as it stands: this is the check itself, run here too.
func TestTheCataloguesImagesBuildFromTheirOwnDirectories(t *testing.T) {
got, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("an image builds from outside its module: %v", got)
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/locks
go 1.22
-105
View File
@@ -1,105 +0,0 @@
// Command locks refuses a TypeScript module that names packages beyond the SDK in its package.json and commits no
// package-lock.json (novox/hq ADR 0469 §5, issue 1868): the Node build module installs a module's own packages only
// from its committed lock, so such a module is refused by the builder and could not be delivered.
//
// go run . <catalogue root>
//
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
type Finding struct {
Module string
Packages []string
}
// Check reads every module under root/modules that builds a TypeScript artifact.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Build struct {
Artifacts []struct {
Language string `json:"language"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
typescript := false
for _, a := range m.Build.Artifacts {
typescript = typescript || a.Language == "typescript"
}
if !typescript {
continue
}
dir := filepath.Dir(path)
pkgRaw, err := os.ReadFile(filepath.Join(dir, "package.json"))
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, err
}
var pkg struct {
Dependencies map[string]json.RawMessage `json:"dependencies"`
}
if err := json.Unmarshal(pkgRaw, &pkg); err != nil {
return nil, fmt.Errorf("%s: package.json: %w", m.Module, err)
}
var names []string
for n := range pkg.Dependencies {
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
names = append(names, n)
}
}
if len(names) == 0 {
continue
}
if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil {
sort.Strings(names)
out = append(out, Finding{Module: m.Module, Packages: names})
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s names packages beyond the SDK (%s) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)\n", f.Module, strings.Join(f.Packages, ", "))
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("locks: every TypeScript module that names packages beyond the SDK commits its lock")
}
-63
View File
@@ -1,63 +0,0 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// module writes modules/<name> with a TypeScript artifact, the package.json given ("" for none) and, when lock, a lock.
func module(t *testing.T, root, name, language, pkg string, lock bool) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
write := func(file, body string) {
if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("module.json", `{"module":"`+name+`","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"`+language+`","from":"index.ts"}]}}`)
if pkg != "" {
write("package.json", pkg)
}
if lock {
write("package-lock.json", "{}")
}
}
const sdkOnly = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1"},"devDependencies":{"typescript":"^5","@types/node":"^22"}}`
const withPg = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","pg":"^8","@types/pg":"^8"}}`
func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
root := t.TempDir()
module(t, root, "plain", "typescript", sdkOnly, false)
module(t, root, "none", "typescript", "", false)
got, err := Check(root)
if err != nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
}
func TestAModuleNamingMorePackagesWithoutALockIsRefusedNamingThem(t *testing.T) {
root := t.TempDir()
module(t, root, "db", "typescript", withPg, false)
module(t, root, "db-locked", "typescript", withPg, true)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if want := []Finding{{Module: "db", Packages: []string{"pg"}}}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) {
root := t.TempDir()
module(t, root, "go", "go", withPg, false)
if got, err := Check(root); err != nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
}
-3
View File
@@ -1,3 +0,0 @@
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
pacman
kernel
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/particulars
go 1.22
-159
View File
@@ -1,159 +0,0 @@
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
// fixture of a covered module names a node of this one.
//
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
// before a line is read.
//
// go run . <catalogue root>
//
// Exits 1 naming file:line and the word, 2 when a list cannot be read.
package main
import (
"bufio"
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// forge is the forge's host and organisation path, blanked before a line is read; a path segment novox/ elsewhere
// (testdata/novox/x, /srv/novox/data) is not blanked.
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
os.Exit(check(root))
}
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
func check(root string) int {
names, err := readList(filepath.Join(root, "particulars"))
if err == nil && len(names) == 0 {
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
}
var covered []string
if err == nil {
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
if err == nil && len(covered) == 0 {
err = fmt.Errorf("covered lists no module — the check would pass on anything")
}
}
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
findings, err := run(root, names, covered)
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
for _, f := range findings {
fmt.Println(f)
}
if len(findings) > 0 {
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
return 1
}
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
return 0
}
// readList reads a list: one entry a line; blank lines and lines starting with # are not entries.
func readList(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out, sc.Err()
}
// run reads every text file of each covered module and returns the findings, sorted.
func run(root string, names, covered []string) ([]string, error) {
var out []string
for _, m := range covered {
dir := filepath.Join(root, "modules", m)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "node_modules", "dist", "vendor", ".git":
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(root, path)
// A path is read too: a testdata directory named after a node is a finding.
for _, n := range hits(filepath.ToSlash(rel), names) {
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
}
if d.Name() == "go.sum" {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
if bytes.IndexByte(b, 0) >= 0 {
return nil
}
out = append(out, scan(filepath.ToSlash(rel), string(b), names)...)
return nil
})
if err != nil {
return nil, err
}
}
sort.Strings(out)
return out, nil
}
// scan returns a finding for each node name in src, whole-word and in any case.
func scan(file, src string, names []string) []string {
var out []string
for i, line := range strings.Split(src, "\n") {
for _, n := range hits(line, names) {
out = append(out, fmt.Sprintf("%s:%d: node name %q — %s", file, i+1, n, strings.Join(strings.Fields(line), " ")))
}
}
return out
}
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
func hits(line string, names []string) []string {
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
var out []string
for _, n := range names {
q := regexp.QuoteMeta(n)
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
if rx.MatchString(line) {
out = append(out, strings.ToLower(n))
}
}
return out
}
-116
View File
@@ -1,116 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
var names = []string{"novox", "ace", "shanks", "g14"}
func TestANodeNameIsRefusedWhereItIsWritten(t *testing.T) {
cases := map[string]struct{ file, src string }{
"manifest string": {"modules/kernel/module.json", "{\n \"description\": \"as on Shanks\"\n}\n"},
"file line": {"modules/kernel/files/boot.sh", "#!/bin/sh\necho run on g14\n"},
"go identifier": {"modules/kernel/cmd/x/a.go", "package main\n\nfunc novox() {}\n"},
"test name": {"modules/kernel/cmd/x/a_test.go", "package main\n\nfunc TestAce(t *testing.T) {}\n\nfunc TestOnAce_x(t *testing.T) {}\n"},
}
for name, c := range cases {
if got := scan(c.file, c.src, names); len(got) == 0 {
t.Errorf("%s: %q not refused", name, c.src)
}
}
}
func TestTheFindingNamesTheLineAndTheWord(t *testing.T) {
got := scan("f.sh", "a\nb\nrun on ACE now\n", names)
if len(got) != 1 || !strings.HasPrefix(got[0], "f.sh:3:") || !strings.Contains(got[0], "ace") {
t.Errorf("got %v", got)
}
}
func TestTheForgeIsNotANodeName(t *testing.T) {
for _, s := range []string{
"see novox/hq issue 5",
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
"https://git.novox.be/novox/mesh-catalog",
"git.novox.be",
"space trace surface grace g14x",
} {
if got := scan("f", s, names); len(got) != 0 {
t.Errorf("%q refused: %v", s, got)
}
}
}
func TestANodeNameInAPathOrJoinedIsRefused(t *testing.T) {
for _, s := range []string{
"modules/kernel/testdata/novox/meminfo",
"/srv/novox/data",
"testdata/shanks-meminfo.txt",
"on-shanks",
"shanks-desktop",
"mesh-novox",
"func novoxTree() {}",
"aceFiles := 1",
"g14Meminfo := 2",
"novox_tree := 3",
} {
if got := scan("f", s, names); len(got) == 0 {
t.Errorf("%q not refused", s)
}
}
}
func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, "modules/kernel/testdata/ace/x")
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("clean\n"), 0o644)
got, err := run(root, names, []string{"kernel"})
if err != nil || len(got) != 1 || !strings.HasPrefix(got[0], "modules/kernel/testdata/ace/x:0: ") {
t.Errorf("got %v, %v", got, err)
}
}
func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "ace\n")
write("checks/particulars/covered", "kernel\n")
write("modules/kernel/a", "clean\n")
if got := check(root); got != 0 {
t.Errorf("clean = %d, want 0", got)
}
write("modules/kernel/a", "on ace\n")
if got := check(root); got != 1 {
t.Errorf("finding = %d, want 1", got)
}
os.Remove(filepath.Join(root, "particulars"))
if got := check(root); got != 2 {
t.Errorf("no list = %d, want 2", got)
}
}
func TestOnlyCoveredModulesAreRead(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("modules/kernel/files/a", "on novox\n")
write("modules/other/files/a", "on novox\n")
got, err := run(root, names, []string{"kernel"})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || !strings.Contains(got[0], "modules/kernel/files/a:1") {
t.Errorf("got %v", got)
}
}
+5 -6
View File
@@ -1,5 +1,5 @@
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, nodejs and npm only in modules/nodejs, and buildah (issue 1702) only in modules/buildah.
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, and nodejs and npm only in modules/nodejs.
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
@@ -19,10 +19,9 @@ import (
// Owners names, for each toolchain package, the one module that declares it.
var Owners = map[string]string{
"buildah": "buildah",
"go": "go",
"nodejs": "nodejs",
"npm": "nodejs",
"go": "go",
"nodejs": "nodejs",
"npm": "nodejs",
}
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
@@ -101,5 +100,5 @@ func main() {
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("toolchains: go, nodejs, npm and buildah are declared only by their owners")
fmt.Println("toolchains: go, nodejs and npm are declared only by their owners")
}
+2 -20
View File
@@ -26,7 +26,6 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
})
@@ -40,23 +39,6 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
}
}
func TestASecondDeclarerOfBuildahIsRefused(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
"imagery": `{"module":"imagery","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := []Finding{{Module: "imagery", Package: "buildah", Owner: "buildah"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("found %v, want %v", got, want)
}
}
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[]}`,
@@ -65,8 +47,8 @@ func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(got) != 4 {
t.Errorf("an owner missing its packages gave %v, want four findings (buildah, go, nodejs, npm)", got)
if len(got) != 3 {
t.Errorf("an owner missing its packages gave %v, want three findings (go, nodejs, npm)", got)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
58
59
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
-24
View File
@@ -50,22 +50,11 @@
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
# only under a home; and no module declares a path inside a directory of another module.
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build.
# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in
# `particulars`, the covered modules in checks/particulars/covered.
(cd checks/particulars && go test -count=1 . && go run . ../..)
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
(cd checks/toolchains && go test -count=1 . && go run . ../..)
# The image builder (novox/hq ADR 0487 §3): only modules/buildah provides rootless-image-build, docker never.
(cd checks/imagebuild && go test -count=1 . && go run . ../..)
# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory.
(cd checks/imageplace && go test -count=1 . && go run . ../..)
# The Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock.
(cd checks/locks && go test -count=1 . && go run . ../..)
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
(cd checks/agenthome && go test -count=1 . && go run . ../..)
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
@@ -201,19 +190,6 @@ for m in $touched; do
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
# The docker tests of the bus's snapshot program and entrypoint (novox/hq ADR 0480 clause 6) start throwaway nats
# containers, and the Go toolchain container holds no docker, so above they skip with a "NOT RUN" line. Where the
# build seat says docker exists (MESH_CHECK_DOCKER=1) they run here by name, and a missing docker fails the check
# (MESH_REQUIRE_DOCKER=1 makes the tests fail instead of skip). Without it the step says it did not run.
if [ "$m" = nats ]; then
if [ "${MESH_CHECK_DOCKER:-}" = 1 ]; then
(cd modules/nats && MESH_REQUIRE_DOCKER=1 CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} -count=1 \
-run 'TestTheDeclaredDumpRunsInTheBussImage|TestTheEntrypointReloadsTheServerWithoutDroppingAConnection|TestTheStoreSurvivesTheNewDeclarationTheOldAndTheNewAgain|TestASnapshotOfALiveBusRestoresToTheSameContent|TestTheSnapshotUserCannotChangeTheBus' \
./cmd/mesh-nats-snapshot) || { echo "the bus's docker tests failed"; exit 1; }
else
echo "NOT RUN: the bus's five docker tests (modules/nats/cmd/mesh-nats-snapshot) skipped: this check has no docker (MESH_CHECK_DOCKER is not 1)"
fi
fi
done
if [ -n "$untestable" ]; then
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
-13
View File
@@ -1,13 +0,0 @@
# buildah
The rootless image builder as a module (novox/hq ADR 0487, issue 1702).
- **The only module that declares the package `buildah`** (`checks/toolchains`), and the only one that provides
`rootless-image-build` (`checks/imagebuild`; docker never does).
- **Provides `rootless-image-build` with the machine's reach**: mesh-build-image requires it, so it composes only on a
machine where this module is assigned, and a node without it is refused at resolution naming `buildah`.
- **No account and no subordinate-id word.** The node-engine makes every `user` account with `useradd`, which
allocates the account 65536 subordinate uids and gids; mesh-build-image's own account gets its ranges that way and
its health proves them (it reads /etc/subuid and /etc/subgid).
- The helpers (an OCI runtime, passt, containers-common) come as buildah's package dependencies.
- Holds no long-running resource, so it adds nothing to `health-undeclared`.
-21
View File
@@ -1,21 +0,0 @@
{
"module": "buildah",
"version": "1",
"capabilities": [
"package-manager",
"user-namespaces"
],
"provides": [
{
"name": "rootless-image-build",
"reach": "machine"
}
],
"resources": [
{
"id": "buildah",
"type": "package",
"package": "buildah"
}
]
}
+3 -28
View File
@@ -476,13 +476,10 @@ type PluginFile struct {
// what it is given. Outside hooks/, so no registered hook's name can be it.
const GuardDir = "guard"
// GuardFiles are the guard as the plugin carries it: the binary to copy, what it judges with, and — the same
// binary again — the waits directory the SessionStart and Stop hooks are given (novox/hq ADR 0489, issues
// 1744, 1743). Empty WaitsDir places no such hook.
// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with.
type GuardFiles struct {
Exe string
Data GuardData
WaitsDir string
Exe string
Data GuardData
}
// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file
@@ -498,20 +495,6 @@ func GuardCommand() string {
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
}
// SessionStartCommand is the plugin's SessionStart hook: the guard's own binary, told the session's open
// waits live under waitsDir (novox/hq ADR 0489 §3, issue 1744).
func SessionStartCommand(waitsDir string) string {
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
return `"` + filepath.Join(dir, "guard") + `" session-start "` + waitsDir + `"`
}
// StopCommand is the plugin's Stop hook: the guard's own binary, refusing to end the turn while a wait under
// waitsDir is open and unwatched, or past its bound and untold (novox/hq ADR 0489 §3-§6, issue 1743).
func StopCommand(waitsDir string) string {
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
return `"` + filepath.Join(dir, "guard") + `" stop "` + waitsDir + `"`
}
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
// over a mesh item of the same kind and name: the node's wins.
func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) }
@@ -550,14 +533,6 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
if guard.WaitsDir != "" {
// A session's wait list (novox/hq ADR 0489): the SessionStart hook names the session's id and its open
// waits (issue 1744); the Stop hook keeps the turn open while one is unwatched or untold (issue 1743).
hooks["SessionStart"] = append(hooks["SessionStart"], map[string]any{"matcher": "startup|resume|clear|compact",
"hooks": []any{map[string]any{"type": "command", "command": SessionStartCommand(guard.WaitsDir), "timeout": 10}}})
hooks["Stop"] = append(hooks["Stop"], map[string]any{
"hooks": []any{map[string]any{"type": "command", "command": StopCommand(guard.WaitsDir), "timeout": 5}}})
}
}
for _, k := range keys {
it := chosen[k]
@@ -1,139 +0,0 @@
package main
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
// adopt call.
import (
"encoding/json"
"strings"
"testing"
"time"
)
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
func suggestedCall(t *testing.T, msg string) map[string]any {
t.Helper()
i := strings.Index(msg, "claude_code_wait ")
if i < 0 {
t.Fatalf("no suggested call in: %s", msg)
}
rest := msg[i+len("claude_code_wait "):]
start := strings.Index(rest, "{")
if start < 0 {
t.Fatalf("no JSON object in: %s", msg)
}
depth, end := 0, -1
for j, c := range rest[start:] {
switch c {
case '{':
depth++
case '}':
depth--
if depth == 0 {
end = start + j + 1
}
}
if end >= 0 {
break
}
}
if end < 0 {
t.Fatalf("unbalanced JSON object in: %s", msg)
}
var args map[string]any
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
}
return args
}
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
if err != nil || len(f.Waits) != 1 {
t.Fatalf("%+v %v", f, err)
}
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
t.Fatalf("the suggested call does not carry by: %s", reason)
}
args := suggestedCall(t, reason)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
}
}
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
clock := t0
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
if isErr {
t.Fatalf("add: %s", text)
}
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
if err != nil || len(f.Waits) != 1 {
t.Fatalf("%+v %v", f, err)
}
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
t.Fatalf("the suggested call does not carry by: %s", reason)
}
args := suggestedCall(t, reason)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
}
}
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
// regression here is caught the same way as the Stop hook's.
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
}
args := suggestedCall(t, msg)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
}
}
@@ -1,23 +0,0 @@
package main
// Shared by the plugin's hooks (novox/hq ADR 0489 §5): a notice on exit 0 — the stop hook failing open or
// letting a wait through, the session-start hook unable to use a session id — reaches the operator's screen,
// not only the log. Claude Code shows a hook's top-level "systemMessage" field to the person; stderr alone,
// on an allowed (exit 0) outcome, is never shown there.
import (
"encoding/json"
"fmt"
"io"
)
// writeSystemMessage prints message as the one JSON object Claude Code shows the operator, falling back to
// the plain words on the one error that cannot happen (a string always marshals).
func writeSystemMessage(w io.Writer, message string) {
raw, err := json.Marshal(map[string]string{"systemMessage": message})
if err != nil {
fmt.Fprintln(w, message)
return
}
fmt.Fprintln(w, string(raw))
}
@@ -388,7 +388,6 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
out = append(out, proposalTools(p)...)
out = append(out, waitTools(p)...)
judge := NewJudge(p)
out = append(out, judgeTool(judge), judgedTool(judge))
return append(out, configTools(p, config, configView)...)
@@ -417,15 +416,6 @@ func main() {
if len(os.Args) == 3 && os.Args[1] == "guard" {
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
}
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): names the session's id and its open waits.
if len(os.Args) == 3 && os.Args[1] == "session-start" {
os.Exit(runSessionStart(os.Args[2], os.Stdin, os.Stdout))
}
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): keeps the turn open while a wait is unwatched
// or untold.
if len(os.Args) == 3 && os.Args[1] == "stop" {
os.Exit(runStop(os.Args[2], os.Stdin, os.Stdout, os.Stderr))
}
// The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this
// module through sudo: one answer per request on stdin, until it ends.
if len(os.Args) == 2 && os.Args[1] == HelperArg {
@@ -473,7 +463,6 @@ func main() {
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
Now: time.Now, Write: writeManaged}
go proposing.watch(p)
waiting = &Waiting{Store: keptWaits{stdio.State(WaitsState)}, Now: time.Now}
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
+1 -1
View File
@@ -278,7 +278,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
}
// MeshToolsEvery is how often the controller is asked again.
@@ -1,86 +0,0 @@
package main
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, issue 1744): `claude-code session-start
// <waits-dir>`, run by the agent's own account on every start, resume, /clear and /compact. The tool runner
// never tells a session its own id (only a hook's stdin carries session_id), so a session cannot name itself
// to claude_code_wait until this hook has told it. It also lists this session id's own still-open waits (if
// any name is already bound to it) and, always, every name on this node that still holds an open wait, so a
// session with a brand new id (a /clear gives one) can see a name it may want to adopt.
//
// It reads the session's mirrored waits file and the node-wide index directly — the same local reads the
// Stop hook and claude_code_wait make (readWaitsFile, readNodeIndex, waits.go) — never calling back into
// this module: this hook runs on every start, and must not wait on the bus to do it. A missing or malformed
// file is not an error here: there is simply nothing open to report.
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
)
// SessionStartInput is what the hook is given on stdin.
type SessionStartInput struct {
SessionID string `json:"session_id"`
}
// SessionStartMessage is the line the hook prints: the session's id and how to give it to claude_code_wait,
// then each still-open wait already bound to this session id (1744/AC1, AC2), then every name on this node
// that still holds an open wait, with how to adopt it (ADR 0490 §2). Pure of IO beyond the two file reads.
func SessionStartMessage(sessionID, waitsDir string, now time.Time) string {
var b strings.Builder
fmt.Fprintf(&b, "This session's id is %s; give it as `session` to claude_code_wait.\n", sessionID)
if f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)); err == nil && len(f.Waits) > 0 {
b.WriteString("Waits already bound to this session's own id:\n")
for _, w := range f.Waits {
age := now.Sub(w.At).Round(time.Second)
state := "no watcher yet"
if w.Watcher != "" {
state = "watched"
}
if w.PastBound(now) {
state += ", past its bound"
if w.Said {
state += ", said"
}
}
fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state)
}
}
if idx, err := readNodeIndex(nodeIndexPathIn(waitsDir)); err == nil && len(idx.Names) > 0 {
b.WriteString("Open waits on this node, by name:\n")
for _, n := range idx.Names {
fmt.Fprintf(&b, " %s: %d open wait(s), last called %s. If this is you: claude_code_wait "+
"{\"op\":\"adopt\",\"by\":%q,\"session\":%q}\n", n.Name, n.Open, n.LastCall.Round(time.Second).Format(time.RFC3339),
n.Name+"@"+idx.Node, sessionID)
}
}
return b.String()
}
// runSessionStart is the hook: the session's id and its open waits out, exit 0 always (1744/AC3).
func runSessionStart(waitsDir string, stdin io.Reader, stdout io.Writer) (code int) {
defer func() {
if recover() != nil {
code = 0 // never fails the session's start
}
}()
var in SessionStartInput
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
return 0
}
sessionID := strings.TrimSpace(in.SessionID)
if sessionID == "" {
return 0
}
// Review round 1, item 1: a session id is never used to build a path until it is checked against
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
if !waitSessionID.MatchString(sessionID) {
writeSystemMessage(stdout, fmt.Sprintf("this session's id (%q) could not be used to find its waits "+
"(letters, digits, '.', '_' and '-' only)", sessionID))
return 0
}
fmt.Fprint(stdout, SessionStartMessage(sessionID, waitsDir, time.Now()))
return 0
}
@@ -1,151 +0,0 @@
package main
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744).
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 1744/AC1: the hook prints the session's id and how to give it to claude_code_wait.
func TestSessionStartPrintsTheSessionsIDAndHowToGiveIt(t *testing.T) {
waitsDir := t.TempDir()
stdin := strings.NewReader(`{"session_id":"sess-9","hook_event_name":"SessionStart","source":"startup"}`)
var out bytes.Buffer
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
t.Fatalf("code %d, stdout %s", code, out.String())
}
if !strings.Contains(out.String(), "sess-9") || !strings.Contains(out.String(), "claude_code_wait") {
t.Fatalf("stdout does not say how to name the session: %s", out.String())
}
}
// 1744/AC2: when the waits file holds open waits, it prints each (what, age, past bound or not).
func TestSessionStartListsOpenWaitsAfterAClear(t *testing.T) {
waitsDir := t.TempDir()
at := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
f := WaitsFile{Session: "sess-10", At: at, Waits: []Wait{
{ID: "w-open", What: "the operator's Approve", Bound: 10, At: at},
{ID: "w-past", What: "a delivery to finish", Bound: 1, At: at},
}}
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-10.json"), f)
now := at.Add(5 * time.Minute)
msg := SessionStartMessage("sess-10", waitsDir, now)
if !strings.Contains(msg, "w-open") || !strings.Contains(msg, "the operator's Approve") || !strings.Contains(msg, "no watcher") {
t.Fatalf("the open, unwatched wait is not described:\n%s", msg)
}
if !strings.Contains(msg, "w-past") || !strings.Contains(msg, "past its bound") {
t.Fatalf("the past-bound wait is not described:\n%s", msg)
}
if !strings.Contains(msg, "5m0s ago") {
t.Fatalf("the age is not said:\n%s", msg)
}
}
// 1744/AC3: a missing or malformed file prints only the id line, and the hook never fails the session start.
func TestSessionStartNeverFailsOnAMissingOrBadFile(t *testing.T) {
waitsDir := t.TempDir()
if msg := SessionStartMessage("sess-11", waitsDir, time.Now()); !strings.Contains(msg, "sess-11") || strings.Contains(msg, "open") {
t.Fatalf("a missing file printed more than the id line: %q", msg)
}
writeFile(t, filepath.Join(waitsDir, "sess-12.json"), "{not json")
if msg := SessionStartMessage("sess-12", waitsDir, time.Now()); !strings.Contains(msg, "sess-12") || strings.Contains(msg, "open") {
t.Fatalf("a malformed file printed more than the id line: %q", msg)
}
var out bytes.Buffer
if code := runSessionStart(waitsDir, strings.NewReader("not json at all"), &out); code != 0 {
t.Fatalf("unreadable stdin failed the session start: code %d", code)
}
var out2 bytes.Buffer
if code := runSessionStart(waitsDir, panicReader{}, &out2); code != 0 {
t.Fatalf("a panic was not recovered: code %d", code)
}
}
// panicReader panics on Read, as a stuck pipe or a bug reading stdin might.
type panicReader struct{}
func (panicReader) Read([]byte) (int, error) { panic("boom") }
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one is never used to
// build a file path — reading outside the waits dir never happens — and the hook prints only a line saying
// the id could not be used.
func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
root := t.TempDir()
waitsDir := filepath.Join(root, "agent", "waits")
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
t.Fatal(err)
}
// What "../../x" resolves to from waitsDir: root/x.json, outside waitsDir, with content that must never be
// read through it.
outside := filepath.Join(waitsDir, "../../x.json")
writeWaitsFixture(t, outside, WaitsFile{Session: "x", At: time.Now(),
Waits: []Wait{{ID: "w-secret", What: "SECRET-DATA", Bound: 10, At: time.Now()}}})
t.Cleanup(func() { _ = os.Remove(outside) })
for _, id := range []string{"../../x", "a/b"} {
var out bytes.Buffer
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q}`, id))
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
t.Fatalf("id %q: code %d", id, code)
}
if strings.Contains(out.String(), "SECRET-DATA") {
t.Fatalf("id %q: read a file outside the waits dir: %s", id, out.String())
}
if !strings.Contains(out.String(), "could not be used") {
t.Fatalf("id %q: did not say the id could not be used: %s", id, out.String())
}
// Item (a): the notice reaches the screen as the one JSON object Claude Code shows.
if !strings.Contains(out.String(), "systemMessage") {
t.Fatalf("id %q: the notice is not a systemMessage: %s", id, out.String())
}
}
var out bytes.Buffer
if code := runSessionStart(waitsDir, strings.NewReader(`{"session_id":""}`), &out); code != 0 || out.Len() != 0 {
t.Fatalf("blank id: code %d, out %q", code, out.String())
}
}
// 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook.
// ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide
// index, with the exact adopt call to make if it is yours — read directly, never by calling back into this
// module.
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
waitsDir := t.TempDir()
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
}}
raw, err := indented(idx)
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw))
msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute))
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
t.Fatalf("the name and its count are not listed:\n%s", msg)
}
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
if !strings.Contains(msg, want) {
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
}
}
func writeWaitsFixture(t *testing.T, path string, f WaitsFile) {
t.Helper()
raw, err := indented(f)
if err != nil {
t.Fatal(err)
}
writeFile(t, path, string(raw))
}
-195
View File
@@ -1,195 +0,0 @@
package main
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop <waits-dir>`, run by the
// agent's own account before a session's turn is let end. It reads the session's mirrored waits file
// directly (readWaitsFile, waits.go) — never the bus, never calling back into this module — so the decision
// is local, under 5 s, and needs no network.
//
// Within its bound, a wait needs a watcher: refused while one is open and nothing follows it yet. Past its
// bound, a wait needs a word to the operator, watched or not: refused until it is marked said. Refused at
// most three times running for the same wait (a counter file beside the waits file, reset once the blocking
// wait changes or clears); the fourth time it is let through, saying the wait was left unwatched. A missing,
// unreadable or malformed waits file, or a panic, always allows.
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
// StopInput is what the hook is given on stdin.
type StopInput struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
StopHookActive bool `json:"stop_hook_active"`
}
// MostStopRefusals is how many times in a row the Stop hook refuses for the same wait before it lets the
// turn end anyway (1743/AC5).
const MostStopRefusals = 3
// StopDecision is the Stop hook's verdict, and the refusal counters it leaves behind for the next call.
type StopDecision struct {
Refuse bool
Reason string
Counters map[string]int
}
// waitBlocksStop says whether w, on its own, keeps the turn from ending, and why (ADR 0489 §3-§4).
func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
if w.Said {
return false, ""
}
if !w.PastBound(now) {
if w.Watcher != "" {
return false, ""
}
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
"%s, and mark it watched: claude_code_wait "+
"{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address,
watcherAdvice(w), session, w.By, w.ID)
}
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
}
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
func watcherAdvice(w Wait) string {
if w.Family != "" {
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
"that rereads %s", w.Family, w.Read.Address)
}
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
}
// DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits
// file gave, previous the refusal counters read beside it, and now the time. Only the first wait that still
// blocks the stop is named; the others are read again next time.
//
// A missing file (review round 2, item 1) is the normal state for a session with no waits — every stop of
// every such session, not an exceptional one — so it says nothing and simply allows, the same as a file that
// reads back with no open waits. Only a file that exists but cannot be read, or does not parse, says so.
func DecideStop(session string, f WaitsFile, readErr error, previous map[string]int, now time.Time) StopDecision {
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
return StopDecision{Reason: fmt.Sprintf("the wait list could not be read (%v); allowed", readErr), Counters: map[string]int{}}
}
for _, w := range f.Waits {
blocks, reason := waitBlocksStop(session, w, now)
if !blocks {
continue
}
count := previous[w.ID] + 1
if count > MostStopRefusals {
return StopDecision{Reason: fmt.Sprintf("wait %s was refused %d times running and is let through: it was left "+
"unwatched (%s)", w.ID, MostStopRefusals, reason), Counters: map[string]int{}}
}
return StopDecision{Refuse: true, Reason: reason, Counters: map[string]int{w.ID: count}}
}
return StopDecision{Counters: map[string]int{}}
}
func refusalCountersPath(waitsDir, session string) string {
return filepath.Join(waitsDir, session+".refusals.json")
}
func readRefusalCounters(waitsDir, session string) map[string]int {
raw, err := os.ReadFile(refusalCountersPath(waitsDir, session))
if err != nil {
return map[string]int{}
}
var m map[string]int
if json.Unmarshal(raw, &m) != nil {
return map[string]int{}
}
return m
}
// writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every
// other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which
// already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as
// before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined
// node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an
// error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a
// cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1
// item 2).
//
// Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record —
// every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first
// time, the waits folder itself; removing a file that was never there is not an error.
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
path := refusalCountersPath(waitsDir, session)
if len(m) == 0 {
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
raw, err := json.Marshal(m)
if err != nil {
return err
}
if err := fsys.MkdirAll(waitsDir, 0o700); err != nil {
return err
}
return fsys.WriteFile(path, raw, 0o600)
}
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
// hook's "systemMessage" on stdout, but not stderr, when the hook allows (review round 1, item a).
func notice(stdout, stderr io.Writer, reason string) {
fmt.Fprintln(stderr, reason)
writeSystemMessage(stdout, reason)
}
// runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743).
func runStop(waitsDir string, stdin io.Reader, stdout, stderr io.Writer) (code int) {
defer func() {
if recover() != nil {
code = 0 // a panic always allows (1743/AC6)
}
}()
var in StopInput
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
return 0
}
sessionID := strings.TrimSpace(in.SessionID)
if sessionID == "" {
return 0
}
// Review round 1, item 1: a session id is never used to build a path until it is checked against
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
if !waitSessionID.MatchString(sessionID) {
notice(stdout, stderr, fmt.Sprintf("the session id %q could not be used to find its waits (letters, digits, "+
"'.', '_' and '-' only); this stop is allowed", sessionID))
return 0
}
f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID))
counters := readRefusalCounters(waitsDir, sessionID)
d := DecideStop(sessionID, f, err, counters, time.Now())
if werr := writeRefusalCounters(waitsDir, sessionID, d.Counters); werr != nil && d.Refuse {
// Item 2: writing the counter failed on the very call that would have incremented it toward the cap;
// refusing anyway could hold the turn forever, so this call is let through instead.
notice(stdout, stderr, fmt.Sprintf("this wait's refusal counter could not be written (%v); allowed so it "+
"cannot hold the turn forever", werr))
return 0
}
if d.Reason != "" {
fmt.Fprintln(stderr, d.Reason)
if !d.Refuse {
writeSystemMessage(stdout, d.Reason) // item a: a fail-open or let-through notice also reaches the screen
}
}
if d.Refuse {
return 2
}
return 0
}
@@ -1,362 +0,0 @@
package main
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var stopT0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
func openWait(id string, bound int) Wait {
return Wait{ID: id, What: "the operator's Approve", Bound: bound, At: stopT0}
}
// 1743/AC1: a wait within its bound and unwatched refuses the stop, naming the wait, its read address and
// what the session does about it (mesh_wait on its family, conditionally — review round 1 item 3 — or a
// watcher that rereads the address).
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
w := openWait("w-1", 10)
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
w.Family = "mesh-delivery.decided"
d := DecideStop("sess-1", WaitsFile{Waits: []Wait{w}}, nil, map[string]int{}, stopT0.Add(time.Minute))
if !d.Refuse || !strings.Contains(d.Reason, "w-1") || !strings.Contains(d.Reason, "mesh-delivery.recheck") ||
!strings.Contains(d.Reason, "mesh_wait") || !strings.Contains(d.Reason, "watcher") {
t.Fatalf("%+v", d)
}
}
// 1743/AC2: a watched wait within its bound, a done wait (absent from the file), or no file entry: allowed.
func TestStopAllowsAWatchedOrDoneOrAbsentWait(t *testing.T) {
watched := openWait("w-2", 10)
watched.Watcher = "mesh_wait on mesh-delivery.decided"
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(time.Minute)); d.Refuse {
t.Fatalf("a watched wait within its bound was refused: %+v", d)
}
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{}}, nil, map[string]int{}, stopT0); d.Refuse {
t.Fatalf("no open waits was refused: %+v", d)
}
}
// 1743/AC3: a wait past its bound, watched or not, is refused until said, then allowed.
func TestStopRefusesPastBoundUntilSaidWatchedOrNot(t *testing.T) {
unwatched := openWait("w-3", 5)
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{unwatched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse ||
!strings.Contains(d.Reason, "w-3") || !strings.Contains(d.Reason, "said") {
t.Fatalf("unwatched past bound: %+v", d)
}
watched := unwatched
watched.Watcher = "mesh_wait on mesh-delivery.decided"
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse {
t.Fatalf("watched past bound was allowed: %+v", d)
}
said := watched
said.Said = true
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{said}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); d.Refuse {
t.Fatalf("said past bound was still refused: %+v", d)
}
}
// 1743/AC4: an unreadable or malformed waits file is allowed, saying so on stderr and as a systemMessage on
// stdout; the decision does not touch the network and answers at once.
func TestStopAllowsAndSaysSoWhenTheWaitsFileIsUnreadableOrMalformed(t *testing.T) {
d := DecideStop("sess-4", WaitsFile{}, errors.New("no such file"), map[string]int{}, stopT0)
if d.Refuse || !strings.Contains(d.Reason, "could not be read") {
t.Fatalf("%+v", d)
}
waitsDir := t.TempDir()
if err := os.WriteFile(filepath.Join(waitsDir, "sess-4b.json"), []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
start := time.Now()
code := runStop(waitsDir, stdin, &stdout, &stderr)
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
// Item (a): a fail-open notice also reaches the screen, as the one JSON object Claude Code shows.
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "could not be read") {
t.Fatalf("the fail-open notice did not reach stdout: %s", stdout.String())
}
if took := time.Since(start); took > 5*time.Second {
t.Fatalf("took %s; the Stop hook has no network and is bounded under 5s", took)
}
}
// Review round 2, item 1 (1743/AC2, ADR 0490 §2): a missing waits file is the normal state for a session
// with no waits — every stop of every such session — so it must print nothing, on either stream, and
// simply allow; a notice there would fire constantly for nothing wrong.
func TestStopPrintsNothingWhenTheWaitsFileIsSimplyMissing(t *testing.T) {
d := DecideStop("sess-4c", WaitsFile{}, fs.ErrNotExist, map[string]int{}, stopT0)
if d.Refuse || d.Reason != "" {
t.Fatalf("a missing file (fs.ErrNotExist) said something: %+v", d)
}
waitsDir := t.TempDir() // no file at all for sess-4d
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-4d","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("code %d", code)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("a missing waits file printed something: stdout %q, stderr %q", stdout.String(), stderr.String())
}
}
// 1743/AC5: the same stop refused three times running for the same wait is allowed the fourth time, saying
// it was left unwatched; the counter resets when the wait changes.
func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
w := openWait("w-5", 10)
counters := map[string]int{}
for i := 1; i <= MostStopRefusals; i++ {
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
if !d.Refuse {
t.Fatalf("refusal %d was allowed: %+v", i, d)
}
counters = d.Counters
}
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
if d.Refuse || !strings.Contains(d.Reason, "unwatched") {
t.Fatalf("the fourth refusal was not let through: %+v", d)
}
if len(d.Counters) != 0 {
t.Fatalf("the counter was not reset once let through: %+v", d.Counters)
}
// A different wait starts its own count from zero.
w2 := openWait("w-5b", 10)
d2 := DecideStop("sess-5", WaitsFile{Waits: []Wait{w2}}, nil, counters, stopT0.Add(time.Minute))
if !d2.Refuse || d2.Counters["w-5b"] != 1 {
t.Fatalf("a different wait did not start its own count: %+v", d2)
}
}
// 1743/AC6: a panic answers allow.
func TestStopAPanicAllows(t *testing.T) {
waitsDir := t.TempDir()
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
t.Fatalf("a panic was not recovered: code %d", code)
}
}
// runStop through its counter file: three refusals in a row for the one open wait, then let through.
func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
waitsDir := t.TempDir()
f := WaitsFile{Session: "sess-6", At: stopT0, Waits: []Wait{openWait("w-6", 10)}}
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-6.json"), f)
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
for i := 1; i <= MostStopRefusals; i++ {
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 2 {
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
}
}
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
}
// Item (a): the let-through notice reaches the screen too, not only the log.
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
}
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
// 1749, follow-up).
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
}
}
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
// every session.
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
t.Fatalf("a stop with no waits created the waits folder: %v", err)
}
}
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
waitsDir := t.TempDir()
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
if err != nil {
t.Fatal(err)
}
var counters map[string]int
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
t.Fatalf("the counter file: %s", raw)
}
}
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
// counter file rather than leaving a stale "{}" behind.
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
waitsDir := t.TempDir()
path := filepath.Join(waitsDir, "sess-8.json")
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
}
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
t.Fatalf("the counter file was not written: %v", err)
}
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
var stdout2, stderr2 bytes.Buffer
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
}
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
}
}
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one (path separators,
// ".."), is never used to build a file path — the counter file stays inside the waits directory — and the
// Stop hook allows, saying why.
func TestStopRejectsAnUnsafeSessionID(t *testing.T) {
for _, id := range []string{"../../x", "a/b"} {
root := t.TempDir()
waitsDir := filepath.Join(root, "agent", "waits")
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q,"stop_hook_active":false}`, id))
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("id %q: code %d, stderr %s", id, code, stderr.String())
}
if !strings.Contains(stderr.String(), "could not be used") {
t.Fatalf("id %q: did not say why it allowed: %s", id, stderr.String())
}
if !strings.Contains(stdout.String(), "systemMessage") {
t.Fatalf("id %q: the notice did not reach stdout: %s", id, stdout.String())
}
escaped := filepath.Join(waitsDir, id+".refusals.json")
if _, err := os.Stat(escaped); !os.IsNotExist(err) {
t.Fatalf("id %q: a counter file was written outside the waits dir at %s", id, escaped)
}
}
// A blank id still just allows, quietly: it never reaches the point of building a path at all.
waitsDir := t.TempDir()
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, strings.NewReader(`{"session_id":""}`), &stdout, &stderr); code != 0 {
t.Fatalf("blank id: code %d", code)
}
}
// Review round 1, item 2 (ADR 0489 §5 "never loops", 1743/AC5): when the decision is to refuse and the
// refusal counter cannot be written, the hook allows rather than risk refusing forever with a cap that
// never takes effect.
func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root: a directory's write bit cannot be taken away")
}
waitsDir := t.TempDir()
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"),
WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}})
if err := os.Chmod(waitsDir, 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(waitsDir, 0o700) })
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "counter") {
t.Fatalf("did not say the counter could not be written: %s", stderr.String())
}
if !strings.Contains(stdout.String(), "systemMessage") {
t.Fatalf("the notice did not reach stdout: %s", stdout.String())
}
}
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
// runner may not be granted it); without one, the advice is only to start a watcher.
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
withFamily := openWait("w-f", 10)
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
withFamily.Family = "mesh-delivery.decided"
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
t.Fatalf("%s", reason)
}
noFamily := openWait("w-nf", 10)
noFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
_, reason2 := waitBlocksStop("sess-nf", noFamily, stopT0.Add(time.Minute))
if strings.Contains(reason2, "mesh_wait") || !strings.Contains(reason2, "start a watcher that rereads mesh-delivery.recheck") {
t.Fatalf("%s", reason2)
}
}
// 1743/AC7's rendering half (hooks.json holds the guard's PreToolUse entry and the Stop entry) is checked
// together with 1744/AC4 in terminal_only_test.go's
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. claude_code_hook_register still
// refuses a Stop hook (or any other) at every scope.
func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
p, _ := node(t, "laptop")
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isError := s.call(t, "claude_code_hook_register", map[string]any{"name": "x", "event": "Stop", "command": "true"})
if !isError || !strings.Contains(text, "issue 340") {
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
}
}
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
// filesystem only through the helper's request/response protocol bumps its count.
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
f, home, counted := agentFiles(t)
was := fsys
fsys = f
t.Cleanup(func() { fsys = was })
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
t.Fatal(err)
}
if counted.asked.Load() == 0 {
t.Fatal("the counter file was written directly, never through the helper")
}
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]int
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
t.Fatalf("the written counters: %s", raw)
}
}
@@ -261,12 +261,12 @@ func TestTheOperatorsManagedSettingsAreWhatSessionsObey(t *testing.T) {
}
}
// The plugin's only hooks are the guard, the session-start hook and the Stop hook (novox/hq ADR 0245, ADR
// 0489) — never a hook a registration named, whatever the configuration given holds (novox/hq issue 340).
func TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook(t *testing.T) {
// The plugin's hooks are the mesh's own guard on the shell (novox/hq ADR 0245) and nothing else, whatever the
// configuration given holds (novox/hq issue 340).
func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
c := Config{Mesh: []Item{{Kind: KindHook, Name: "planted", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
Command: "${HOOK_DIR}/x.sh", Files: map[string]string{"x.sh": "#!/bin/sh\n"}}}}
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true", WaitsDir: "/home/agent/.claude/nox-mesh/waits"})
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true"})
root := "plugins/" + Plugin + "/"
var hooks struct {
Hooks map[string][]struct {
@@ -279,16 +279,9 @@ func TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook(t *test
if err := json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks); err != nil {
t.Fatal(err)
}
if len(hooks.Hooks) != 3 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
if len(hooks.Hooks) != 1 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
hooks.Hooks["PreToolUse"][0].Hooks[0].Command != GuardCommand() {
t.Fatalf("the guard's hook: %s", plugin[root+"hooks/hooks.json"].Content)
}
if len(hooks.Hooks["SessionStart"]) != 1 || hooks.Hooks["SessionStart"][0].Matcher != "startup|resume|clear|compact" ||
hooks.Hooks["SessionStart"][0].Hooks[0].Command != SessionStartCommand("/home/agent/.claude/nox-mesh/waits") {
t.Fatalf("the session-start hook: %s", plugin[root+"hooks/hooks.json"].Content)
}
if len(hooks.Hooks["Stop"]) != 1 || hooks.Hooks["Stop"][0].Hooks[0].Command != StopCommand("/home/agent/.claude/nox-mesh/waits") {
t.Fatalf("the Stop hook: %s", plugin[root+"hooks/hooks.json"].Content)
t.Fatalf("the plugin's hooks: %s", plugin[root+"hooks/hooks.json"].Content)
}
for path := range plugin {
if strings.HasPrefix(path, root+"hooks/planted") {
@@ -1,776 +0,0 @@
package main
// A session's wait list (novox/hq ADR 0489, as changed by ADR 0490): what a session still expects an answer
// to — an Approve, a delivery's end, anything it started and must act on when it ends
// (follow-what-you-start) — so the plugin's Stop hook can refuse to end its turn while one is open and
// nothing watches it.
//
// Kept in the module's `waits` state under the session's name and this node (ADR 0490 §1) — `by`, as
// claude_code_ask takes it, never the session id, which Claude Code hands a session a new one of at a
// /clear. Every call also gives `session`, the caller's current id; the module keeps the binding of a name
// to the session that last called for it, with that call's time, so the same name's waits are found again
// across a /clear once the new session has adopted it.
//
// The waits file a hook reads is kept per session id, not per name, and holds the waits of whichever name is
// currently bound to that id (empty for an id nothing is bound to): written whole, atomically, on every
// change, and rewritten for both ids when a binding moves (AdoptName). A second file, the node-wide index,
// lists every name on this node that still holds an open wait, so the SessionStart hook can tell a session
// with a brand new id what it might want to adopt, without calling back into this module.
//
// Every file lives under the agent's home (the manifest's `agent-home` resource), which this process —
// launched as the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent
// account of its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already
// carries such a write through the sudo helper the guard's binary also serves, exactly as grant.go's
// WriteCredentials does for the agent's own credentials file. Where the node has no agent account of its
// own, fsys is the plain filesystem and the write reaches the operator's own home directly.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// WaitsState is the module's state the wait lists are kept in.
const WaitsState = "waits"
// WaitsDir is where the mirrored files live under the agent's home.
const WaitsDir = "nox-mesh/waits"
// WaitPrefix starts every wait's id.
const WaitPrefix = "w-"
// MostWaits is how many open waits one name may hold at once (novox/hq ADR 0489, 1629/AC5).
const MostWaits = 50
// The bounds of a wait's bound, in minutes (1629/AC5).
const (
ShortestWaitBound = 1
LongestWaitBound = 1440
)
// mostWaitAttempts bounds the compare-and-set retry loop over the `waits` state.
const mostWaitAttempts = 20
var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
// requireSessionID trims session and refuses it unless it is a plain id matching waitSessionID — every op
// holds to this (review round 1, item b), so a session id is never used to build a path from anything but a
// safe name (no "/", so it cannot name a file outside the waits directory).
func requireSessionID(session string) (string, error) {
session = strings.TrimSpace(session)
if !waitSessionID.MatchString(session) {
return "", errors.New("session is required: the id the SessionStart hook named")
}
return session, nil
}
// WaitRead is the verb a session reads to see where one wait stands.
type WaitRead struct {
Address string `json:"address"`
Arguments map[string]any `json:"arguments,omitempty"`
}
// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). By is the name it was
// added under.
type Wait struct {
ID string `json:"id"`
By string `json:"by"`
What string `json:"what"`
Read WaitRead `json:"read"`
Family string `json:"family,omitempty"`
Bound int `json:"bound"` // minutes
At time.Time `json:"at"`
Watcher string `json:"watcher,omitempty"`
Said bool `json:"said,omitempty"`
}
// PastBound says whether now is past this wait's bound.
func (w Wait) PastBound(now time.Time) bool {
return now.Sub(w.At) > time.Duration(w.Bound)*time.Minute
}
// NamedWaits is what the `waits` state keeps for one name on this node (novox/hq ADR 0490 §1): its open
// waits, and the binding to whichever session currently answers to that name — empty until the first call
// names it, by add or by adopt.
type NamedWaits struct {
Name string `json:"name"`
Node string `json:"node"`
CurrentSession string `json:"currentSession,omitempty"`
LastCall time.Time `json:"lastCall,omitempty"`
Waits []Wait `json:"waits"`
}
// WaitsFile is what one session id's mirrored file holds, whole, on every change (1629/AC3): the open waits
// of whichever name is currently bound to this session, and the time they were written.
type WaitsFile struct {
Session string `json:"session"`
At time.Time `json:"at"`
Waits []Wait `json:"waits"`
}
// NodeIndexEntry is one name's open waits, as the node-wide index lists it for the SessionStart hook
// (novox/hq ADR 0490 §2).
type NodeIndexEntry struct {
Name string `json:"name"`
Open int `json:"open"`
CurrentSession string `json:"currentSession,omitempty"`
LastCall time.Time `json:"lastCall"`
}
// NodeIndex is the node-wide index file's whole content (novox/hq ADR 0490 §2): every name on this node that
// still holds an open wait, written by the tool on every change a name's waits or binding takes, so the
// SessionStart hook can say "if this is you, adopt" without calling back into this module.
type NodeIndex struct {
Node string `json:"node"`
At time.Time `json:"at"`
Names []NodeIndexEntry `json:"names"`
}
// WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the
// revision read; Keys lists every name kept, for the node-wide index.
type WaitStore interface {
Get(key string) (json.RawMessage, uint64, bool, error)
Create(key string, value any) error
Update(key string, value any, revision uint64) error
Keys() ([]string, error)
}
// keptWaits is the `waits` state on the bus.
type keptWaits struct{ s stdio.KeptState }
func (k keptWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (k keptWaits) Create(key string, value any) error {
_, err := k.s.Create(key, value)
return err
}
func (k keptWaits) Update(key string, value any, revision uint64) error {
_, err := k.s.Update(key, value, revision)
return err
}
func (k keptWaits) Keys() ([]string, error) { return k.s.Keys() }
// Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch.
type Waiting struct {
Store WaitStore
Now func() time.Time
}
var waiting *Waiting
// waitsKey is the state's key for one name's waits: its name and this node (novox/hq ADR 0490 §1), so two
// nodes never collide over the same name.
func waitsKey(p Paths, name string) string { return p.Node + "/" + name }
// WaitsDirPath is where this node's sessions' waits are mirrored, under the agent's home: the directory the
// plugin's SessionStart and Stop hooks (1744, 1743) are given on their command line.
func WaitsDirPath(p Paths) string {
return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir))
}
// waitsFilePath is where one session id's mirrored waits are.
func waitsFilePath(p Paths, session string) string {
return filepath.Join(WaitsDirPath(p), session+".json")
}
// nodeIndexPath is the node-wide index's file, beside the per-session ones; its name can never collide with
// a session id, since waitSessionID never allows a leading "_".
func nodeIndexPath(p Paths) string { return filepath.Join(WaitsDirPath(p), "_index.json") }
func newWaitID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return WaitPrefix + hex.EncodeToString(b[:])
}
// addressShaped says whether s could be an address `mesh_call` takes: not blank, no whitespace, and a dot
// between two names (`<seat>.<verb>`, `<node>/<seat>.<verb>`, `<node>/<module>.<tool>`).
func addressShaped(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.ContainsAny(s, " \t\n\r") {
return false
}
dot := strings.LastIndex(s, ".")
return dot > 0 && dot < len(s)-1
}
func readNamedWaits(store WaitStore, key string) (NamedWaits, uint64, bool, error) {
raw, rev, found, err := store.Get(key)
if err != nil || !found {
return NamedWaits{}, 0, found, err
}
var nw NamedWaits
_ = json.Unmarshal(raw, &nw)
return nw, rev, true, nil
}
// updateNamedWaits reads one name's waits, lets mutate change them, and writes the result back at the
// revision read — created when there was none yet — retried when another write won the race.
func updateNamedWaits(store WaitStore, key string, mutate func(NamedWaits) (NamedWaits, error)) (NamedWaits, error) {
for i := 0; i < mostWaitAttempts; i++ {
nw, rev, found, err := readNamedWaits(store, key)
if err != nil {
return NamedWaits{}, fmt.Errorf("the wait list could not be read: %w", err)
}
next, err := mutate(nw)
if err != nil {
return NamedWaits{}, err
}
if !found {
err = store.Create(key, next)
} else {
err = store.Update(key, next, rev)
}
if err == nil {
return next, nil
}
if errors.Is(err, stdio.ErrChanged) {
continue
}
return NamedWaits{}, fmt.Errorf("the wait list could not be written: %w", err)
}
return NamedWaits{}, errors.New("the wait list kept changing elsewhere; try again")
}
// tempSuffix is a short random name for a temp file, so two writes racing for the same path never share one
// (review round 2, item a): each gets its own, and only the rename is a single atomic step.
func tempSuffix() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// writeSessionFile rewrites one session id's mirrored file whole, atomically (its own temp file, then
// rename): it holds exactly the open waits given and the time of this write (1629/AC3). waits is always the
// union of every name currently bound to sessionID (sessionWaitsUnion), never only the name an op acted on:
// a second name bound to the same id must stay enforced (ADR 0490 §1, review round 2 item 2).
func writeSessionFile(p Paths, sessionID string, waits []Wait, now time.Time) error {
path := waitsFilePath(p, sessionID)
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
if waits == nil {
waits = []Wait{}
}
raw, err := indented(WaitsFile{Session: sessionID, At: now.UTC(), Waits: waits})
if err != nil {
return err
}
tmp := path + ".mesh-tmp-" + tempSuffix()
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return fsys.Rename(tmp, path)
}
// sessionWaitsUnion is every open wait of every name on this node currently bound to session (ADR 0490 §1):
// what that session id's mirrored file must hold, whole — never only the one name an op just acted on, or a
// second name bound to the same id would stop being enforced the moment any op touched the first.
func sessionWaitsUnion(store WaitStore, node, session string) ([]Wait, error) {
keys, err := store.Keys()
if err != nil {
return nil, err
}
prefix := node + "/"
var out []Wait
names := make([]string, 0, len(keys))
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
names = append(names, k)
}
}
sort.Strings(names) // deterministic order: tests, and a session reading its own file, see the same thing
for _, k := range names {
nw, _, found, err := readNamedWaits(store, k)
if err != nil || !found || nw.CurrentSession != session {
continue
}
out = append(out, nw.Waits...)
}
return out, nil
}
// writeSessionWaits rewrites session's mirrored file with sessionWaitsUnion: every call that changes a
// name's waits or its binding goes through this, never writeSessionFile directly with one name's waits
// alone.
func writeSessionWaits(p Paths, store WaitStore, session string, now time.Time) error {
union, err := sessionWaitsUnion(store, p.Node, session)
if err != nil {
return fmt.Errorf("the session's other names could not be read: %w", err)
}
return writeSessionFile(p, session, union, now)
}
// writeNodeIndex rewrites the node-wide index whole, atomically, from every name's current state on this
// node (novox/hq ADR 0490 §2): a full scan of the `waits` state, bounded by how many names are ever used on
// one node at once. Best-effort for its caller: a name's own file is already correct by the time this runs.
func writeNodeIndex(p Paths, store WaitStore, now time.Time) error {
keys, err := store.Keys()
if err != nil {
return fmt.Errorf("the node-wide wait index could not be read: %w", err)
}
prefix := p.Node + "/"
idx := NodeIndex{Node: p.Node, At: now.UTC(), Names: []NodeIndexEntry{}}
for _, k := range keys {
name, ok := strings.CutPrefix(k, prefix)
if !ok {
continue
}
nw, _, found, err := readNamedWaits(store, k)
if err != nil || !found || len(nw.Waits) == 0 {
continue
}
idx.Names = append(idx.Names, NodeIndexEntry{Name: name, Open: len(nw.Waits),
CurrentSession: nw.CurrentSession, LastCall: nw.LastCall.UTC()})
}
sort.Slice(idx.Names, func(i, j int) bool { return idx.Names[i].Name < idx.Names[j].Name })
path := nodeIndexPath(p)
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
raw, err := indented(idx)
if err != nil {
return err
}
tmp := path + ".mesh-tmp-" + tempSuffix()
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return fsys.Rename(tmp, path)
}
// callerName reads `by` as claude_code_ask does: who@node, where node is this one — the name a wait list is
// kept under (novox/hq ADR 0490 §1), never the session id.
func callerName(p Paths, by string) (string, error) {
by = strings.TrimSpace(by)
if who, node, found := strings.Cut(by, "@"); found {
if node != p.Node {
return "", fmt.Errorf("by names the node %q, and this is %s: call on the node the session runs on", node, p.Node)
}
return who, nil
}
if by == "" {
return "", errors.New("by is required: your session's name, as who@node or who")
}
return by, nil
}
// bindCaller checks the binding for name against session, refusing when another, more recently bound
// session holds it (novox/hq ADR 0490 §3) — naming that session and when it last called, and how to take the
// name back with adopt — and otherwise (re)binds it to session at now, so a name nothing has bound yet is
// claimed by the first session that calls for it, add included.
func bindCaller(nw *NamedWaits, name, node, by, session string, now time.Time) error {
if nw.CurrentSession != "" && nw.CurrentSession != session {
return fmt.Errorf("the name %q now answers to session %s (it last called at %s); adopt it again if this "+
"is you: claude_code_wait {\"op\":\"adopt\",\"by\":%q,\"session\":%q}", name, nw.CurrentSession,
nw.LastCall.UTC().Format(time.RFC3339), by, session)
}
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, node, session, now.UTC()
return nil
}
// findWait is the index of id in waits, or -1.
func findWait(waits []Wait, id string) int {
for i, w := range waits {
if w.ID == id {
return i
}
}
return -1
}
// WaitInput is what claude_code_wait's add takes.
type WaitInput struct {
Session string
By string
What string
Read WaitRead
Family string
Bound int
}
// AddWait keeps one more wait for a name, refusing outside ADR 0489's bounds (1629/AC5) or when another
// session has since taken the name over (ADR 0490 §3), and rewrites the caller's mirrored file (1629/AC3).
func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(in.Session)
if err != nil {
return nil, err
}
name, err := callerName(p, in.By)
if err != nil {
return nil, err
}
what := strings.TrimSpace(in.What)
if what == "" {
return nil, errors.New("what is required: say what you are waiting for")
}
if in.Bound < ShortestWaitBound || in.Bound > LongestWaitBound {
return nil, fmt.Errorf("bound_minutes is %d; it is %d to %d", in.Bound, ShortestWaitBound, LongestWaitBound)
}
address := strings.TrimSpace(in.Read.Address)
if !addressShaped(address) {
return nil, fmt.Errorf("read.address %q is not an address: the verb that reads where this wait stands, as mesh_call takes it", in.Read.Address)
}
w := Wait{ID: newWaitID(), By: name, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments},
Family: strings.TrimSpace(in.Family), Bound: in.Bound, At: now.UTC()}
key := waitsKey(p, name)
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, in.By, session, now); err != nil {
return nw, err
}
if len(nw.Waits) >= MostWaits {
return nw, fmt.Errorf("%d waits already wait for %s; at most %d open waits per name", len(nw.Waits), name, MostWaits)
}
nw.Waits = append(append([]Wait(nil), nw.Waits...), w)
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was kept, but its file could not be written: %w", err)
}
if err := writeNodeIndex(p, store, now); err != nil {
say("writing the node-wide wait index after %s added for %s: %v", w.ID, name, err)
}
say("wait %s added for %s (session %s): %s", w.ID, name, session, what)
return map[string]any{"id": w.ID, "name": name, "session": session, "open": len(nw.Waits)}, nil
}
// WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2).
func WatchedWait(p Paths, session, by, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
id, watcher = strings.TrimSpace(id), strings.TrimSpace(watcher)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
if watcher == "" {
return nil, errors.New("watcher is required: what follows this wait (mesh_wait on its family, or a watcher agent)")
}
key := waitsKey(p, name)
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
return nw, err
}
i := findWait(nw.Waits, id)
if i < 0 {
return nw, fmt.Errorf("no open wait %s for %s", id, name)
}
nw.Waits[i].Watcher = watcher
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err)
}
_ = writeNodeIndex(p, store, now)
return map[string]any{"id": id, "watcher": watcher}, nil
}
// DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2).
func DoneWait(p Paths, session, by, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
id, outcome = strings.TrimSpace(id), strings.TrimSpace(outcome)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
key := waitsKey(p, name)
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
return nw, err
}
i := findWait(nw.Waits, id)
if i < 0 {
return nw, fmt.Errorf("no open wait %s for %s", id, name)
}
nw.Waits = append(append([]Wait(nil), nw.Waits[:i]...), nw.Waits[i+1:]...)
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was done, but its file could not be written: %w", err)
}
_ = writeNodeIndex(p, store, now)
say("wait %s for %s (session %s) done: %s", id, name, session, outcome)
return map[string]any{"id": id, "done": true, "outcome": outcome}, nil
}
// SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound
// (1629/AC2).
func SaidWait(p Paths, session, by, id string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
id = strings.TrimSpace(id)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
key := waitsKey(p, name)
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
return nw, err
}
i := findWait(nw.Waits, id)
if i < 0 {
return nw, fmt.Errorf("no open wait %s for %s", id, name)
}
if !nw.Waits[i].PastBound(now) {
return nw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id)
}
nw.Waits[i].Said = true
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err)
}
_ = writeNodeIndex(p, store, now)
return map[string]any{"id": id, "said": true}, nil
}
// ListWaits answers a name's open waits, with their age and whether they are past their bound (1629/AC1),
// and who currently holds the name. Read-only (review round 2, item c): unlike every other op it never
// claims an unbound name, and never refuses a session that is not (or not yet) the one bound to it — a
// session may want to look before adopting.
func ListWaits(p Paths, session, by string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
nw, _, _, err := readNamedWaits(store, waitsKey(p, name))
if err != nil {
return nil, fmt.Errorf("the wait list could not be read: %w", err)
}
out := []map[string]any{}
for _, w := range nw.Waits {
entry := map[string]any{"id": w.ID, "what": w.What, "by": w.By, "address": w.Read.Address,
"bound_minutes": w.Bound, "at": w.At.Format(time.RFC3339), "age_seconds": int(now.Sub(w.At).Seconds()),
"past_bound": w.PastBound(now), "watcher": w.Watcher, "said": w.Said}
if len(w.Read.Arguments) > 0 {
entry["arguments"] = w.Read.Arguments
}
if w.Family != "" {
entry["family"] = w.Family
}
out = append(out, entry)
}
answer := map[string]any{"session": session, "name": name, "open": out}
if nw.CurrentSession != "" {
answer["held-by"] = map[string]any{"session": nw.CurrentSession, "last-called": nw.LastCall.UTC().Format(time.RFC3339)}
}
return answer, nil
}
// AdoptName binds name to session, always taking it over from whoever held it (novox/hq ADR 0490 §3): the
// mirrored files of both the session it is taken from and the one adopting it are rewritten at once, so a
// session that no longer owns a name never finds its old waits in its own file, and the new one does right
// away — no waiting on a bound, unlike the Stop and SessionStart hooks' own.
func AdoptName(p Paths, by, session string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
key := waitsKey(p, name)
var oldSession string
var oldLastCall time.Time
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
oldSession, oldLastCall = nw.CurrentSession, nw.LastCall
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, p.Node, session, now.UTC()
return nw, nil
})
if err != nil {
return nil, err
}
tookOver := oldSession != "" && oldSession != session
if tookOver {
// oldSession's file is rewritten from the union of whatever names remain bound to it (ADR 0490 §1,
// review round 2 item 2): name no longer is one of them, but another might still be — never simply
// emptied.
if err := writeSessionWaits(p, store, oldSession, now); err != nil {
return nil, fmt.Errorf("%s was adopted, but the session it was taken from could not be told: %w", name, err)
}
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the name was adopted, but its file could not be written: %w", err)
}
if err := writeNodeIndex(p, store, now); err != nil {
say("writing the node-wide wait index after %s was adopted: %v", name, err)
}
// Review round 2, item b: a fresh three-strikes count starts under the new binding, on both ids.
waitsDir := WaitsDirPath(p)
_ = writeRefusalCounters(waitsDir, session, map[string]int{})
if tookOver {
_ = writeRefusalCounters(waitsDir, oldSession, map[string]int{})
}
answer := map[string]any{"name": name, "session": session, "open": len(nw.Waits)}
if tookOver {
answer["took-over-from"] = map[string]any{"session": oldSession, "last-called": oldLastCall.UTC().Format(time.RFC3339)}
say("%s adopted by session %s, taken over from %s (last called %s)", name, session, oldSession,
oldLastCall.UTC().Format(time.RFC3339))
} else {
say("%s adopted by session %s", name, session)
}
return answer, nil
}
// waitsFileIn is where one session's mirrored waits are, given a hook's own waits-dir argument: the hooks
// have no launch environment, only their command line, so they never recompute this from Paths.
func waitsFileIn(waitsDir, session string) string { return filepath.Join(waitsDir, session+".json") }
// nodeIndexPathIn is the node-wide index file, given a hook's own waits-dir argument.
func nodeIndexPathIn(waitsDir string) string { return filepath.Join(waitsDir, "_index.json") }
// readWaitsFile reads one session's mirrored file, as the hooks do: a plain local read, as the session's own
// account (never through fsys or the module's state), and never calling back into this module.
func readWaitsFile(path string) (WaitsFile, error) {
raw, err := os.ReadFile(path)
if err != nil {
return WaitsFile{}, err
}
var f WaitsFile
if err := json.Unmarshal(raw, &f); err != nil {
return WaitsFile{}, err
}
return f, nil
}
// readNodeIndex reads the node-wide index, as the SessionStart hook does: a plain local read, never through
// fsys or the module's state, and never calling back into this module.
func readNodeIndex(path string) (NodeIndex, error) {
raw, err := os.ReadFile(path)
if err != nil {
return NodeIndex{}, err
}
var idx NodeIndex
if err := json.Unmarshal(raw, &idx); err != nil {
return NodeIndex{}, err
}
return idx, nil
}
// waitTools is claude_code_wait: add, watched, done, said, list and adopt, over a name's wait list (novox/hq
// ADR 0489, ADR 0490).
func waitTools(p Paths) []stdio.Tool {
notLaunched := errors.New("this module was not launched with the bus; no wait can be kept")
return []stdio.Tool{
{Name: "claude_code_wait",
Description: "Your wait list, kept under your session's name (novox/hq ADR 0489, ADR 0490): what you " +
"still expect an answer to — an Approve, a delivery's end, anything you started and must act on when " +
"it ends. The plugin's Stop hook refuses to end your turn while a wait here is open and nothing " +
"follows it. op is add, watched, done, said, list or adopt; session (your current session id, named " +
"by the SessionStart hook) and by (your session's name, as who@node or who) are always required. A " +
"name is bound to whichever session last called for it; if your own id changed (a /clear) you must " +
"adopt your name again before it answers to you — the SessionStart hook lists every name on this " +
"node with an open wait and how. adopt takes the name over at once, however recently another " +
"session called for it; that session is told so, naming yours, the next time it calls. add takes " +
"what (what you are waiting for), read (the verb that answers where it stands: {address, " +
"arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes (1 to 1440), and " +
"answers an id; at most 50 waits are open per name. watched takes id and watcher (what follows it: " +
"mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes it " +
"from the open list. said takes id and marks a wait past its bound as told to the operator — " +
"refused while it is still within its bound. list answers the open waits, with their age and " +
"whether each is past its bound.",
Input: map[string]any{
"op": str("add, watched, done, said, list or adopt"),
"session": str("required: your session's current id"),
"by": str("required: your session's name, as who@node or who"),
"what": str("add: what you are waiting for, in plain words"),
"read": map[string]any{"type": "object", "description": "add: the verb that reads where this wait stands",
"properties": map[string]any{
"address": str("the address, as mesh_call takes it"),
"arguments": map[string]any{"type": "object", "description": "its arguments"},
}},
"family": str("add: the event family mesh_wait can wait on, when there is one"),
"bound_minutes": map[string]any{"type": "integer", "description": "add: minutes until this wait's bound, 1 to 1440"},
"id": str("watched, done or said: the id add answered"),
"watcher": str("watched: what follows this wait"),
"outcome": str("done: what the wait ended with"),
},
Run: func(a map[string]any) (any, error) {
if waiting == nil {
return nil, notLaunched
}
session, by := strArg(a, "session"), strArg(a, "by")
now := time.Now()
if waiting.Now != nil {
now = waiting.Now()
}
switch op := strArg(a, "op"); op {
case "add":
read := WaitRead{}
if m, ok := a["read"].(map[string]any); ok {
read.Address = strArg(m, "address")
if args, ok := m["arguments"].(map[string]any); ok {
read.Arguments = args
}
}
bound := 0
if n, ok := a["bound_minutes"].(float64); ok {
bound = int(n)
}
return AddWait(p, WaitInput{Session: session, By: by, What: strArg(a, "what"), Read: read,
Family: strArg(a, "family"), Bound: bound}, waiting.Store, now)
case "watched":
return WatchedWait(p, session, by, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now)
case "done":
return DoneWait(p, session, by, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now)
case "said":
return SaidWait(p, session, by, strArg(a, "id"), waiting.Store, now)
case "list":
return ListWaits(p, session, by, waiting.Store, now)
case "adopt":
return AdoptName(p, by, session, waiting.Store, now)
default:
return nil, fmt.Errorf("op is add, watched, done, said, list or adopt, not %q", op)
}
}},
}
}
@@ -1,577 +0,0 @@
package main
// A session's wait list (novox/hq ADR 0489, ADR 0490, issues 1629, 1744).
import (
"encoding/json"
"os"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// memWaits is the `waits` state: values with revisions, create-only and compare-and-set, as the bus keeps it.
type memWaits struct {
mu sync.Mutex
m map[string]json.RawMessage
rev map[string]uint64
n uint64
}
func newMemWaits() *memWaits {
return &memWaits{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
}
func (s *memWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
raw, ok := s.m[key]
return raw, s.rev[key], ok, nil
}
func (s *memWaits) put(key string, value any) error {
raw, err := json.Marshal(value)
s.n++
s.m[key], s.rev[key] = raw, s.n
return err
}
func (s *memWaits) Create(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.m[key]; ok {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memWaits) Update(key string, value any, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.rev[key] != revision {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memWaits) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.m))
for k := range s.m {
out = append(out, k)
}
return out, nil
}
// addApproveWait adds the wait fixture the operator's Approve is kept under, for name on session.
func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string, at time.Time) string {
t.Helper()
out, err := AddWait(p, WaitInput{Session: session, By: name + "@" + p.Node, What: "the operator's Approve of pull request 42",
Read: WaitRead{Address: "mesh-delivery.recheck", Arguments: map[string]any{"pr": 42}}, Family: "mesh-delivery.decided",
Bound: 10}, store, at)
if err != nil {
t.Fatalf("add: %v", err)
}
return out["id"].(string)
}
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
if !strings.HasPrefix(id, WaitPrefix) {
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
}
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
open := out["open"].([]map[string]any)
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
t.Fatalf("%+v", open)
}
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
if err != nil {
t.Fatal(err)
}
open = out["open"].([]map[string]any)
if open[0]["past_bound"] != true {
t.Fatalf("past its 10-minute bound after 11 minutes: %+v", open[0])
}
}
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
// it from the open list.
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
t.Fatalf("said before the bound: %v", err)
}
if _, err := WatchedWait(p, "sess-2", by, id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
out, _ := ListWaits(p, "sess-2", by, store, t0.Add(2*time.Minute))
open := out["open"].([]map[string]any)
if open[0]["watcher"] != "mesh_wait on mesh-delivery.decided" {
t.Fatalf("watcher not kept: %+v", open[0])
}
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(11*time.Minute)); err != nil {
t.Fatalf("said past the bound: %v", err)
}
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(12*time.Minute))
open = out["open"].([]map[string]any)
if open[0]["said"] != true {
t.Fatalf("said not kept: %+v", open[0])
}
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(13*time.Minute)); err != nil {
t.Fatal(err)
}
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(14*time.Minute))
if len(out["open"].([]map[string]any)) != 0 {
t.Fatalf("done did not remove it from the open list: %v", out)
}
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(15*time.Minute)); err == nil {
t.Fatal("done on an already-done wait was not refused")
}
}
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
// and the write time.
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
if err != nil {
t.Fatal(err)
}
if f.Session != "sess-3" || len(f.Waits) != 1 || f.Waits[0].ID != id1 || !f.At.Equal(t0.UTC()) {
t.Fatalf("%+v", f)
}
// Written whole: no .mesh-tmp left beside it.
if _, err := os.Stat(waitsFilePath(p, "sess-3") + ".mesh-tmp"); !os.IsNotExist(err) {
t.Fatalf("a temp file was left beside it: %v", err)
}
id2, err := AddWait(p, WaitInput{Session: "sess-3", By: by, What: "a second wait",
Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
if err != nil || len(f.Waits) != 2 || !f.At.Equal(t0.Add(time.Minute).UTC()) {
t.Fatalf("%+v %v", f, err)
}
if _, err := DoneWait(p, "sess-3", by, id1, "approved", store, t0.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
if err != nil || len(f.Waits) != 1 || f.Waits[0].ID != id2["id"] {
t.Fatalf("the file does not hold exactly the open waits: %+v %v", f, err)
}
}
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name
// after a /clear that keeps calling with the same session id; a different name lists none of them.
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
// A restart: a fresh Waiting over the same store reads the same waits.
restarted := &Waiting{Store: store, Now: func() time.Time { return t0.Add(time.Minute) }}
_ = restarted
out, err := ListWaits(p, "sess-4", by, store, t0.Add(time.Minute))
if err != nil || len(out["open"].([]map[string]any)) != 1 {
t.Fatalf("not read again after a restart: %v %v", out, err)
}
// The same session id, called again: still lists it.
out, err = ListWaits(p, "sess-4", by, store, t0.Add(2*time.Minute))
if err != nil || len(out["open"].([]map[string]any)) != 1 {
t.Fatalf("not read again for the same name: %v %v", out, err)
}
// A different name, same node: none of them.
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
if err != nil || len(out["open"].([]map[string]any)) != 0 {
t.Fatalf("another name saw them: %v %v", out, err)
}
}
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
// address, and more than 50 open waits per name, naming the argument.
func TestAddRefusesOutsideItsBounds(t *testing.T) {
p, _ := node(t, "g14")
base := func() WaitInput {
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
}
for name, change := range map[string]struct {
mutate func(*WaitInput)
want string
}{
"empty what": {func(in *WaitInput) { in.What = "" }, "what is required"},
"bound zero": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
"bound under 1": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
"bound over 1440": {func(in *WaitInput) { in.Bound = 1441 }, "bound_minutes"},
"empty address": {func(in *WaitInput) { in.Read.Address = "" }, "read.address"},
"address no dot": {func(in *WaitInput) { in.Read.Address = "notanaddress" }, "read.address"},
"address has spaces": {func(in *WaitInput) { in.Read.Address = "not an address" }, "read.address"},
} {
in := base()
change.mutate(&in)
store := newMemWaits()
if _, err := AddWait(p, in, store, t0); err == nil || !strings.Contains(err.Error(), change.want) {
t.Errorf("%s: %v", name, err)
}
}
// More than 50 open waits per name.
store := newMemWaits()
for i := 0; i < MostWaits; i++ {
if _, err := AddWait(p, base(), store, t0); err != nil {
t.Fatalf("wait %d: %v", i, err)
}
}
if _, err := AddWait(p, base(), store, t0); err == nil || !strings.Contains(err.Error(), "at most 50 open waits") {
t.Fatalf("the 51st wait was not refused: %v", err)
}
}
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
p, _ := node(t, "g14")
by := "fix-1629@g14"
for _, bad := range []string{"", "a/b", "../x"} {
store := newMemWaits()
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("list %q: %v", bad, err)
}
if _, err := WatchedWait(p, bad, by, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("watched %q: %v", bad, err)
}
if _, err := DoneWait(p, bad, by, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("done %q: %v", bad, err)
}
if _, err := SaidWait(p, bad, by, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("said %q: %v", bad, err)
}
}
}
// 1629/AC6: the tool is served through the module's own dispatch.
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
var added map[string]any
if err := json.Unmarshal([]byte(text), &added); err != nil {
t.Fatal(err)
}
id, _ := added["id"].(string)
if id == "" {
t.Fatalf("no id answered: %s", text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@g14"})
if isErr || !strings.Contains(text, id) {
t.Fatalf("list: %v %s", isErr, text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14",
"id": id, "outcome": "approved"})
if isErr {
t.Fatalf("done: %s", text)
}
}
// ---- ADR 0490: a name's waits survive a session's id changing, by adopting -----------------------------
// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without
// a separate adopt first.
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
t.Fatalf("%v %v", out, err)
}
}
// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same
// name (any op) is refused, naming the session that holds it and when it last called.
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
Bound: 10}, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "sess-old") {
t.Fatalf("add from a different session was not refused: %v", err)
}
// list, read-only (review round 2 item c), never refuses: it just says who holds the name.
out, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute))
if err != nil {
t.Fatalf("list from a different session was refused: %v", err)
}
held, _ := out["held-by"].(map[string]any)
if held == nil || held["session"] != "sess-old" {
t.Fatalf("list did not say who holds the name: %v", out)
}
}
// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if out["session"] != "sess-new" || out["open"] != 1 {
t.Fatalf("%v", out)
}
took, _ := out["took-over-from"].(map[string]any)
if took == nil || took["session"] != "sess-old" {
t.Fatalf("the answer did not name the earlier id: %v", out)
}
oldFile, err := readWaitsFile(waitsFilePath(p, "sess-old"))
if err != nil || len(oldFile.Waits) != 0 {
t.Fatalf("the old session's file still holds waits: %+v %v", oldFile, err)
}
newFile, err := readWaitsFile(waitsFilePath(p, "sess-new"))
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != id {
t.Fatalf("the new session's file does not hold the waits: %+v %v", newFile, err)
}
// The new session can act on the name at once: no 10-minute rule.
if _, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute)); err != nil {
t.Fatalf("the new session could not act right after adopting: %v", err)
}
}
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
// no bound protects it.
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
// sess-fresh calls again right before being adopted away from.
if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil {
t.Fatal(err)
}
out, err := AdoptName(p, by, "sess-taker", store, t0.Add(11*time.Second))
if err != nil {
t.Fatalf("adopt was refused even though it always takes over: %v", err)
}
if out["session"] != "sess-taker" {
t.Fatalf("%v", out)
}
}
// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to
// another session, naming it.
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
_, err := AddWait(p, WaitInput{Session: "sess-old", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
Bound: 10}, store, t0.Add(2*time.Minute))
if err == nil || !strings.Contains(err.Error(), "sess-new") || !strings.Contains(err.Error(), "adopt") {
t.Fatalf("the earlier id's next call did not name the new session: %v", err)
}
}
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
// hook to read directly.
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
idx, err := readNodeIndex(nodeIndexPath(p))
if err != nil {
t.Fatal(err)
}
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
idx.Names[0].CurrentSession != "sess-y" {
t.Fatalf("%+v", idx)
}
}
// wantsOnlyWait is the id of the one wait a fixture added, read back through list.
func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string) string {
t.Helper()
out, err := ListWaits(p, session, name+"@"+p.Node, store, t0)
if err != nil {
t.Fatal(err)
}
open := out["open"].([]map[string]any)
if len(open) != 1 {
t.Fatalf("%v", open)
}
return open[0]["id"].(string)
}
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
if isErr || !strings.Contains(text, "sess-old") {
t.Fatalf("adopt: %v %s", isErr, text)
}
}
// ---- Review round 2: a session id's mirrored file is the union of every name bound to it -----------------
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
// names' waits in its one mirrored file, not only the name an op last acted on.
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
f, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, w := range f.Waits {
ids[w.ID] = true
}
if len(f.Waits) != 2 || !ids[idX] || !ids[idY] {
t.Fatalf("the session's file does not hold both names' waits: %+v", f)
}
}
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
// file holding the other name's waits — it is never emptied wholesale.
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
sharedFile, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
if err != nil || len(sharedFile.Waits) != 1 || sharedFile.Waits[0].ID != idX {
t.Fatalf("sess-shared should still hold fix-x's wait: %+v %v", sharedFile, err)
}
newFile, err := readWaitsFile(waitsFilePath(p, "sess-y-new"))
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != idY {
t.Fatalf("sess-y-new should hold fix-y's wait: %+v %v", newFile, err)
}
}
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
// fresh three-strikes count starts under the new binding.
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
waitsDir := WaitsDirPath(p)
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
t.Fatal(err)
}
if err := writeRefusalCounters(waitsDir, "sess-new", map[string]int{"w-2": 3}); err != nil {
t.Fatal(err)
}
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if c := readRefusalCounters(waitsDir, "sess-old"); len(c) != 0 {
t.Errorf("sess-old's counter was not cleared: %v", c)
}
if c := readRefusalCounters(waitsDir, "sess-new"); len(c) != 0 {
t.Errorf("sess-new's counter was not cleared: %v", c)
}
}
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
// mismatched session; it answers the name's waits and who currently holds it.
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
// succeeds, as the first caller for an unbound name).
out, err := ListWaits(p, "sess-reader", by, store, t0)
if err != nil || len(out["open"].([]map[string]any)) != 0 {
t.Fatalf("an unbound name: %v %v", out, err)
}
if _, found, _ := readSessionBinding(store, waitsKey(p, "fix-1629")); found {
t.Fatal("list bound an unbound name")
}
if _, err := AddWait(p, WaitInput{Session: "sess-first", By: by, What: "x", Read: WaitRead{Address: "mesh-delivery.recheck"},
Bound: 10}, store, t0); err != nil {
t.Fatalf("add after a read-only list: %v", err)
}
// A name bound to another session: list still answers (never refused), and says who holds it.
out, err = ListWaits(p, "sess-other", by, store, t0.Add(time.Minute))
if err != nil {
t.Fatalf("list for a name held by another session was refused: %v", err)
}
if len(out["open"].([]map[string]any)) != 1 {
t.Fatalf("list did not answer the name's waits: %v", out)
}
held, _ := out["held-by"].(map[string]any)
if held == nil || held["session"] != "sess-first" {
t.Fatalf("list did not say who holds the name: %v", out)
}
}
// readSessionBinding is a thin test helper reading the raw NamedWaits at key, without claiming it.
func readSessionBinding(store WaitStore, key string) (NamedWaits, bool, error) {
nw, _, found, err := readNamedWaits(store, key)
return nw, found, err
}
+1 -3
View File
@@ -28,8 +28,7 @@
"config",
"proposals",
"questions",
"memory",
"waits"
"memory"
],
"reads": [
"claude-licence-manager.bindings"
@@ -68,7 +67,6 @@
"claude_code_proposals",
"claude_code_ask",
"claude_code_answer",
"claude_code_wait",
"claude_code_settings_get",
"claude_code_settings_set",
"claude_code_settings_clear",
@@ -1,7 +1,6 @@
package main
import (
"bufio"
"context"
"net"
"os"
@@ -171,33 +170,20 @@ func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T)
store := dir + "/cm/clipmenu.6.op"
os.MkdirAll(store, 0o700)
lock := store + "/session_lock"
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed. It says "held" once it has the
// lock, so the test goes on only when the lock is held for certain: a holder that is slow to start (the checker
// runs thousands of tests at once) must not let the launcher find the lock free (issue 1814).
holder := exec.Command("flock", "-x", "-n", lock, "sh", "-c", "echo held; exec sleep 60")
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
held, err := holder.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := holder.Start(); err != nil {
t.Fatal(err)
}
// flock's child shares the lock, so the whole group goes
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
defer release()
said := make(chan string, 1)
go func() {
line, _ := bufio.NewReader(held).ReadString('\n')
said <- strings.TrimSpace(line)
}()
select {
case line := <-said:
if line != "held" {
t.Fatalf("the older clipmenud did not take the lock: %q", line)
for i := 0; i < 50; i++ {
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
break
}
case <-time.After(30 * time.Second):
t.Fatal("the older clipmenud did not take the lock within 30 s")
time.Sleep(50 * time.Millisecond)
}
var out syncBuffer
cmd := exec.Command("sh", file)
+1 -3
View File
@@ -24,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
## Tools
All six only read.
All five only read.
| tool | what | replaces |
|---|---|---|
@@ -32,7 +32,6 @@ All six only read.
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
| `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_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 | `top`, `systemd-cgtop` |
### How each number is read
@@ -88,5 +87,4 @@ cover:
- processes ranked, one that started inside the window left out, unreadable ones counted;
- filesystems said once, mount escapes undone, a statfs that fails said;
- the window's bounds;
- folders ranked biggest first to the depth asked, a mount never entered, unreadable folders named, a walk past its bound answered as partial with where it stopped, a hard link counted once;
- the manifest: tools listed equal tools served, all read-only, no resources, no machine named.
-197
View File
@@ -1,197 +0,0 @@
package main
import (
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
// FolderBudget is how long a folder walk may take before it answers what it has measured.
const FolderBudget = 25 * time.Second
// namedUnreadable is how many unreadable folders are named; the rest are only counted.
const namedUnreadable = 50
// Folder is one folder's size: everything under it on the same filesystem.
type Folder struct {
Path string `json:"path"`
Depth int `json:"depth"`
Bytes int64 `json:"bytes"`
GiB float64 `json:"gib"`
}
// FolderReport is the answer of disk_folders.
type FolderReport struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
GiB float64 `json:"gib"`
Largest []Folder `json:"largest"`
Partial bool `json:"partial"`
StoppedAt string `json:"stopped_at,omitempty"`
Unreadable int `json:"unreadable"`
UnreadableFolders []string `json:"unreadable_folders,omitempty"`
Skipped []string `json:"other_filesystems_skipped,omitempty"`
Said []string `json:"said"`
}
func (m *Machine) readDir(real string) ([]os.DirEntry, error) {
if m.ReadDir != nil {
return m.ReadDir(real)
}
return os.ReadDir(real)
}
func (m *Machine) now() time.Time {
if m.Clock != nil {
return m.Clock()
}
return time.Now()
}
func devOf(path string, info fs.FileInfo) uint64 {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return uint64(st.Dev)
}
return 0
}
type inodeKey struct{ dev, ino uint64 }
// walk is one folder measurement.
type walk struct {
m *Machine
maxDepth int
dev uint64
deadline time.Time
seen map[inodeKey]bool
folders []Folder
bad []string
skipped []string
stopped string
}
// Folders measures path like du -x: allocated bytes, a hard link once, never entering another filesystem.
func (m *Machine) Folders(path string, depth, limit int) (FolderReport, error) {
if !filepath.IsAbs(path) {
return FolderReport{}, fmt.Errorf("path must be absolute, not %q", path)
}
path = filepath.Clean(path)
info, err := os.Stat(m.path(path))
if err != nil {
return FolderReport{}, fmt.Errorf("path %s: %w", path, err)
}
if !info.IsDir() {
return FolderReport{}, fmt.Errorf("path %s is not a folder", path)
}
budget := m.Budget
if budget == 0 {
budget = FolderBudget
}
w := &walk{m: m, maxDepth: depth, deadline: m.now().Add(budget), seen: map[inodeKey]bool{}}
w.dev = w.device(path, info)
total := w.dir(path, 0)
sort.SliceStable(w.folders, func(i, j int) bool {
if w.folders[i].Bytes != w.folders[j].Bytes {
return w.folders[i].Bytes > w.folders[j].Bytes
}
return w.folders[i].Path < w.folders[j].Path
})
if len(w.folders) > limit {
w.folders = w.folders[:limit]
}
sort.Strings(w.bad)
named := w.bad
if len(named) > namedUnreadable {
named = named[:namedUnreadable]
}
r := FolderReport{
Path: path, Bytes: total, GiB: gibOf(total), Largest: w.folders,
Partial: w.stopped != "", StoppedAt: w.stopped,
Unreadable: len(w.bad), UnreadableFolders: named, Skipped: w.skipped,
}
if r.Largest == nil {
r.Largest = []Folder{}
}
var said []string
if r.Partial {
said = append(said, fmt.Sprintf("PARTIAL: the walk stopped after %s at %s; sizes are what was measured so far", budget, r.StoppedAt))
}
said = append(said, fmt.Sprintf("%s holds %.1f GiB on its own filesystem", path, r.GiB))
if r.Unreadable > 0 {
said = append(said, fmt.Sprintf("%d folders could not be read (%s), so their size is missing", r.Unreadable, strings.Join(named, ", ")))
}
if len(r.Skipped) > 0 {
said = append(said, "other filesystems not entered: "+strings.Join(r.Skipped, ", "))
}
r.Said = said
return r, nil
}
func (w *walk) device(path string, info fs.FileInfo) uint64 {
if w.m.DevOf != nil {
return w.m.DevOf(path, info)
}
return devOf(path, info)
}
// dir is the allocated bytes under a folder; it records the folder when it is within the depth.
func (w *walk) dir(path string, depth int) int64 {
if w.stopped != "" {
return 0
}
if w.m.now().After(w.deadline) {
w.stopped = path
return 0
}
entries, err := w.m.readDir(w.m.path(path))
if err != nil {
w.bad = append(w.bad, path)
return 0
}
var total int64
for _, e := range entries {
child := filepath.Join(path, e.Name())
info, err := os.Lstat(w.m.path(child))
if err != nil {
continue // gone since it was listed
}
if info.IsDir() {
if w.device(child, info) != w.dev {
w.skipped = append(w.skipped, child)
continue
}
total += w.allocated(info, false) + w.dir(child, depth+1)
continue
}
total += w.allocated(info, true)
}
if depth >= 1 && depth <= w.maxDepth {
w.folders = append(w.folders, Folder{Path: path, Depth: depth, Bytes: total, GiB: gibOf(total)})
}
return total
}
// allocated is the bytes an entry occupies on disk, as du counts them; a file with several links once.
func (w *walk) allocated(info fs.FileInfo, file bool) int64 {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return info.Size()
}
if file && st.Nlink > 1 {
k := inodeKey{uint64(st.Dev), uint64(st.Ino)}
if w.seen[k] {
return 0
}
w.seen[k] = true
}
return st.Blocks * 512
}
func gibOf(b int64) float64 { return math.Round(float64(b)/gib*100) / 100 }
@@ -1,170 +0,0 @@
package main
import (
"fmt"
"io/fs"
"os"
"strings"
"testing"
"time"
)
const mb = 1 << 20
// megabytes writes a file of n MiB under the fake root.
func (f *fake) megabytes(path string, n int) { f.file(path, strings.Repeat("x", n*mb)) }
func aFullDisk(t *testing.T) *fake {
f := newFake(t)
f.megabytes("var/lib/docker/overlay2/a/layer", 8)
f.megabytes("var/lib/docker/overlay2/b/layer", 4)
f.megabytes("var/log/big.log", 3)
f.megabytes("home/jo/video.mkv", 6)
f.megabytes("home/jo/deep/er/est/file", 1)
f.dir("empty")
return f
}
func folders(t *testing.T, m *Machine, args map[string]any) FolderReport {
t.Helper()
out, err := tool(t, m, "disk_folders").Run(args)
if err != nil {
t.Fatal(err)
}
return out.(FolderReport)
}
func paths(r FolderReport) string {
var p []string
for _, f := range r.Largest {
p = append(p, f.Path)
}
return strings.Join(p, ",")
}
// 1804/AC1: given a disk with folders of known sizes, when disk_folders runs with the defaults, then it answers
// the largest folders to depth 2, biggest first, with bytes and GiB, and the total of the path.
func TestFoldersAreRankedBiggestFirstToDepthTwoByDefault(t *testing.T) {
r := folders(t, aFullDisk(t).machine(), nil)
if got := paths(r); !strings.HasPrefix(got, "/var,/var/lib,") || !strings.Contains(got, "/home,/home/jo") {
t.Fatalf("ranked %s", got)
}
if r.Path != "/" || r.Partial || r.Unreadable != 0 {
t.Errorf("%+v", r)
}
top := r.Largest[0]
if top.Path != "/var" || top.Depth != 1 || top.Bytes < 15*mb || top.Bytes > 16*mb || top.GiB <= 0 {
t.Errorf("top %+v", top)
}
for _, f := range r.Largest {
if f.Depth > 2 || f.Depth < 1 {
t.Errorf("%+v is outside depth 2", f)
}
if strings.Contains(f.Path, "overlay2") {
t.Errorf("%s is deeper than 2", f.Path)
}
}
if r.Bytes < 22*mb || r.Bytes > 24*mb {
t.Errorf("total %d", r.Bytes)
}
}
// 1804/AC2: given a path, a depth and a limit, then only that folder is measured, depth is held at 3 and limit at 100.
func TestFoldersTakeAPathADepthAndALimitHeldAtThreeAndAHundred(t *testing.T) {
f := aFullDisk(t)
r := folders(t, f.machine(), map[string]any{"path": "/var/lib/docker", "depth": 1.0})
if r.Path != "/var/lib/docker" || paths(r) != "/var/lib/docker/overlay2" {
t.Errorf("%s: %s", r.Path, paths(r))
}
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0, "limit": 2.0})
if len(r.Largest) != 2 {
t.Errorf("limit 2 gave %d", len(r.Largest))
}
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0})
if got := paths(r); !strings.Contains(got, "/home/jo/deep/er") || strings.Contains(got, "/home/jo/deep/er/est") {
t.Errorf("depth 3 under /home is /home/jo/deep/er: %s", got)
}
tl := tool(t, f.machine(), "disk_folders")
for _, bad := range []map[string]any{{"depth": 0.0}, {"limit": 0.0}, {"depth": "deep"}, {"path": "relative/dir"}, {"path": "/nope"}, {"path": "/var/log/big.log"}} {
if _, err := tl.Run(bad); err == nil {
t.Errorf("%v was accepted", bad)
}
}
for i := 0; i < 120; i++ {
f.file(fmt.Sprintf("many/d%03d/f", i), "x")
}
if r := folders(t, f.machine(), map[string]any{"path": "/many", "limit": 500.0}); len(r.Largest) != 100 {
t.Errorf("limit held at %d, not 100", len(r.Largest))
}
}
// 1804/AC3: given a mount below the path, then it is never entered or counted, and is named.
func TestFoldersNeverCrossIntoAnotherFilesystem(t *testing.T) {
f := aFullDisk(t)
m := f.machine()
m.DevOf = func(path string, info fs.FileInfo) uint64 {
if path == "/var/lib/docker" {
return 7
}
return 1
}
r := folders(t, m, nil)
if strings.Contains(paths(r), "docker") || len(r.Skipped) != 1 || r.Skipped[0] != "/var/lib/docker" {
t.Errorf("%s, skipped %v", paths(r), r.Skipped)
}
if r.Bytes > 14*mb {
t.Errorf("total %d includes the mount", r.Bytes)
}
}
// 1804/AC4: given folders it cannot read, then they are counted and named and the rest is still answered.
func TestUnreadableFoldersAreCountedAndNamed(t *testing.T) {
f := aFullDisk(t)
m := f.machine()
real := os.ReadDir
m.ReadDir = func(p string) ([]os.DirEntry, error) {
if strings.HasSuffix(p, "/var/log") || strings.HasSuffix(p, "/overlay2/b") {
return nil, fmt.Errorf("open %s: permission denied", p)
}
return real(p)
}
r := folders(t, m, map[string]any{"depth": 3.0})
if r.Unreadable != 2 || strings.Join(r.UnreadableFolders, ",") != "/var/lib/docker/overlay2/b,/var/log" {
t.Errorf("%d: %v", r.Unreadable, r.UnreadableFolders)
}
if r.Partial || !strings.Contains(strings.Join(r.Said, "\n"), "2 folders could not be read") {
t.Errorf("%+v", r)
}
}
// 1804/AC5: given a walk that outlasts its bound, then it answers what it measured, partial, with where it stopped.
func TestALongWalkAnswersPartialAndSaysWhereItStopped(t *testing.T) {
f := aFullDisk(t)
m := f.machine()
now := time.Unix(0, 0)
m.Clock = func() time.Time { now = now.Add(10 * time.Second); return now }
r := folders(t, m, nil)
if !r.Partial || r.StoppedAt == "" || len(r.Said) < 2 || !strings.HasPrefix(r.Said[0], "PARTIAL") || !strings.Contains(r.Said[0], r.StoppedAt) {
t.Errorf("%+v", r)
}
}
// 1804/AC6: a hard link is counted once, and the tool changes nothing.
func TestAHardLinkIsCountedOnce(t *testing.T) {
f := newFake(t)
f.megabytes("a/big", 5)
if err := os.Link(f.root+"/a/big", f.root+"/a/again"); err != nil {
t.Skip(err)
}
r := folders(t, f.machine(), nil)
if r.Bytes > 6*mb {
t.Errorf("counted twice: %d", r.Bytes)
}
}
// 1804/AC7: the bound sits below the 30 s the tool runner gives a call, so a partial answer reaches its caller.
func TestTheWalkBoundIsBelowTheToolRunnersCallLimit(t *testing.T) {
if FolderBudget >= 30*time.Second {
t.Errorf("the budget %s is not below the 30 s a call gets", FolderBudget)
}
}
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -54,13 +53,6 @@ type Machine struct {
Run Runner
Wait func(time.Duration)
Statfs func(path string) (FSStat, error)
// The folder walk's seams, all optional: the folder reader, the filesystem a path is on, the clock and
// the time the walk may take (60 s when zero).
ReadDir func(path string) ([]os.DirEntry, error)
DevOf func(path string, info fs.FileInfo) uint64
Clock func() time.Time
Budget time.Duration
}
// Here is the machine this process runs on.
-28
View File
@@ -136,34 +136,6 @@ func Tools(m *Machine) []stdio.Tool {
}, nil
},
},
{
Name: "disk_folders",
Description: "Which folders fill a disk, like du -x: the largest folders under a path, measured on disk (allocated bytes, a hard link " +
"once), staying on the path's one filesystem (a mount below it is named, never entered). path defaults to /, depth to 2 (at most 3), " +
"limit to 20 (at most 100). Sizes in bytes and GiB. It reads every folder the tool runner can read, none of the content; folders " +
"it cannot read are counted and named. It stops after 25 s and answers what it measured so far, marked partial, with where it stopped. " +
"Replaces du. (r)",
Input: map[string]any{
"path": map[string]any{"type": "string", "description": "the absolute folder to measure (default /)"},
"depth": map[string]any{"type": "integer", "description": "how many levels below path to rank (default 2, at most 3)"},
"limit": map[string]any{"type": "integer", "description": "how many folders to answer (default 20, at most 100)"},
},
Run: func(args map[string]any) (any, error) {
depth, err := count(args, "depth", 2, 1, 3)
if err != nil {
return nil, err
}
limit, err := count(args, "limit", 20, 1, 100)
if err != nil {
return nil, err
}
path := text(args, "path")
if path == "" {
path = "/"
}
return m.Folders(path, depth, limit)
},
},
{
Name: "disk_filesystems",
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
-1
View File
@@ -5,7 +5,6 @@
"disk_load",
"disk_top",
"disk_filesystems",
"disk_folders",
"disk_devices",
"cpu_top"
],
-1
View File
@@ -160,7 +160,6 @@ A failure is an error naming how it failed, never an empty answer.
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
| `docker_networks` | r | networks, subnets, and the containers on each |
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
| `docker_volumes_remove_anonymous` | a | leftover anonymous volumes no container mounts in any state (so none the mesh holds), optionally created before a time; each other volume answered with why it stays; none can be named. **A dry run unless `dry_run` is false, which needs `why`. Never forced** (issue 1796). Sizes only when `sizes` is true (docker is slow on very many volumes); at most `limit` (default 200) per call |
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: <what it was>]`** |
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
+8 -132
View File
@@ -944,9 +944,16 @@ func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[st
}
size := map[string]string{}
if sizes {
if size, err = c.volumeSizes(ctx); err != nil {
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
if err != nil {
return nil, err
}
var v struct{ Volumes []map[string]any }
if err := json.Unmarshal([]byte(out), &v); err == nil {
for _, vol := range v.Volumes {
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
}
}
}
vols := []map[string]any{}
if len(lines(names)) > 0 {
@@ -1208,134 +1215,3 @@ func (c *Client) DaemonConfig(ctx context.Context) (map[string]any, error) {
answer["note"] = "keys read only at start take effect at the daemon's next restart; with live-restore on, a restart keeps every container running"
return answer, nil
}
// volumeSizes is every volume's size by name, from docker system df -v, which walks every volume:
// on a machine with a thousand of them it does not answer within a call's bound.
func (c *Client) volumeSizes(ctx context.Context) (map[string]string, error) {
size := map[string]string{}
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
if err != nil {
return nil, err
}
var v struct{ Volumes []map[string]any }
if err := json.Unmarshal([]byte(out), &v); err == nil {
for _, vol := range v.Volumes {
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
}
}
return size, nil
}
// RemoveVolumesAsk is what docker_volumes_remove_anonymous is asked to take.
type RemoveVolumesAsk struct {
DryRun bool
Why string
CreatedBefore *time.Time
Sizes bool
Limit int // how many to remove in one call; 0 is 200
}
// RemoveAnonymousVolumes removes the leftover anonymous volumes (novox/hq issue 1796): those docker
// labels anonymous, which no container mounts in any state, so none a mesh-held container holds. The
// classification is Volumes' own, not a copy. Every other volume is answered with why it stays. Each
// is removed by its own `docker volume rm`, never forced and never `volume prune`, so one the daemon
// refuses is reported and the rest still go.
func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) (map[string]any, error) {
if !a.DryRun && strings.TrimSpace(a.Why) == "" {
return nil, fmt.Errorf("why is required when dry_run is false")
}
all, err := c.Volumes(ctx, false, false)
if err != nil {
return nil, err
}
candidates := []string{}
skipped := []map[string]any{}
skip := func(name, why string) { skipped = append(skipped, map[string]any{"name": name, "why": why}) }
for _, v := range all["volumes"].([]map[string]any) {
name := fmt.Sprint(v["name"])
switch by := v["mounted_by"].([]map[string]any); {
case v["anonymous"] != true:
skip(name, "not anonymous: a named volume is data")
case v["mesh_held"] == true:
skip(name, "mounted by a container the mesh holds")
case len(by) > 0:
skip(name, fmt.Sprintf("mounted by %v (%v)", by[0]["container"], by[0]["state"]))
case a.CreatedBefore != nil && !volumeOlder(fmt.Sprint(v["created"]), *a.CreatedBefore):
skip(name, "not created before "+a.CreatedBefore.Format(time.RFC3339)+" (created "+fmt.Sprint(v["created"])+")")
default:
candidates = append(candidates, name)
}
}
answer := map[string]any{"dry_run": a.DryRun, "candidates": len(candidates)}
// Sizes are asked for, never needed: the listing walks every volume and may not answer, and
// then the call answers without them. Nothing below depends on it.
sizes := map[string]string{}
if a.Sizes {
if got, err := c.volumeSizes(ctx); err != nil {
answer["sizes_unknown"] = err.Error()
} else {
sizes = got
answer["sizes"] = sizes
}
}
sum := func(names []string) int64 {
var t int64
for _, n := range names {
t += max(Bytes(sizes[n]), 0)
}
return t
}
if a.DryRun {
answer["skipped"] = skipped
answer["would_remove"] = candidates
if len(sizes) > 0 {
answer["would_free_bytes"] = sum(candidates)
}
answer["note"] = "nothing was removed: call again with dry_run false and why to remove them (limit volumes at most per call)"
return answer, nil
}
limit := a.Limit
if limit <= 0 {
limit = 200
}
take := candidates
if len(take) > limit {
take = candidates[:limit]
}
removed := []string{}
// Chunks of a hundred in one `docker volume rm`, so a thousand volumes are ten calls; a chunk
// docker refuses any of is done again one by one, so what it refuses is named.
for i := 0; i < len(take); i += 100 {
chunk := take[i:min(i+100, len(take))]
if _, err := c.docker(ctx, append([]string{"volume", "rm"}, chunk...)...); err == nil {
removed = append(removed, chunk...)
continue
}
for _, name := range chunk {
if _, err := c.docker(ctx, "volume", "rm", name); err != nil {
// The batch took it before it refused another (or before its bound ran out):
// a volume that is no longer there is a removed one.
if strings.Contains(strings.ToLower(err.Error()), "no such volume") {
removed = append(removed, name)
continue
}
skip(name, "docker refused: "+err.Error())
continue
}
removed = append(removed, name)
}
}
answer["skipped"] = skipped
answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why
answer["remaining"] = len(candidates) - len(take)
if len(sizes) > 0 {
answer["freed_bytes"] = sum(removed)
}
return answer, nil
}
// volumeOlder is whether a volume's CreatedAt is before the cutoff; one that cannot be read is not.
func volumeOlder(created string, cutoff time.Time) bool {
t, err := time.Parse(time.RFC3339Nano, created)
return err == nil && t.Before(cutoff)
}
@@ -431,145 +431,3 @@ func TestEventsCoveringTheWholeWindowSayItIsCompleteAndFilterTheTypeHere(t *test
t.Fatalf("a full buffer reaching past the window: %v", got)
}
}
// 1796/AC1: only anonymous volumes nothing mounts go, each answered; a named, a mounted and a too young one stay with why.
func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) {
f := machine().
on("docker volume ls --quiet", Ran{Stdout: "webdata\nanonmounted\nold\nyoung\nnamed\n"}).
on("docker volume inspect", Ran{Stdout: `[
{"Name":"webdata","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
{"Name":"old","Driver":"local","CreatedAt":"2026-09-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
{"Name":"young","Driver":"local","CreatedAt":"2026-10-04T11:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
{"Name":"named","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z"}]`}).
on("docker system df --verbose", Ran{Stdout: `{"Volumes":[{"Name":"old","Size":"1.5MB"},{"Name":"young","Size":"1kB"}]}`}).
on("docker volume rm old", Ran{Stdout: "old\n"})
c := client(f, 1000)
before := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true, CreatedBefore: &before})
if err != nil {
t.Fatal(err)
}
if f.ran("docker volume rm") || !reflect.DeepEqual(got["would_remove"], []string{"old"}) {
t.Fatalf("dry run: %v %+v", got, f.calls)
}
if got["would_free_bytes"] != int64(1500000) || got["sizes"].(map[string]string)["old"] != "1.5MB" {
t.Fatalf("dry run size: %v", got)
}
skipped := got["skipped"].([]map[string]any)
why := map[string]string{}
for _, s := range skipped {
why[s["name"].(string)] = s["why"].(string)
}
if len(why) != 3 || !strings.Contains(why["webdata"], "mesh holds") || why["young"] == "" || why["named"] == "" {
t.Fatalf("skipped: %v", skipped)
}
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "leftovers", Sizes: true, CreatedBefore: &before})
if err != nil {
t.Fatal(err)
}
if !f.ran("docker volume rm old") || got["count"] != 1 || !reflect.DeepEqual(got["removed"], []string{"old"}) {
t.Fatalf("removed: %v %+v", got, f.calls)
}
if got["freed_bytes"] != int64(1500000) {
t.Fatalf("freed: %v", got)
}
for _, c := range f.calls {
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" && (len(c.args) != 3 || c.args[2] != "old") {
t.Errorf("reached too far: %v", c.args)
}
}
}
// 1796/AC2: a volume that is mounted by a mesh-held container is never removed; the tool takes no name.
func TestRemoveAnonymousVolumesRefusesANamedVolumeAndKeepsWhatTheMeshHolds(t *testing.T) {
for _, tool := range tools(client(&fake{}, 1000)) {
if tool.Name != "docker_volumes_remove_anonymous" {
continue
}
for _, k := range []string{"name", "names", "volume", "volumes"} {
if _, err := tool.Run(map[string]any{k: "x", "dry_run": false, "why": "w"}); err == nil {
t.Errorf("%s accepted", k)
}
}
if _, err := tool.Run(map[string]any{"dry_run": false}); err == nil {
t.Error("a removal without why was accepted")
}
if _, err := tool.Run(map[string]any{"created_before": "yesterday"}); err == nil {
t.Error("a bad time was accepted")
}
return
}
t.Fatal("tool not served")
}
// 1796/AC3: on a machine with a very large set of volumes docker's size listing does not answer in
// time; the tool still answers, and removal never depends on it.
func TestRemoveAnonymousVolumesAnswersWhenTheSizeListingDoesNot(t *testing.T) {
f := (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{}).
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
on("docker system df", Ran{Status: 124, Err: "no answer within 20 s"}).
on("docker volume rm", Ran{Stdout: "ok\n"})
c := client(f, 1000)
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true})
if err != nil || f.ran("docker system df") || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) {
t.Fatalf("default dry run: %v %v %+v", got, err, f.calls)
}
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true})
if err != nil || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) || got["sizes_unknown"] == nil {
t.Fatalf("sizes asked, df silent: %v %v", got, err)
}
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Sizes: true, Limit: 2})
if err != nil || got["count"] != 2 || got["remaining"] != 1 || !f.ran("docker volume rm a1 a2") {
t.Fatalf("removal: %v %v %+v", got, err, f.calls)
}
}
// 1796/AC4: a batch docker half-did is settled by name: what is already gone counts as removed, what is in use stays with why.
func TestRemoveAnonymousVolumesCountsWhatAFailedBatchAlreadyRemoved(t *testing.T) {
f := (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{}).
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
on("docker volume rm a1 a2 a3", Ran{Status: 1, Stdout: "a1\n", Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
on("docker volume rm a1", Ran{Status: 1, Stderr: "Error response from daemon: get a1: no such volume\n"}).
on("docker volume rm a2", Ran{Status: 1, Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
on("docker volume rm a3", Ran{Stdout: "a3\n"})
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w"})
if err != nil {
t.Fatal(err)
}
sk := got["skipped"].([]map[string]any)
if !reflect.DeepEqual(got["removed"], []string{"a1", "a3"}) || got["count"] != 2 || len(sk) != 1 || sk[0]["name"] != "a2" || !strings.Contains(sk[0]["why"].(string), "in use") {
t.Fatalf("%v", got)
}
}
// 1796/AC5: removal goes in batches of a hundred names.
func TestRemoveAnonymousVolumesRemovesInBatchesOfAHundred(t *testing.T) {
names, entries := []string{}, []string{}
for i := 0; i < 250; i++ {
n := fmt.Sprintf("v%03d", i)
names = append(names, n)
entries = append(entries, `{"Name":"`+n+`","Labels":{"com.docker.volume.anonymous":""}}`)
}
f := (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{}).
on("docker volume ls --quiet", Ran{Stdout: strings.Join(names, "\n")}).
on("docker volume inspect", Ran{Stdout: "[" + strings.Join(entries, ",") + "]"}).
on("docker volume rm", Ran{Stdout: "ok\n"})
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Limit: 250})
if err != nil || got["count"] != 250 {
t.Fatalf("%v %v", got, err)
}
sizes := []int{}
for _, c := range f.calls {
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" {
sizes = append(sizes, len(c.args)-2)
}
}
if !reflect.DeepEqual(sizes, []int{100, 100, 50}) {
t.Fatalf("batches %v", sizes)
}
}
-35
View File
@@ -11,7 +11,6 @@ import (
"math"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -287,40 +286,6 @@ func tools(c *Client) []stdio.Tool {
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
},
},
{
Name: "docker_volumes_remove_anonymous",
Description: "Remove the leftover anonymous volumes (novox/hq issue 1796): only a volume docker labels anonymous (as docker_volumes says), which no container mounts in any state " +
"and so none the mesh holds, and with created_before only one created before it. Every other volume is answered with why it stays; a named volume is data and is never taken, " +
"and no volume can be named. Each is removed on its own, never forced; the answer has the count and names removed. With sizes true the answer has each volume's size and the bytes freed; docker's size listing is slow on many volumes, so it is asked for, and when it does not answer the call answers without it. At most limit are removed per call, in batches. " +
"A dry run by default; dry_run false needs why. Replaces docker volume rm and docker volume prune. (a)",
Input: map[string]any{
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
"why": map[string]any{"type": "string", "description": "why: required when dry_run is false"},
"created_before": map[string]any{"type": "string", "description": "RFC 3339 time: only volumes created before it (default: any age)"},
"sizes": map[string]any{"type": "boolean", "description": "also read each volume's size and the bytes freed (default false: docker walks every volume for it and on a machine with very many may not answer; the call then answers without sizes)"},
"limit": map[string]any{"type": "integer", "description": "how many to remove in one call (default 200, at most 2000); the answer says how many remain, call again for them"},
},
Run: func(args map[string]any) (any, error) {
for _, k := range []string{"name", "names", "volume", "volumes"} {
if _, ok := args[k]; ok {
return nil, fmt.Errorf("%s: this tool takes no volume by name; it removes only unmounted anonymous volumes", k)
}
}
limit, err := bounded(args, "limit", 200, 2000)
if err != nil {
return nil, err
}
ask := RemoveVolumesAsk{DryRun: flag(args, "dry_run", true), Why: optional(args, "why"), Sizes: flag(args, "sizes", false), Limit: limit}
if s := optional(args, "created_before"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil, fmt.Errorf("created_before must be an RFC 3339 time such as 2026-10-01T00:00:00Z")
}
ask.CreatedBefore = &t
}
return c.RemoveAnonymousVolumes(ctx, ask)
},
},
{
Name: "docker_events",
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
-1
View File
@@ -33,7 +33,6 @@
"docker_disk_usage",
"docker_networks",
"docker_volumes",
"docker_volumes_remove_anonymous",
"docker_events",
"docker_daemon_config",
"docker_unlabelled",
+9 -15
View File
@@ -22,18 +22,16 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
minutes, or when the search fails.
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's
`mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
answered — a required check pending, or never set beside one that is — is said as a stalled line in the state
`unanswered` once that has lasted the delivery table's bound for a check asked and not answered, an hour, counted
from its first sight and again only when a check is asked anew (issue 438).
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
was one, and the announcer it fixes never saw it — is announced within a pass of the next sweep at the latest, its
`mesh/check` is the checker's to set, and `mesh-controller.conditions` names none for it. (Since issue 1301 this
module writes `mesh/merge-gate` only where a base still requires it, and none does once the forge requires
`mesh/check`.)
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
at the latest, and `mesh-controller.conditions` names none for it.
- **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a
misnamed argument sent `pulls/NaN`.
@@ -156,19 +154,15 @@ listed repository:
naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit
tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on
a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation.
requiring `mesh/repo-check` and `mesh/merge-gate`, and only then is the repository moved into the organisation.
There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge
list a user without access to the repository, and under the setup account `mesh-tools` has none;
- one found still under the setup account is finished: the steps it lacks are done, main is never written twice;
- one in the organisation is only put right: main's rule narrowed, `mesh/check` required in place of
`mesh/merge-gate` and `mesh/repo-check`, and every other status already there kept. A main that requires
`mesh/repo-check` without `mesh/merge-gate` is judged by its own script alone (hq, ADR 0503 §5, until issue 1296)
and keeps it; `mesh/check` is never dropped.
- one in the organisation is only put right: main's rule narrowed, a missing one of the two statuses added and every
status already there kept.
Every other repository of the organisation whose main requires `mesh/merge-gate` has the two old statuses swapped
for `mesh/check`, and only its statuses (issue 1301): the switch reaches every repository the merge gate judged, with
no list kept by hand. A step the forge refuses stops that repository, and the others go on. Beyond that swap, a
repository taken out of the value is never changed: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
A step the forge refuses stops that repository, and the others go on. A repository taken out of the value is never
asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
`repositories-outcome.json`.
**How a refusal is seen.** `repositories sync` is a run-once process, declared as the module's last resource, so
-44
View File
@@ -1,44 +0,0 @@
// **A consumer that cannot be bound yet is bound again, never fatal** (novox/hq issue 1793). When the tool runtime
// starts this bundle, the runtime's answer to "bind this module's consumer" can be "no responders" for a few seconds (the
// runtime restarted by a delivery, the bus not yet serving the stream). That rejection, left unhandled at the top of
// index.ts, ended the bundle: nothing heard `mesh-controller.checked` again, and a verdict that landed later never
// reached the forge's statuses. A bind that fails is said and asked again with a growing wait until it holds.
/** How long to wait before the nth new attempt (1-based): 2 s, 5 s, 10 s, then every 30 s. */
export function bindWait(attempt: number): number {
return [2_000, 5_000, 10_000][attempt - 1] ?? 30_000;
}
/**
* Binds with `bind`, asking again after each failure, until it holds. `sleep` is the wait; `say` how a failure is said.
* Never rejects; resolves once the bind holds.
*/
export async function bindUntilHeld(what: string, bind: () => Promise<unknown>,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
say: (line: string) => void = (line) => console.error(line)): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await bind();
if (attempt > 1) say(`[gitea] ${what} is bound after ${attempt} attempts`);
return;
} catch (err) {
const wait = bindWait(attempt);
say(`[gitea] ${what} could not be bound (${err instanceof Error ? err.message : String(err)}); asking again in ${wait / 1000}s`);
await sleep(wait);
}
}
}
/**
* **Starts the binds without making the bundle wait for them** (novox/hq issue 1793). The launcher imports the bundle
* and then answers the runtime's handshake, which the runtime gives 10 s and then kills the bundle without starting it
* again; a bind awaited at the top of the module that stays refused longer ends the bundle all the same. So this
* returns at once: the binds run in the order given, each until it holds, and `done` is called after the last.
*/
export function bindInBackground(binds: [what: string, bind: () => Promise<unknown>][], done: () => void,
sleep?: (ms: number) => Promise<void>, say?: (line: string) => void): void {
void (async () => {
for (const [what, bind] of binds) await bindUntilHeld(what, bind, sleep, say);
done();
})();
}
+2 -17
View File
@@ -533,7 +533,7 @@ 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
* 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. Reads only. */
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>> }> {
async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record<string, string> }> {
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 entries: any[] = [];
@@ -560,22 +560,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}`);
trees[dir] = tree;
}
if (paths === undefined) return { root, trees };
// **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
// naming both, and nothing is answered in part.
const objectOf = new Map<string, string>();
for (const e of entries) if (typeof e?.path === "string" && (e.type === "tree" || e.type === "blob")) objectOf.set(e.path, String(e.sha));
const named: Record<string, Record<string, string>> = {};
for (const [dir, list] of Object.entries(paths)) {
named[dir] = {};
for (const p of list) {
const id = objectOf.get(dir === "" ? p : `${dir}/${p}`);
if (!id) throw new Error(`the path ${p} of ${dir === "" ? "the repository root" : dir} is not in ${owner}/${repo} at ${sha}`);
named[dir][p] = id;
}
}
return { root, trees, paths: named };
return { root, trees };
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -179,7 +179,7 @@ func newTeamRig(t *testing.T) *teamRig {
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, Log: func(string, ...any) {}})
svc.loaded = true
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"], svc.loadedFrom["mesh-build-image"] = true, true, true
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"] = true, true
return &teamRig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
}
@@ -347,7 +347,7 @@ func TestABuildModulesEntryWaitsForItsOwnBuilderKey(t *testing.T) {
}
func TestTheKindOfAModulesKeyAndItsForgeUser(t *testing.T) {
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "mesh-build-image": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
got, err := (&Service{}).valuesForUser("ace", module)
if err != nil || got != want {
t.Errorf("%s: %q, %v; want %q", module, got, err, want)
@@ -38,7 +38,7 @@ func newWatched(t *testing.T) *watched {
r.svc.OnKeys = func(string) { n++ }
r.svc.cfg.Log = func(f string, a ...any) { mu.Lock(); logs = append(logs, fmt.Sprintf(f, a...)); mu.Unlock() }
w := &watched{teamRig: r, sources: map[string]*fakeKeys{}, passes: &n, logs: &logs, mu: mu}
for _, module := range []string{"ssh-client", "mesh-build-go", "mesh-build-node", "mesh-build-image"} {
for _, module := range []string{"ssh-client", "mesh-build-go", "mesh-build-node"} {
w.sources[module] = &fakeKeys{}
if err := r.svc.WatchSource(context.Background(), module, w.sources[module]); err != nil {
t.Fatal(err)
@@ -154,7 +154,7 @@ func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} {
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys"} {
if !slices.Contains(m.Reads, state) {
t.Errorf("gitea's reads %v lack %q", m.Reads, state)
}
@@ -1,215 +0,0 @@
package main
import (
"strings"
"testing"
)
// The tests of issue 1301 (ADR 0447 §1, §5; ADR 0503 §3, §5; lead decisions L4 and L11): main requires `mesh/check`
// alone in place of `mesh/merge-gate` and `mesh/repo-check`; a main that requires `mesh/repo-check` without
// `mesh/merge-gate` (hq) keeps it; every repository of the organisation whose main requires `mesh/merge-gate` is
// switched, listed in the value or not; no other status is dropped.
func narrowedRule(contexts ...any) map[string]any {
return map[string]any{
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{},
"enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
"enable_status_check": true, "status_check_contexts": contexts,
"block_admin_merge_override": true,
}
}
// 1301-S1: a listed repository whose main requires the two old statuses is put right to `mesh/check` in their place,
// every other status kept; a second pass changes nothing.
func TestListedRepositorySwapsTheTwoStatusesForCheck(t *testing.T) {
f := newFake()
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check", "other/x")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"mesh-delivery": {}})
got := strs(repo.Rules[mainRule]["status_check_contexts"])
if strings.Join(got, ",") != "other/x,mesh/check" {
t.Errorf("main requires %v, want other/x and mesh/check", got)
}
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
t.Errorf("outcome %+v, want put right", r)
}
before := len(f.writes())
run(t, s, Value{"mesh-delivery": {}})
if w := f.writes(); len(w) != before {
t.Errorf("a second pass wrote %v", w[before:])
}
}
// 1301-S1: a repository the program makes requires `mesh/check` alone from its first rule in the organisation on.
func TestNewRepositoryRequiresCheckAlone(t *testing.T) {
f := newFake()
s, _ := syncer(t, f)
run(t, s, Value{"fresh": {Description: "d"}})
rule := f.repos["novox/fresh"].Rules[mainRule]
if got := strs(rule["status_check_contexts"]); rule["enable_status_check"] != true || strings.Join(got, ",") != CheckStatus {
t.Errorf("a new repository's main requires %v, want %s alone", got, CheckStatus)
}
if readme := f.repos["novox/fresh"].Files["README.md"]; !strings.Contains(readme, CheckStatus) ||
strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") {
t.Errorf("the README names the statuses of before: %q", readme)
}
}
// 1301-S1: mesh/check is never dropped, and a rule that requires it beside a retired status loses the retired one.
func TestCheckIsKeptAndARetiredStatusBesideItGoes(t *testing.T) {
f := newFake()
both := f.add("novox/both", map[string]map[string]any{mainRule: narrowedRule("mesh/check", "mesh/repo-check")}, "main")
right := f.add("novox/right", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"both": {}, "right": {}})
if got := strs(both.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("both requires %v, want %s alone", got, CheckStatus)
}
if got := strs(right.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("right requires %v", got)
}
if result(o, "right").Did != DidUnchanged || result(o, "both").Did != DidPutRight {
t.Errorf("outcome %+v", o)
}
}
// 1301-S1: a listed repository whose main requires nothing is given mesh/check, not the old two.
func TestListedRuleRequiringNothingGetsCheck(t *testing.T) {
f := newFake()
none := f.add("novox/none", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
s, _ := syncer(t, f)
run(t, s, Value{"none": {}})
if got := strs(none.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("none requires %v, want %s", got, CheckStatus)
}
}
// 1301-S2 (ADR 0503 §5): a main that requires mesh/repo-check without mesh/merge-gate is judged by its own script
// alone (hq): it keeps mesh/repo-check and is not given mesh/check, listed or not.
func TestOwnCheckAloneIsKept(t *testing.T) {
f := newFake()
hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"hq": {}})
for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} {
if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != "mesh/repo-check" {
t.Errorf("%s requires %v, want mesh/repo-check alone", name, got)
}
}
if result(o, "hq").Did != DidUnchanged {
t.Errorf("hq outcome %+v, want unchanged", result(o, "hq"))
}
for _, w := range f.writes() {
t.Errorf("a repository judged by its own check alone was written: %s", w)
}
}
// 1301-S3 (L4, L11): a repository the value does not list whose main requires mesh/merge-gate has the two old
// statuses swapped for mesh/check, and only its statuses: nothing else of its rule is sent. One that requires
// neither, or mesh/repo-check alone, is not written to.
func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
f := newFake()
catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: {
"enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check"},
}}, "main")
toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
f.add("novox/norule", nil, "main")
f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{})
if got := strs(catalog.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("mesh-catalog requires %v, want %s", got, CheckStatus)
}
if catalog.Rules[mainRule]["enable_push"] != true {
t.Errorf("an unlisted repository's rule was narrowed beyond its statuses: %v", catalog.Rules[mainRule])
}
if got := strs(toolsGo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("mesh-tools-go requires %v, want %s", got, CheckStatus)
}
want := []string{
"PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main",
"PATCH /api/v1/repos/novox/mesh-tools-go/branch_protections/main",
}
if got := f.writes(); strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("writes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
for _, n := range []string{"mesh-catalog", "mesh-tools-go"} {
if r := result(o, n); r.Did != DidPutRight {
t.Errorf("%s outcome %+v, want put right", n, r)
}
}
for _, n := range []string{"plain", "hq", "norule", "elsewhere"} {
if r := result(o, n); r.Name != "" {
t.Errorf("%s is in the outcome though nothing was done: %+v", n, r)
}
}
before := len(f.writes())
run(t, s, Value{})
if w := f.writes(); len(w) != before {
t.Errorf("a second pass wrote %v", w[before:])
}
}
// 1301-S3: the organisation's repositories are read page by page, so one past the first page is switched too.
func TestUnlistedRepositoriesAreReadPastTheFirstPage(t *testing.T) {
f := newFake()
f.pageSize = 2
for _, n := range []string{"a", "b", "c", "d", "e"} {
f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check")}, "main")
}
s, _ := syncer(t, f)
run(t, s, Value{})
for _, n := range []string{"a", "b", "c", "d", "e"} {
if got := strs(f.repos["novox/"+n].Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("%s requires %v", n, got)
}
}
}
// 1301-S3: a refused swap is said with the repository and its step, and the pass goes on to the next.
func TestRefusedSwapIsSaid(t *testing.T) {
f := newFake()
f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
f.refuse = func(method, path string) (int, string) {
if method == "PATCH" && strings.Contains(path, "/novox/a/") {
return 403, "no"
}
return 0, ""
}
s, _ := syncer(t, f)
o := run(t, s, Value{})
if r := result(o, "a"); r.Did != DidRefused || r.Step != StepSwap {
t.Errorf("a: %+v, want refused at %s", r, StepSwap)
}
if r := result(o, "b"); r.Did != DidPutRight {
t.Errorf("b: %+v, want put right", r)
}
}
// 1301-S3: the organisation's list refused is said, and the listed repositories are still done.
func TestOrganisationListRefusedIsSaid(t *testing.T) {
f := newFake()
f.refuse = func(method, path string) (int, string) {
if strings.HasPrefix(path, "/api/v1/orgs/") {
return 500, "down"
}
return 0, ""
}
s, _ := syncer(t, f)
o := run(t, s, Value{"x": {}})
if result(o, "x").Did != DidMade {
t.Errorf("x: %+v", result(o, "x"))
}
refusedList := false
for _, r := range o.Refused() {
refusedList = refusedList || r.Step == StepList
}
if !refusedList {
t.Errorf("the refused list is not in the outcome: %+v", o)
}
}
+9 -29
View File
@@ -11,7 +11,6 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
@@ -44,8 +43,6 @@ type fakeForge struct {
calls []string // "METHOD /path"
// refuse answers a call with a status and a message when it returns a status above zero.
refuse func(method, path string) (int, string)
// pageSize is how many repositories one page of the organisation's list holds; 50 when zero.
pageSize int
}
func newFake() *fakeForge { return &fakeForge{repos: map[string]*fRepo{}} }
@@ -87,7 +84,6 @@ var (
branchPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/branches/([^/]+)$`)
filesPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/contents$`)
movePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/transfer$`)
orgRepos = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/repos$`)
)
func strs(v any) []string {
@@ -171,31 +167,6 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case p == "/api/v1/version":
reply(200, map[string]string{"version": "1.27"})
case orgRepos.MatchString(p) && r.Method == http.MethodGet:
owner := orgRepos.FindStringSubmatch(p)[1]
var names []string
for full := range f.repos {
if o, n, _ := strings.Cut(full, "/"); o == owner {
names = append(names, n)
}
}
sort.Strings(names)
size := f.pageSize
if size == 0 {
size = 50
}
if l, _ := strconv.Atoi(r.URL.Query().Get("limit")); l > 0 && l < size {
size = l
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
out := []map[string]any{}
for i := (page - 1) * size; i < len(names) && i < page*size; i++ {
out = append(out, map[string]any{"name": names[i], "full_name": owner + "/" + names[i]})
}
reply(200, out)
case p == "/api/v1/user/repos" && r.Method == http.MethodPost:
name := fmt.Sprint(in["name"])
if f.repos[admin+"/"+name] != nil {
@@ -341,3 +312,12 @@ func result(o Outcome, name string) Result {
}
return Result{}
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
+3 -3
View File
@@ -8,7 +8,7 @@ import "strings"
const MergeCheck = `#!/bin/sh
# mesh-check-toolchain: go
#
# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4):
# This repository's own check, mesh/repo-check (novox/hq ADR 0238 §4), written with its first commit (ADR 0428 §4):
# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration).
# A tree with no Go module has nothing to test, and the check says so and passes.
set -eu
@@ -37,7 +37,7 @@ func FirstCommit(name string, r Repo) map[string]string {
}
readme := "# " + name + "\n\n" + purpose + "\n\n" +
"Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " +
"a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " +
"main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n"
"a change reaches it by a pull request whose `mesh/repo-check` (this repository's `merge-check.sh`) and " +
"`mesh/merge-gate` passed.\n"
return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck}
}
+1 -2
View File
@@ -3,8 +3,7 @@
// again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login,
// makes every listed repository that does not exist under that login's own account, protects its main, writes
// main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has
// its main rule put right, requiring mesh/check in place of the two statuses it replaces (issue 1301). A repository
// the value does not list is touched only to swap those two for mesh/check when its main requires mesh/merge-gate. It keeps what it did, per
// its main rule put right. A repository the value does not list is never touched. It keeps what it did, per
// repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a
// step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once
// step (ADR 0136) reports it and runs it again on every apply until it passes.
@@ -18,7 +18,7 @@ func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
}
// 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its
// statuses kept (the two old ones as mesh/check, issue 1301), and a second pass changes nothing.
// statuses kept, and a second pass changes nothing.
func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
f := newFake()
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: {
@@ -33,8 +33,10 @@ func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
if rule["block_admin_merge_override"] != true {
t.Errorf("the administrator's override is still allowed: %v", rule)
}
if c := strs(rule["status_check_contexts"]); strings.Join(c, ",") != "other/x,mesh/check" {
t.Errorf("statuses %v, want other/x kept and mesh/check for the two it replaces (issue 1301)", c)
for _, c := range []string{"mesh/merge-gate", "mesh/repo-check", "other/x"} {
if !has(strs(rule["status_check_contexts"]), c) {
t.Errorf("a status was dropped: %v", rule["status_check_contexts"])
}
}
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
t.Errorf("outcome %+v, want put right", r)
+28 -126
View File
@@ -26,47 +26,9 @@ const (
MainBranch = "main"
)
// CheckStatus is the one status main requires (ADR 0447 §1, issue 1301): the checker's verdict on a pull request's
// head merged onto its trunk's head. It replaces GateStatus and OwnCheckStatus, and is never dropped.
const CheckStatus = "mesh/check"
// GateStatus and OwnCheckStatus are the two statuses CheckStatus replaces: the controller's merge gate and the
// repository's own merge-check.sh (ADR 0238 §4, ADR 0428 §5 before ADR 0447 §1).
const (
GateStatus = "mesh/merge-gate"
OwnCheckStatus = "mesh/repo-check"
)
// wanted is what a main rule requiring have should require: have with GateStatus and OwnCheckStatus replaced by
// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus with neither
// GateStatus nor CheckStatus is judged by its own script alone, as hq is until issue 1296 settles how hq is
// checked (ADR 0503 §5): the checker writes no status there (ADR 0484 §8), so it is left as it is. This reads the
// rule, not a list of names (lead decision L11 of R4e).
func wanted(have []string) []string {
if !has(have, GateStatus) && !has(have, CheckStatus) && has(have, OwnCheckStatus) {
return have
}
out := []string{}
for _, s := range have {
if s != GateStatus && s != OwnCheckStatus && s != CheckStatus {
out = append(out, s)
}
}
return append(out, CheckStatus)
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
func sameList(a, b []string) bool {
return strings.Join(a, "\x00") == strings.Join(b, "\x00")
}
// RequiredStatuses are what main requires in every listed repository from its first commit (ADR 0428 §5): the
// repository's own check and the module graph's gate. A status already on the rule is kept beside them.
var RequiredStatuses = []string{"mesh/merge-gate", "mesh/repo-check"}
// The steps of one repository, as an outcome names a refused one (ADR 0428 §6).
const (
@@ -80,10 +42,6 @@ const (
// StepRuleAfterMove reads main's rule back once the repository is in the organisation and puts it right: the
// forge drops from a merge whitelist a user without access, and under the setup account the tools' have none.
StepRuleAfterMove = "rule-after-move"
// StepList reads the organisation's repositories, for those the value does not list (issue 1301).
StepList = "list"
// StepSwap swaps the two old statuses for CheckStatus on a repository the value does not list (issue 1301).
StepSwap = "swap"
)
// What a pass did with one repository.
@@ -199,73 +157,9 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
}
out.Repositories = append(out.Repositories, r)
}
for _, r := range s.switchUnlisted(ctx, v) {
if r.Did == DidRefused {
s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said)
} else {
s.say("%s: %s/%s (%s in place of %s and %s)", r.Did, s.Org, r.Name, CheckStatus, GateStatus, OwnCheckStatus)
}
out.Repositories = append(out.Repositories, r)
}
return out, nil
}
// orgPage is how many repositories one read of the organisation's list asks for.
const orgPage = 50
// switchUnlisted swaps the two old statuses for CheckStatus on main's rule of every repository of the organisation
// the value does not list whose main requires GateStatus (issue 1301, lead decisions L4 and L11 of R4e): the
// repositories the merge gate judges are switched in the delivery that has the checker write mesh/check (ADR 0447
// §5), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the
// value does not hold is changed (ADR 0428 §7 for all of it but the statuses the mesh itself requires). A main that
// does not require GateStatus gets no call. It answers only what it did: a repository switched, or refused.
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
var names []string
for page := 1; ; page++ {
var repos []struct {
Name string `json:"name"`
}
q := "?limit=" + fmt.Sprint(orgPage) + "&page=" + fmt.Sprint(page)
if err := s.Forge.GetJSON(ctx, "/api/v1/orgs/"+url.PathEscape(s.Org)+"/repos"+q, &repos); err != nil {
return []Result{refused("(the organisation's repositories)", StepList, err)}
}
for _, r := range repos {
if _, listed := v[r.Name]; !listed && r.Name != "" {
names = append(names, r.Name)
}
}
if len(repos) == 0 || page > 1000 {
break
}
}
sort.Strings(names)
var out []Result
for _, n := range names {
rules, err := s.rules(ctx, s.Org, n)
if err != nil {
out = append(out, refused(n, StepRead, err))
continue
}
var did *Result
for _, r := range rules {
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) {
continue
}
body := map[string]any{"enable_status_check": true, "status_check_contexts": wanted(required(r))}
if err := s.Forge.SendJSON(ctx, http.MethodPatch,
repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), body, nil); err != nil {
did = &Result{Name: n, Did: DidRefused, Step: StepSwap, Said: err.Error()}
break
}
did = &Result{Name: n, Did: DidPutRight}
}
if did != nil {
out = append(out, *did)
}
}
return out
}
// waitForForge is cmd/agent-teams' (teams.go waitForForge): the forge starting with the module answers late.
func (s *Syncer) waitForForge(ctx context.Context) error {
deadline := time.Now().Add(s.WaitFor)
@@ -415,7 +309,7 @@ func strList(v any) []string {
return out
}
// statuses is what the rules require together, as wanted makes it.
// statuses is what a rule requires, with RequiredStatuses added and none dropped.
func statuses(rules ...rule) []string {
seen := map[string]bool{}
var out []string
@@ -426,19 +320,16 @@ func statuses(rules ...rule) []string {
}
}
for _, r := range rules {
for _, s := range required(r) {
add(s)
if r != nil && r["enable_status_check"] == true {
for _, s := range strList(r["status_check_contexts"]) {
add(s)
}
}
}
return wanted(out)
}
// required is what a rule requires: its statuses when it checks them, else none.
func required(r rule) []string {
if r == nil || r["enable_status_check"] != true {
return nil
for _, s := range RequiredStatuses {
add(s)
}
return strList(r["status_check_contexts"])
return out
}
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges,
@@ -453,7 +344,7 @@ func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
}
}
// isRight says whether a rule already is narrowed and requires what wanted makes of it.
// isRight says whether a rule already is narrowed and requires both statuses.
func isRight(r rule, toolsAccount string) bool {
if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true {
return false
@@ -461,15 +352,26 @@ func isRight(r rule, toolsAccount string) bool {
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
return false
}
have := strList(r["status_check_contexts"])
return r["enable_status_check"] == true && len(have) > 0 && sameList(have, wanted(have)) &&
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) &&
r["block_admin_merge_override"] == true
}
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires but
// the two CheckStatus replaces (ADR 0447 §1).
func hasAll(have []string) bool {
for _, want := range RequiredStatuses {
found := false
for _, h := range have {
found = found || h == want
}
if !found {
return false
}
}
return true
}
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires.
// A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts
// ensureMainMerges, issue 948). It takes away no other status and never unprotects (ADR 0428 §5, §7).
// ensureMainMerges, issue 948). It never takes a status away and never unprotects (ADR 0428 §5, §7).
func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) {
rules, err := s.rules(ctx, owner, name)
if err != nil {
@@ -17,8 +17,8 @@ import (
const mainRule = "main"
// 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/check (1124/AC5
// as issue 1301 changes it), and only then moved into the organisation. Nothing reaches the organisation before the
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/repo-check (and
// mesh/merge-gate, 1124/AC5), and only then moved into the organisation. Nothing reaches the organisation before the
// narrowed rule, and nothing else is pushed to main.
func TestNewRepositoryIsMadeInOrder(t *testing.T) {
f := newFake()
@@ -67,8 +67,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule)
}
checks := strs(rule["status_check_contexts"])
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
t.Errorf("main does not require mesh/check alone (1124/AC5, issue 1301): %v", rule)
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
t.Errorf("main does not require mesh/repo-check and mesh/merge-gate (1124/AC5): %v", rule)
}
if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" {
t.Errorf("outcome %+v, want made", r)
@@ -87,7 +87,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
// 1124/AC2: the forge drops from a merge whitelist a user without access to the repository, and under the setup
// account the tools' account has none; so once the repository is in the organisation its main rule is read back and
// put right: the tools' account the only one that merges, mesh/check required.
// put right: the tools' account the only one that merges, both statuses required.
func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
f := newFake()
s, _ := syncer(t, f)
@@ -104,8 +104,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
t.Errorf("after the move main takes a push: %v", rule)
}
checks := strs(rule["status_check_contexts"])
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
t.Errorf("after the move main does not require mesh/check alone: %v", rule)
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
t.Errorf("after the move main does not require both statuses: %v", rule)
}
if r := result(o, "mesh-delivery"); r.Did != DidMade {
t.Errorf("outcome %+v, want made", r)
@@ -136,8 +136,7 @@ func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) {
}
// 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right;
// a rule still letting the setup login push is narrowed, its statuses kept but the two mesh/check replaces (1124/AC5,
// issue 1301).
// a rule still letting the setup login push is narrowed, its statuses kept (1124/AC5).
func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
f := newFake()
hq := f.add("novox/hq", map[string]map[string]any{mainRule: {
@@ -158,11 +157,14 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
t.Errorf("hq's rule not narrowed: %v", rule)
}
checks := strs(rule["status_check_contexts"])
if strings.Join(checks, ",") != "other/x,mesh/check" {
t.Errorf("hq's rule requires %v, want other/x and mesh/check", checks)
for _, c := range []string{"other/x", "mesh/merge-gate", "mesh/repo-check"} {
if !has(checks, c) {
t.Errorf("hq's rule lacks %s: %v", c, checks)
}
}
made := bare.Rules[mainRule]
if made == nil || made["enable_push"] != false || strings.Join(strs(made["status_check_contexts"]), ",") != CheckStatus {
if made == nil || made["enable_push"] != false || !has(strs(made["status_check_contexts"]), "mesh/repo-check") ||
!has(strs(made["status_check_contexts"]), "mesh/merge-gate") {
t.Errorf("bare's rule: %v", made)
}
if hq.Commits != 0 || bare.Commits != 0 {
@@ -212,8 +214,8 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
}
}
// 1124/AC5 as issue 1301 changes it: putting a rule right adds mesh/check when the rule requires mesh/merge-gate,
// and keeps every other status there; a rule requiring mesh/repo-check alone keeps it (ADR 0503 §5).
// 1124/AC5: putting a rule right adds a missing one of mesh/repo-check and mesh/merge-gate and keeps every status
// already there; no pass drops one.
func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
f := newFake()
only := f.add("novox/only", map[string]map[string]any{mainRule: {
@@ -222,15 +224,15 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
}}, "main")
more := f.add("novox/more", map[string]map[string]any{mainRule: {
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
"enable_status_check": true, "status_check_contexts": []any{"mesh/delivery", "mesh/check"},
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"},
"block_admin_merge_override": true, // right since issue 1141
}}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"only": {}, "more": {}})
if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != "mesh/repo-check" {
if c := strs(only.Rules[mainRule]["status_check_contexts"]); !has(c, "mesh/merge-gate") || !has(c, "mesh/repo-check") {
t.Errorf("only: %v", c)
}
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") {
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 3 || !has(c, "mesh/delivery") {
t.Errorf("more lost a status: %v", c)
}
for _, w := range f.writes() {
@@ -312,8 +314,7 @@ func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) {
}
}
// 1124/AC7: a repository taken out of the value is not changed: its rules are read (issue 1301 switches one whose
// main requires mesh/merge-gate), nothing is written, and one still under the setup account is not asked about.
// 1124/AC7: a repository taken out of the value gets no call at all.
func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
f := newFake()
f.add("novox/old", map[string]map[string]any{mainRule: {"enable_push": true}}, "main")
@@ -322,7 +323,7 @@ func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
run(t, s, Value{})
run(t, s, Value{"new": {}})
for _, c := range f.all() {
if strings.Contains(c, "older") || (strings.Contains(c, "old") && !strings.HasPrefix(c, "GET ")) {
if strings.Contains(c, "old") {
t.Errorf("a repository out of the value was asked: %s", c)
}
}
+1 -56
View File
@@ -16,9 +16,6 @@ import { promisify } from "node:util";
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
import type { CommitStatus } from "./client.js";
import { checkName } from "./guard.js";
import { CHECK_STATUS } from "./pulls.js";
export { CHECK_STATUS };
/** How a command is run: docker on the forge's machine, or a test's. */
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
@@ -94,18 +91,11 @@ const states = new Set(["pending", "success", "error", "failure", "warning"]);
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
* bounded description. */
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
// state, as issue 293 closed for the two before it. The forge bridge writes it through gitea_check_status, a tool
// only its account may call (issue 1301).
if (context === CHECK_STATUS) {
throw new Error(`${CHECK_STATUS} is the checker's status: the forge bridge writes it through gitea_check_status, never this tool`);
}
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
// issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes,
@@ -136,7 +126,7 @@ export interface StatusWriters {
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
return {
name: "gitea_commit_status",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: the checker's forge bridge writes it through gitea_check_status. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -163,48 +153,3 @@ export function commitStatusTool(writers: StatusWriters): ToolDefinition {
},
};
}
/** What the check's status tool writes with: the forge. */
export interface CheckStatusWriter {
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
}
/**
* The `gitea_check_status` tool (novox/hq issue 1301, ADR 0447 §1): the one way `mesh/check` is written. Its arguments
* are gitea_commit_status's, so the forge bridge sends an owed status's arguments unchanged to it: owner, repo, sha
* (the head's whole sha), state, description, target_url, and context, which may be left out and is mesh/check when
* given. **Who may write it is the bus's grant**: the runtime tells a tool no caller, so only a module that lists
* `gitea.gitea_check_status` in its manifest's `invokes` may call it, and the forge bridge alone does. Served beside
* gitea_commit_status, from the watcher's bundle.
*/
export function checkStatusTool(writer: CheckStatusWriter): ToolDefinition {
return {
name: "gitea_check_status",
description: "Set mesh/check, the checker's verdict and the one status main requires, on a pull request's head: pending, success, error, failure or warning, a short description, and the page it links to. The checker's forge bridge alone may call it (its manifest's invokes); no other status is written here, and no other tool writes this one.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the head's whole sha" },
context: { type: "string", description: "mesh/check, or left out" },
state: { type: "string", description: "pending | success | error | failure | warning" },
description: { type: "string", description: "one line" },
target_url: { type: "string", description: "the page it links to (the check's report)" },
},
run: async (args) => {
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
if (args.context !== undefined && args.context !== null && String(args.context) !== CHECK_STATUS) {
throw new Error(`${JSON.stringify(args.context)}: this tool writes ${CHECK_STATUS} alone. Nothing was sent`);
}
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: ${CHECK_STATUS} names the head whole. Nothing was sent`);
}
// deliveryStatus checks the state and cuts the description; it refuses mesh/check, so it is asked for a
// context of the mesh's own and the status is named mesh/check after.
const checked = deliveryStatus("mesh/delivery", String(args.state), String(args.description ?? ""),
args.target_url ? String(args.target_url) : undefined);
const status = { ...checked, context: CHECK_STATUS };
await writer.setCommitStatus(owner, repo, sha, status);
return { set: status };
},
};
}
+1 -2
View File
@@ -212,8 +212,7 @@ export class GateWriter {
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;
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;
+1 -1
View File
@@ -3,7 +3,7 @@ module gitea
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.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.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+22 -42
View File
@@ -12,9 +12,7 @@
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
// `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, written by its forge bridge
// through gitea_check_status — everywhere but hq, whose main requires `mesh/repo-check` alone (ADR 0503 §5).
// saying why when the gate is not a pass or the repository's own check failed.
//
// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of,
// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate.
@@ -40,17 +38,15 @@
import { emit, on } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { statesAnnouncement } from "./kept.js";
import { checkStatusTool, commitStatusTool } from "./delivery.js";
import { commitStatusTool } from "./delivery.js";
import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, statusesFor, type Announced, type Checked,
repoCheckFacts, reposToLook } from "./pulls.js";
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindInBackground } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
@@ -250,11 +246,8 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's, and hq's requires
// mesh/repo-check alone; a rule that cannot be read is said pending, as before.
const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined);
if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha);
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
await gates.pending(repo.owner, repo.name, sha);
return true;
}
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
@@ -263,7 +256,7 @@ type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
try {
const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha));
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks || CHECK_STATUS in checks;
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks;
} catch {
return false; // the forge cannot say: announced, and the bus's message id collapses it if it was
}
@@ -400,20 +393,16 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
// (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))) {
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) {
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);
const said = commentFor(c, await repoCheckFacts(client, c, trunk));
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}`);
@@ -422,10 +411,10 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
}
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)) {
for (const status of statusesFor(c, facts, gateNow)) {
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
}
const comment = composedGate(c) ? null : commentFor(c, facts, required);
const comment = composedGate(c) ? null : commentFor(c, facts);
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}`);
}
@@ -483,16 +472,10 @@ const gates = new GateWriter({
const pace = new Pace();
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gates,
}), checkStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
}), mergeTool(gitea, { gates })] : [])]);
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
@@ -607,18 +590,15 @@ if (gitea) {
tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), () => pace.every());
tick(() => pollPulls(client), () => pace.every());
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
// handshake in 10 s, so the import must end while a refused bind is still asked again.
bindInBackground([
["mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
})],
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
await on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
});
await on("node-build-agent.started", buildStarted);
console.log("[gitea] watching for new repositories and merged pull requests");
}
@@ -195,7 +195,7 @@ const BuilderUser = "nox-builder"
// BuildModules are the modules whose keys are the builders' (kind builder), by their name, which is also the name of
// the account each runs as (ADR 0466 §1).
var BuildModules = []string{"mesh-build-go", "mesh-build-node", "mesh-build-image"}
var BuildModules = []string{"mesh-build-go", "mesh-build-node"}
// KindOfModule is the kind of key a consumer module's entry is made for: builder for a build module, machine for any
// other (ssh-client's).
@@ -12,7 +12,7 @@ func TestTheBuildModulesHaveOneForgeUserAndTheMachinesKeepTheirs(t *testing.T) {
if ForgeUser("Not A Node", "builder") != "" {
t.Errorf("a name no account could be named for got a user")
}
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "mesh-build-image": "builder", "ssh-client": "machine", "": "machine"} {
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "ssh-client": "machine", "": "machine"} {
if got := KindOfModule(module); got != want {
t.Errorf("KindOfModule(%q) = %s, want %s", module, got, want)
}
-20
View File
@@ -1,20 +0,0 @@
// The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue
// 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is
// granted what it said (clause 3). A state is readable when another module's manifest reads it; none reads gitea's.
// test/kept.test.ts holds the list to the manifest's `state`.
export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [
{ name: "announced" },
{ name: "bound" },
];
/** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1,
* the module, the machine when known, and the states sorted by name, `readable` only where true. It is said as the event
* `states`, which is the subject mesh.mod.gitea.event.states. gitea says it with the SDK's `emit` because the
* toolchain's SDK, which judges R4e's group, predates the SDK's `keeps` (0.1.15); switch to `keeps` once the toolchain's
* SDK is 0.1.15 or later (issue 1852). */
export function statesAnnouncement(module: string, node: string | undefined) {
const states = [...KEPT]
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
.map((k) => (k.readable ? { name: k.name, readable: true } : { name: k.name }));
return { v: 1, module, ...(node ? { node } : {}), states };
}
+9 -48
View File
@@ -4,20 +4,15 @@
// when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and
// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked.
// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain.
//
// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's,
// whose line names the trunk it judged ("pass <head12> on <trunk12>"); where a base does not require it, the controller's
// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is
// asked: the checker sets it out of date on the trunk's move and checks it again.
import { announce } from "./announce.js";
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
import type { CommitStatus, GiteaClient } from "./client.js";
import { mergeableUnder, newestByContext } from "./checks.js";
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk } 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. */
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
@@ -37,7 +32,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
return {
name: "gitea_merge_pull_request",
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " +
"and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " +
"and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " +
"judged against the trunk's head as it is now, 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 " +
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
@@ -113,56 +108,25 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
// head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes.
const trunkHead = await gitea.branchHead(owner, repo, pull.base);
const checks = await gitea.headChecks(owner, repo, pull.head_sha);
const required = requiredOf(rule) ?? [];
if (required.includes(CHECK_STATUS)) {
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
const check = checks[CHECK_STATUS];
const at = verdictAtTrunk(checks, trunkHead, CHECK_STATUS);
if (!check || check.state === "pending") {
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ` +
`${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` +
`${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`);
}
if (!at.current) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` +
`${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` +
`${pull.base} is now ${twelve(trunkHead)}. The checker checks it again against the trunk's head (novox/hq ` +
`ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`);
}
}
// Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a
// revert), the first it requires judges, as before (ADR 0323 §5).
const kept = maintained(required);
const judge = kept[0] ?? CHECK_CONTEXT;
const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false };
const at = verdictAtTrunk(checks, trunkHead);
if (!at.current) {
// A verdict put out of date by a sibling's move says so in its own words (ADR 0382 §5); it is asked again as a
// trunk's move is, and a failed ask puts those words back, not the trunk's.
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
const siblingWords = siblingOutOfDate(checks[CHECK_CONTEXT]?.description) ? checks[CHECK_CONTEXT]?.description : undefined;
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";
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
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
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
// refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited.
if (waitsForComposed(checks[judge])) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[judge]?.description}; the ` +
`verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` +
`(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`);
}
if (at.onItsWay) {
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[CHECK_CONTEXT]?.description}), so it is not ` +
`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`);
}
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
// announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather
// than wait for ever on a check nobody asked.
// The statuses the base requires of the controller's (issue 1301): hq's mesh/repo-check alone.
const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT]));
const contexts = [CHECK_CONTEXT, ...(checks[REPO_CHECK_CONTEXT] ? [REPO_CHECK_CONTEXT] : [])];
// Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new
// check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older.
const say = async (description: string) => {
@@ -195,12 +159,9 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
// **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6): the
// siblings its verdict names are the holder's record (siblings.ts); a status that says it read siblings with no record
// of them is no verdict to merge on, never read as none.
// Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the
// Approve's heads above; a record the controller's verdicts left before the switch judges nothing there.
const heard = required.includes(CHECK_STATUS) ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
const heard = readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : [];
if (!siblings.length && !required.includes(CHECK_STATUS) &&
namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
if (!siblings.length && namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` +
`here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`);
}
+2 -3
View File
@@ -172,7 +172,7 @@
},
"repositories": {
"type": "object",
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.",
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/repo-check and mesh/merge-gate, and only then moved into the organisation; one that exists only has main's rule put right, its statuses kept. A repository taken out of the value is never touched.",
"trusted": true,
"default": {}
}
@@ -542,7 +542,6 @@
"mesh-controller.assignments",
"ssh-client.keys",
"mesh-build-go.keys",
"mesh-build-node.keys",
"mesh-build-image.keys"
"mesh-build-node.keys"
]
}
+4 -4
View File
@@ -1,6 +1,6 @@
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`,
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) —
// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check —
// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it
// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
import { TOOLS_ACCOUNT } from "./tools-account.js";
@@ -24,7 +24,7 @@ export interface BranchProtection {
/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's
* default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */
export interface ProtectionWanted {
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/check. Empty: none. */
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */
statusChecks: string[];
/** Whether a person may push to the branch directly. */
push?: boolean;
+8 -50
View File
@@ -19,34 +19,6 @@ export const CHECK_CONTEXT = "mesh/merge-gate";
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by its forge bridge
* through gitea_check_status. Its line names the head and the trunk commit it judged: "pass <head12> on <trunk12> …". */
export const CHECK_STATUS = "mesh/check";
/** The statuses a branch's rule requires, from the rule the forge applies to it: none when it is unprotected or checks
* no status; undefined when the rule could not be read. */
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] } | null | undefined): string[] | undefined {
if (rule === undefined) return undefined;
if (!rule || !rule.enable_status_check) return [];
return (rule.status_check_contexts ?? []).filter(Boolean);
}
/**
* **The controller's statuses are written only where a base still requires them** (issue 1301; ADR 0503 §3, §5): of
* mesh/merge-gate and mesh/repo-check, those the base requires — hq's mesh/repo-check alone once the forge requires
* mesh/check everywhere else, so no mesh/merge-gate stands on a head that nothing will set. A base whose rule could not
* be read keeps both, as before: a status nothing requires blocks nothing, and one that is required is never left out.
*/
export function maintained(required: string[] | undefined): string[] {
const both = [CHECK_CONTEXT, REPO_CHECK_CONTEXT];
return required === undefined ? both : both.filter((c) => required.includes(c));
}
/** Whether mesh/merge-gate is written on a head of this base (maintained). */
export function writesGate(required: string[] | undefined): boolean {
return maintained(required).includes(CHECK_CONTEXT);
}
/**
* The pending words a head's merge check is announced with (novox/hq issue 446): where the check is, and since when,
* in the time the operator reads. They stood as "the mesh is mapping this head onto its module graph" for the whole
@@ -254,10 +226,6 @@ 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
* 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";
/** The forge's status for the gate. */
export function statusFor(c: Checked): CommitStatus {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
@@ -285,20 +253,14 @@ 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[] {
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
export function statusesFor(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 });
out.push({ state: "pending", context: CHECK_CONTEXT, description: "waits for its group's composed check" });
}
const repo = c["repo-check"];
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
@@ -326,13 +288,11 @@ function judgedFirst(c: Checked, status: CommitStatus): CommitStatus {
* 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 {
export function commentFor(c: Checked, facts: RepoCheckFacts = {}): 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;
if (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.
@@ -348,11 +308,9 @@ export function commentFor(c: Checked, facts: RepoCheckFacts = {}, required?: st
} 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(", ")}` : "") + ")" : ""));
}
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
-58
View File
@@ -1,58 +0,0 @@
// **A consumer that cannot be bound yet is bound again** (novox/hq issue 1793): the runtime's "no responders" at the
// bundle's start, seen 2026-10-22 00:28:37 on mesh-catalog#427's verdict, ended the bundle and the verdict was never
// written on the forge.
import { test } from "node:test";
import assert from "node:assert/strict";
import { bindInBackground, bindUntilHeld, bindWait } from "../dist/bind.js";
test("a bind refused with no responders is asked again until it holds, and says so", async () => {
let calls = 0;
const waits: number[] = [];
const said: string[] = [];
await bindUntilHeld("mesh-controller.checked", async () => {
calls++;
if (calls < 4) throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request");
}, async (ms) => { waits.push(ms); }, (l) => said.push(l));
assert.equal(calls, 4);
assert.deepEqual(waits, [2_000, 5_000, 10_000]);
assert.equal(said.length, 4);
assert.match(said[0], /no responders available for request.*asking again in 2s/);
assert.match(said[3], /is bound after 4 attempts/);
});
test("a bind that holds the first time says nothing and waits for nothing", async () => {
const said: string[] = [];
await bindUntilHeld("x", async () => {}, async () => assert.fail("waited"), (l) => said.push(l));
assert.deepEqual(said, []);
});
test("the wait grows to 30 s and stays", () => {
assert.deepEqual([1, 2, 3, 4, 50].map(bindWait), [2_000, 5_000, 10_000, 30_000, 30_000]);
});
test("the binds start without being waited for: the caller returns while a bind keeps refusing, and they go in order", async () => {
const order: string[] = [];
let finished = false;
let refusals = 0;
const gate = new Promise<void>((r) => setTimeout(r, 0));
bindInBackground([
["first", async () => { refusals++; if (refusals < 3) throw new Error("nats: no responders available for request"); order.push("first"); }],
["second", async () => { order.push("second"); }],
], () => { finished = true; }, async () => { await gate; }, () => {});
// Returned at once, with the first bind refused and nothing done.
assert.equal(finished, false);
assert.deepEqual(order, []);
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(order, ["first", "second"]);
assert.equal(finished, true);
});
test("a bind that never holds does not stop the caller from returning", () => {
let returned = false;
bindInBackground([["x", async () => { throw new Error("no responders"); }]], () => assert.fail("done"),
() => new Promise(() => {}), () => {});
returned = true;
assert.equal(returned, true);
});
-38
View File
@@ -1,38 +0,0 @@
// **The bundle's import ends while a bind is refused** (novox/hq issue 1793). The launcher imports index.js and then answers
// node-tools' handshake, which has 10 s before the bundle is killed and never started again. A bind awaited at the top
// of index.ts, refused for longer, ended the bundle at 00:28:37 on 2026-10-22. Run in a child: the polls' timers keep a
// process alive, and the child says "imported" and exits as soon as the import resolves.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
test("importing the bundle resolves while the broker keeps refusing the binds", async () => {
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
const script = `
const { useBroker } = await import("@novox/mesh-sdk/messaging");
useBroker(() => ({
subscribe: async () => { throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request"); },
publish: async () => {}, request: async () => { throw new Error("no responders"); },
handle: async () => () => {}, close: async () => {},
}));
await import(${JSON.stringify(index)});
console.log("imported");
process.exit(0);`;
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_URL: "http://127.0.0.1:1" };
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE"]) delete env[k];
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const ended = new Promise<void>((r) => child.on("exit", () => r()));
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
await ended;
clearTimeout(timer);
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
});
-50
View File
@@ -171,53 +171,3 @@ test("the root tree is git's hash of the root's own entries, never the listing's
server.close();
}
});
test("named paths: each asked path's git object id at the commit, a file's or a directory's, by directory (1758 S1, ADR 0499 §3)", 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, { "modules/a": ["main.go", "module.json"], "modules/b": ["c"], "": ["README.md", "modules/d"] });
assert.deepEqual(got.paths, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": { c: h("8") },
"": { "README.md": h("1"), "modules/d": h("a") },
});
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
} finally {
server.close();
}
});
test("a named path the listing lacks fails naming the directory and the path, 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, { "modules/a": ["main.go", "gone.go"] }), (err: Error) => {
assert.match(err.message, /modules\/a/);
assert.match(err.message, /gone\.go/);
return true;
});
for (const bad of [[""], ["../x"], ["/abs"], ["a//b"]]) {
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": bad }), /./, JSON.stringify(bad));
}
} finally {
server.close();
}
});
test("gitea_commit_trees takes paths as {directory: [paths]} 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("paths" in tool.input, "the tool names the argument");
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
const before = asked.length;
for (const paths of ["modules/a", ["main.go"], { "modules/a": "main.go" }, { "modules/a": [1] }]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths } as any), /paths/, JSON.stringify(paths));
}
assert.equal(asked.length, before, "the forge was asked for a refused call");
} finally {
server.close();
}
});
-35
View File
@@ -61,38 +61,3 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check");
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
});
// Issue 1301-S4 (ADR 0447 §1; the lead's choice A): main requires mesh/check, so no hand may set it. The status tool
// every caller may use refuses it in every state; it is written only by gitea_check_status, which the forge bridge
// alone lists in its manifest's `invokes`, so only its account may call it.
test("mesh/check is refused by the status tool and written by the check's own tool alone", async () => {
const { commitStatusTool, checkStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
assert.equal(CHECK_STATUS, "mesh/check");
const sha = "0123456789abcdef0123456789abcdef01234567";
const sent: string[] = [];
const setCommitStatus = async (o: string, r: string, s: string, st: any) =>
void sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
for (const state of ["pending", "success", "failure", "error", "warning"]) {
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
context: "mesh/check", state, description: "by hand" }), /gitea_check_status/);
}
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
const tool = checkStatusTool({ setCommitStatus });
assert.equal(tool.name, "gitea_check_status");
for (const k of ["owner", "repo", "sha", "state", "description", "target_url", "context"]) assert.ok(k in tool.input, k);
// The bridge's arguments as it sends them for gitea_commit_status: the context may be given, and is mesh/check.
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/check", state: "success", description: "pass",
target_url: "https://forge.invalid/novox/mesh-sdk/pulls/1" });
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "pending", description: "y".repeat(300) });
assert.equal(sent[0], "novox/mesh-sdk@01234567 mesh/check success pass https://forge.invalid/novox/mesh-sdk/pulls/1");
assert.ok(sent[1].startsWith("novox/mesh-sdk@01234567 mesh/check pending yyyy"), sent[1]);
assert.ok(sent[1].length < 200, "the description is cut as every status's is");
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/delivery", state: "success" }), /mesh\/check alone/);
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha: sha.slice(0, 12), state: "success" }), /whole sha/);
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "green" }), /not a status/);
await assert.rejects(tool.run({ owner: "../x", repo: "mesh-sdk", sha, state: "success" }));
assert.equal(sent.length, 2, "nothing more was sent");
});
-10
View File
@@ -322,13 +322,3 @@ test("a repository as the mesh clones it is told from another", async () => {
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);
});
+2 -80
View File
@@ -15,7 +15,7 @@ import { GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { mergeTool } from "../dist/merge.js";
import { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
import { commitStatusTool } from "../dist/delivery.js";
import { GateWriter } from "../dist/gate.js";
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
@@ -168,7 +168,7 @@ const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
} };
const tools = [...getGiteaTools(client), commitStatusTool({
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
}), checkStatusTool({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s) }), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
now: () => new Date(2026, 9, 12, 15, 1) })];
const tool = (name: string) => tools.find((t) => t.name === name)!;
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
@@ -339,30 +339,6 @@ test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk
assert.equal(state.emitted.length, 4, "a merge asks no check");
});
// **A group member whose gate waits for its group's composed check is not announced again by the merge tool** (novox/hq issue
// 1106): the member's own check can only say "composed" again, so each refusal's ask brought a verdict that names no trunk and
// the next merge was refused the same way, for ever. The tool says whose verdict is awaited and asks nothing.
test("gitea_merge_pull_request asks no check of a member that waits for its group's composed check", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
state.trunk = TRUNK;
const before = state.emitted.length;
state.statuses = [{ context: "mesh/merge-gate", state: "pending", description: "waits for its group's composed check" },
{ context: "mesh/repo-check", state: "success", description: "pass: y" }];
for (let i = 0; i < 3; i++) {
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /waits for its group's composed check/);
assert.match(err.message, /mesh-delivery asks it, this tool asks no check/);
assert.match(err.message, /Nothing was merged/);
return true;
});
}
assert.equal(state.emitted.length, before, "no check is announced again and again");
assert.equal(state.merged, false);
// Once the composed verdict names the member's unmoved trunk, it merges.
state.statuses = judgedAt(TRUNK);
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
@@ -568,10 +544,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
["gitea_check_status", { ...repo, sha: "abc1234?sudo=jschoubben", state: "success" }],
["gitea_check_status", { ...repo, sha: "../../../admin/users", state: "success" }],
["gitea_check_status", { owner: "novox", repo: "..", sha: "0123456789abcdef0123456789abcdef01234567", state: "success" }],
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
["gitea_commit_statuses", { ...repo, sha: ".." }],
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
@@ -708,53 +680,3 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea
assert.equal(done.merged, true);
assert.equal(state.emitted.length, 0, "no check was asked");
});
// **Issue 1301 (ADR 0447 §1, §4; ADR 0503 §3, §5): the merge tool judges on the status the base requires.** A main that
// requires mesh/check is judged by the checker's line ("pass <head12> on <trunk12> …"): it merges on a pass that names the
// trunk's head, and refuses one that names an older trunk, naming both, writing nothing and asking nothing — a re-check on
// a trunk's move is the checker's. No mesh/merge-gate is read or written there.
test("gitea_merge_pull_request judges a main requiring mesh/check on mesh/check alone, and asks nothing of it", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/check"] }];
const line = (trunk: string, s = "success", words = "pass") =>
[{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }];
const before = state.emitted.length;
state.statuses = line(MOVED);
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /mesh\/check/);
assert.match(err.message, new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`));
assert.match(err.message, /checker checks it again/i);
assert.match(err.message, /Nothing was merged/);
return true;
});
state.statuses = line(TRUNK, "pending", "checking");
await assert.rejects(merge(), /mesh\/check is pending/);
state.statuses = judgedAt(TRUNK); // the old two, current: not what this main requires
await assert.rejects(merge(), /mesh\/check/);
assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's");
assert.equal(state.emitted.length, before, "no check is asked");
assert.equal(state.merged, false);
state.statuses = line(TRUNK);
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
// hq's main requires mesh/repo-check alone (ADR 0503 §5): judged on its words, and an out-of-date one is asked again on
// mesh/repo-check alone; no mesh/merge-gate is written on it.
test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on it, and writes no mesh/merge-gate", async () => {
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
keepSiblings(siblingsFile(stateDir), {}); // no sibling record left by the tests before
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/repo-check"] }];
state.statuses = [judgedAt(MOVED)[1]];
const before = state.emitted.length;
await assert.rejects(merge(), /judged against trunk/);
assert.deepEqual(state.sent.filter((s) => s.path.includes("/statuses/")).map((s) => `${s.body.context} ${s.body.state}`),
["mesh/repo-check pending"]);
assert.equal(state.emitted.length, before + 1, "its check is asked once");
await assert.rejects(merge(), /already asked/);
state.sent = [];
state.statuses = [judgedAt(TRUNK)[1]];
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
-25
View File
@@ -1,25 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { readFileSync } from "node:fs";
// 1790: the states the module announces are the states its manifest declares, no more, no fewer; none is readable
// because no other module's manifest reads one of them.
test("the states the module announces are the states its manifest declares", async () => {
const { KEPT } = await import("../kept.ts");
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
const declared = (manifest.state as (string | { name: string })[]).map((s) => (typeof s === "string" ? s : s.name)).sort();
assert.deepEqual(KEPT.map((k) => k.name).sort(), declared);
assert.ok(KEPT.every((k) => !k.readable), "no other module's manifest reads a state of gitea");
});
test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => {
const { statesAnnouncement } = await import("../kept.ts");
assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound" }] });
assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound" }] });
});
test("index.ts says the announcement as the event `states` before the tools are served", () => {
const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
assert.match(source, /await emit\("states", statesAnnouncement\(/);
assert.ok(source.indexOf('await emit("states"') < source.indexOf('registerModuleTools("gitea"'), "announced before the tools are served");
});
-24
View File
@@ -192,27 +192,3 @@ test("a member's own verdict with a composed gate layer leaves the gate to the c
// 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
// 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 () => {
const { maintained, writesGate, statusesFor, commentFor, CHECK_STATUS } = await import("../pulls.ts");
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(["mesh/repo-check"]), ["mesh/repo-check"]);
assert.deepEqual(maintained(["mesh/check", "other/x"]), []);
assert.deepEqual(maintained([]), []);
assert.equal(writesGate(undefined), true);
assert.equal(writesGate(["mesh/repo-check"]), false);
assert.equal(writesGate(["mesh/check"]), false);
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 ?? "");
});
-18
View File
@@ -199,21 +199,3 @@ test("a release branch's move puts every open pull request into it out of date,
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
});
// Issue 1301: a trunk's move puts out of date only the controller's statuses the base still requires; on hq that is
// mesh/repo-check alone, judged by its own words; on a main requiring mesh/check, none (the checker's, ADR 0447 §4).
test("a trunk's move writes only the statuses the base still requires", async () => {
const { outOfDateStatuses, trunkNamed } = await import("../trunk.ts");
const old = "1".repeat(40), now = "2".repeat(40);
const repo = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: y` };
const gate = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: x` };
assert.deepEqual(outOfDateStatuses({ "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context), ["mesh/repo-check"]);
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context),
["mesh/repo-check"]);
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/check"]), []);
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now).map((s: any) => s.context),
["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
// The checker's line names its trunk as "<head12> on <trunk12>".
assert.equal(trunkNamed(`pass ${"a".repeat(12)} on ${old.slice(0, 12)} | unit 3 run`), old.slice(0, 12));
assert.equal(trunkNamed("building on shanks since 10:00"), undefined);
});
-11
View File
@@ -350,14 +350,3 @@ test("a pull request's edit event carries its description, and no other pull req
assert.equal(eventOf("pull_request", { action, pull_request: pull, repository }).body.body, undefined, action);
}
});
// Issue 1302: a status event carries the status's words, so the delivery gate reads mesh/check's line (the head and the trunk
// it judged) from the event itself.
test("a status event carries its context, state and words", async () => {
const { eventOf } = await import("../webhook.ts");
const e = eventOf("status", { sha: "a".repeat(40), context: "mesh/check", state: "success",
description: "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept", repository: { name: "x", owner: { login: "novox" } } });
assert.equal(e.name, "forge.status");
assert.equal((e.body as any).context, "mesh/check");
assert.equal((e.body as any).description, "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept");
});
+6 -23
View File
@@ -60,22 +60,6 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
}));
}
/** The `paths` argument of gitea_commit_trees: {directory: [path relative to it]}, each path plain (no empty, `.` or `..`
* segment, not absolute); anything else is refused before the forge is asked. Absent is absent. */
function namedPaths(raw: unknown): Record<string, string[]> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("paths is {directory: [path, ...]}");
const out: Record<string, string[]> = {};
for (const [dir, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) throw new Error(`paths of ${dir || "the root"} is a list of paths`);
for (const p of list as string[]) {
if (p === "" || p.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`paths: ${JSON.stringify(p)} of ${dir || "the root"} is not a plain relative path`);
}
out[dir] = list as string[];
}
return out;
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
}
@@ -411,7 +395,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
// ---- Branch protection (novox/hq ADR 0237) ----
{
name: "gitea_branch_protection_get",
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/check), approvals, and whether an administrator may merge past them.",
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -429,12 +413,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_branch_protection_set",
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's checker sets `mesh/check` (ADR 0447), and the repositories program requires it on every main. Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch to protect, e.g. main" },
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/check; empty requires none" },
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" },
push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" },
block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" },
},
@@ -494,7 +478,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_statuses",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -541,19 +525,18 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
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 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). The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
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" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim();
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");
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
return gitea.commitTrees(owner, repo, sha);
},
},
{
+12 -29
View File
@@ -12,17 +12,12 @@
// the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a
// 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
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (hq's
// mesh/repo-check, pulls.ts maintained).
//
// 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 { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
/** A commit as a verdict names it: its first twelve characters. */
export function twelve(sha: string | undefined): string {
@@ -45,9 +40,7 @@ export function judgedWords(head: string, trunk: string): string {
* verdict put out of date, the one it had judged ("judged against trunk …"). Undefined when it names none, as a
* verdict of a build seat from before ADR 0323 does. */
export function trunkNamed(description: string | undefined): string | undefined {
const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? "")
// The checker's mesh/check line (ADR 0447 §3): "<head12> on <trunk12>".
?? /\b[0-9a-f]{12} on ([0-9a-f]{12})\b/i.exec(description ?? "");
const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? "");
return m ? m[1].toLowerCase() : undefined;
}
@@ -65,11 +58,6 @@ export function checkOnItsWay(gate: HeadCheck | undefined): boolean {
return d.startsWith("waiting in the build queue") || d.startsWith("building on ") || d.startsWith("checking again");
}
/** Whether a gate says it waits for its group's composed check. */
export function waitsForComposed(gate: HeadCheck | undefined): boolean {
return !!gate && gate.state === "pending" && (gate.description ?? "").startsWith(WAITS_FOR_COMPOSED);
}
/**
* The statuses a trunk's move puts back to pending on one open pull request's head, from what the forge holds on it
* now: none when it has no merge check yet, when a check is pending (on its way, or already out of date — its
@@ -77,15 +65,15 @@ export function waitsForComposed(gate: HeadCheck | undefined): boolean {
* gate, and the repository's own check when the head has one — a pending repo-check a verdict will not set would
* stand for ever (novox/hq issue 305's rule).
*/
export function outOfDateStatuses(checks: Record<string, HeadCheck>, trunkHead: string, required?: string[]): CommitStatus[] {
// Only the statuses the base still requires (issue 1301): the first of them the head holds judges.
const kept = maintained(required).filter((c) => checks[c]);
const judge = kept.length ? checks[kept[0]] : undefined;
if (!judge || judge.state === "pending") return [];
const judged = trunkNamed(judge.description);
export function outOfDateStatuses(checks: Record<string, HeadCheck>, trunkHead: string): CommitStatus[] {
const gate = checks[CHECK_CONTEXT];
if (!gate || gate.state === "pending") return [];
const judged = trunkNamed(gate.description);
if (judged && sameCommit(judged, trunkHead)) return [];
const description = outOfDateWords(judged, trunkHead);
return kept.map((context) => ({ state: "pending" as const, context, description }));
const out: CommitStatus[] = [{ state: "pending", context: CHECK_CONTEXT, description }];
if (checks[REPO_CHECK_CONTEXT]) out.push({ state: "pending", context: REPO_CHECK_CONTEXT, description });
return out;
}
/** Where the merge tool stands with a head's verdict and the trunk's head (ADR 0323 §5). */
@@ -98,8 +86,8 @@ export interface VerdictAtTrunk {
judged?: string;
}
export function verdictAtTrunk(checks: Record<string, HeadCheck>, trunkHead: string, context = CHECK_CONTEXT): VerdictAtTrunk {
const gate = checks[context];
export function verdictAtTrunk(checks: Record<string, HeadCheck>, trunkHead: string): VerdictAtTrunk {
const gate = checks[CHECK_CONTEXT];
const judged = trunkNamed(gate?.description);
if (checkOnItsWay(gate)) return { current: false, onItsWay: true, judged };
return { current: !!gate && gate.state !== "pending" && !!judged && sameCommit(judged, trunkHead), onItsWay: false, judged };
@@ -126,9 +114,6 @@ export function lateVerdictComment(c: { commit: string; trunk?: string; verdict:
export interface TrunkForge {
listOpenPulls(owner: string, repo: string): Promise<GiteaPull[]>;
branchHead(owner: string, repo: string, branch: string): Promise<string>;
/** The rule the forge applies to a branch: which of the controller's statuses are kept there (issue 1301). Not given,
* or not read: both, as before. */
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] } | null>;
}
/** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */
@@ -157,13 +142,11 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru
if (before && sameCommit(before, head)) continue;
if (before) {
let put = 0;
const required = forge.appliedProtection
? requiredOf(await forge.appliedProtection(owner, name, base).catch(() => undefined)) : undefined;
for (const pull of open.filter((p) => p.base === base)) {
// The trunk is read again in the head's own turn: a verdict judged against a head newer than the pass's first
// read — the trunk moved again meanwhile, and a check answered — is not put out of date.
const written = await gates.outOfDate(owner, name, String(pull.head_sha),
async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base), required), pull.html_url || undefined);
async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base)), pull.html_url || undefined);
if (written.length) put++;
}
console.log(`[gitea] ${owner}/${name}'s ${base} moved from ${twelve(before)} to ${twelve(head)}: ${put} open pull ` +
+1 -3
View File
@@ -127,9 +127,7 @@ function forgeEventOf(said: string, payload: any): ForgeEvent {
return { name: kind, body: { ...base, ref: str(payload.ref), ref_type: str(payload.ref_type), sha: str(payload.sha) } };
}
if (kind === "status") {
// Its words too (issue 1302): mesh/check's line names the head and the trunk it judged, which the delivery gate reads.
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state),
description: str(payload.description) } };
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state) } };
}
const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` :
["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`;
+28 -23
View File
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
@@ -203,37 +204,41 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
}
}
// The window-manager lines the catalogue's modules contribute to the main file (novox/hq ADR 0212) are kept in
// testdata/window-manager-lines.json, one entry per contributing module in module order, as the controller places
// them. The test reads that file, never the other modules' manifests: a module may not read a manifest (ADR 0495
// §1), and a test that reads its siblings' fails wherever they are not beside it (issue 1814). The main file and
// those lines are placed together — each module's under a line naming it — and loaded as i3 would load them on a
// machine with all of them assigned: no two bind one key, and every line parses.
// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in
// the main file as the controller places them — in module order, each module's under a line naming it
// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and
// every line parses.
func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) {
i3, err := exec.LookPath("i3")
if err != nil {
t.Skip("no i3 here to check with")
}
dir := t.TempDir()
raw, err := os.ReadFile(filepath.Join("testdata", "window-manager-lines.json"))
if err != nil {
t.Fatal(err)
}
var contributions []struct {
Module string `json:"module"`
Content []string `json:"content"`
}
if err := json.Unmarshal(raw, &contributions); err != nil {
t.Fatal(err)
}
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
sort.Strings(manifests)
var placed strings.Builder
var found []string
for _, c := range contributions {
placed.WriteString("# " + c.Module + "\n")
found = append(found, c.Module)
for _, content := range c.Content {
placed.WriteString(content)
if !strings.HasSuffix(content, "\n") {
for _, p := range manifests {
raw, _ := os.ReadFile(p)
var m struct {
Module string `json:"module"`
Contributions []struct {
Seat, Kind, Content string
} `json:"contributions"`
}
json.Unmarshal(raw, &m)
named := false
for _, c := range m.Contributions {
if c.Seat != "node-display-session" || c.Kind != "config" {
continue
}
if !named {
placed.WriteString("# " + m.Module + "\n")
named = true
found = append(found, m.Module)
}
placed.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
placed.WriteString("\n")
}
}
@@ -1,50 +0,0 @@
[
{
"module": "asus-zephyrus-g14",
"content": [
"# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-kbd-notify\n# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-touchpad reset\n"
]
},
{
"module": "clipmenu",
"content": [
"# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
]
},
{
"module": "dunst",
"content": [
"# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is\n# chosen from the keyboard as a tap chooses it on the notification.\nbindsym $mod+n exec --no-startup-id dunstctl context\n"
]
},
{
"module": "feh",
"content": [
"# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
]
},
{
"module": "i3status-rust",
"content": [
"# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
]
},
{
"module": "polychromatic",
"content": [
"# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:\n# replaced at every push. This line is the tray's one start, at the session's start (exec, not\n# exec_always, so a reload starts nothing). The package's own login helper starts it too while the\n# application's setting \"Start the tray applet when I log on\" is ticked; polychromatic_check names\n# that as a second start.\nexec --no-startup-id polychromatic-tray-applet\n"
]
},
{
"module": "rofi",
"content": [
"# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n"
]
},
{
"module": "slack",
"content": [
"# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n"
]
}
]
+4 -23
View File
@@ -1,9 +1,8 @@
# kernel
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds two things:
**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and
its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are
not built yet.
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one:
**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot
entries) is not built yet.
## Why (novox/hq issue 746)
@@ -36,34 +35,16 @@ under the new mode. A failed rotation is said in the unit's journal and does not
rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing`
or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit.
## The fallback kernel (novox/hq ADR 0508, issue 1841)
Every machine keeps a second, older kernel to start from, so a kernel upgrade that does not boot is not the
end of the machine. The module declares the packages `linux-lts` and `linux-lts-headers` (so every DKMS module is
built for the fallback kernel too) after its boot-entries service, so the entry is in place before the kernel is.
| | what | notes |
|---|---|---|
| file | `${dir:state}/boot-entries` | the script. It reads from the machine which loader booted it: the EFI variable `LoaderEntrySelected` present means systemd-boot, a `/boot/grub/grub.cfg` without it means GRUB, neither fails the unit loudly |
| | systemd-boot | writes `/boot/loader/entries/mesh-linux-lts.conf` from `/proc/cmdline` without `initrd=` and `BOOT_IMAGE=`, with the microcode images present in `/boot` and the fallback initramfs as initrd lines; `loader.conf` and the default entry are never touched |
| | GRUB | runs `grub-mkconfig -o /boot/grub/grub.cfg` only when the set of `/boot/vmlinuz-*` differs from the kernels the menu names, with `GRUB_TOP_LEVEL` naming the kernel the machine booted (`BOOT_IMAGE`; else the non-fallback kernel), since GRUB lists kernels in reverse version order and would otherwise make the fallback kernel the default; `/etc/default/grub` still overrides it when it sets one |
| | room | while `/boot/vmlinuz-linux-lts` is not there yet, less than 96 MiB free on `/boot` fails the unit and says so (the fallback kernel writes about 72 MB, issue 1836) |
| | a kernel-copying hook | a hook in `/etc/pacman.d/hooks` that the mesh did not write, triggered by kernel images and copying to one fixed file name in `/boot`, would put the fallback kernel in the default kernel's place: it is moved into `${dir:state}/taken-over/` and named in the unit's journal. Each kernel is then placed under its own package's name by the distribution's hooks |
| file | `/etc/systemd/system/mesh-kernel-boot-entries.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/pacman.d/hooks` and the module's state; no home, no network |
| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed |
| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook |
## Tools
| tool | | answers |
|---|---|---|
| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, and the room free on `/boot` in KiB |
| `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) |
## Tests
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
-89
View File
@@ -1,89 +0,0 @@
package main
// kernel_boot: which loader booted the machine, its entries, the kernels in /boot, the entry it booted and
// whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only.
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"syscall"
"unicode/utf16"
)
const loaderSelected = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
// BootState is what kernel_boot answers.
type BootState struct {
Loader string `json:"loader"`
Entries []string `json:"entries"`
Kernels []string `json:"kernels"`
Booted string `json:"booted"`
FallbackPresent bool `json:"fallback_present"`
BootFreeKiB int64 `json:"boot_free_kib"`
}
var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`)
var grubTitle = regexp.MustCompile(`(?m)^\s*menuentry\s+'([^']*)'`)
var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`)
// Boot reads the machine, the loader the way the boot-entries script does: the loader's EFI variable means
// systemd-boot, a grub.cfg without it GRUB.
func (m *Machine) Boot() (*BootState, error) {
b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}}
files, _ := filepath.Glob(m.path("boot/vmlinuz-*"))
for _, f := range files {
b.Kernels = append(b.Kernels, filepath.Base(f))
}
sort.Strings(b.Kernels)
hasKernel := func(name string) bool {
for _, k := range b.Kernels {
if k == name {
return true
}
}
return false
}
var s syscall.Statfs_t
if err := syscall.Statfs(m.path("boot"), &s); err == nil {
b.BootFreeKiB = int64(s.Bavail) * int64(s.Bsize) / 1024
}
if raw, err := os.ReadFile(m.path(loaderSelected)); err == nil {
b.Loader = "systemd-boot"
if len(raw) > 4 {
var u []uint16
for i := 4; i+1 < len(raw); i += 2 {
u = append(u, uint16(raw[i])|uint16(raw[i+1])<<8)
}
b.Booted = strings.TrimRight(string(utf16.Decode(u)), "\x00")
}
files, _ := filepath.Glob(m.path("boot/loader/entries/*.conf"))
for _, f := range files {
b.Entries = append(b.Entries, filepath.Base(f))
}
sort.Strings(b.Entries)
if raw, err := os.ReadFile(m.path("boot/loader/entries/mesh-linux-lts.conf")); err == nil {
b.FallbackPresent = strings.Contains(string(raw), "/vmlinuz-linux-lts") && hasKernel("vmlinuz-linux-lts")
}
return b, nil
}
if raw, err := os.ReadFile(m.path("boot/grub/grub.cfg")); err == nil {
b.Loader = "grub"
for _, t := range grubTitle.FindAllStringSubmatch(string(raw), -1) {
b.Entries = append(b.Entries, t[1])
}
for _, k := range grubKernel.FindAllStringSubmatch(string(raw), -1) {
if k[1] == "vmlinuz-linux-lts" && hasKernel("vmlinuz-linux-lts") {
b.FallbackPresent = true
}
}
if cmd, err := m.read("proc/cmdline"); err == nil {
if i := bootImage.FindStringSubmatch(cmd); i != nil {
b.Booted = i[1]
}
}
}
return b, nil
}
@@ -1,90 +0,0 @@
package main
// kernel_boot over stand-in roots: a systemd-boot machine with and without the fallback entry, a GRUB machine
// with and without the fallback kernel in its menu, and a machine with neither (novox/hq ADR 0508 §9, issue 1841).
import (
"os"
"path/filepath"
"testing"
)
const selectedVar = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
func put(t *testing.T, root, p, content string) {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
write(t, full, content, 0o644)
}
func bootOf(t *testing.T, root string) *BootState {
t.Helper()
got, err := (&Machine{Root: root}).Boot()
if err != nil {
t.Fatal(err)
}
return got
}
func TestKernelBootReadsASystemdBootMachineWithItsFallbackEntry(t *testing.T) {
root := t.TempDir()
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\n")
put(t, root, "boot/vmlinuz-linux", "k")
put(t, root, "boot/vmlinuz-linux-lts", "k")
got := bootOf(t, root)
if got.Loader != "systemd-boot" || got.Booted != "arch.conf" || !got.FallbackPresent {
t.Fatalf("%+v", got)
}
if len(got.Entries) != 2 || got.Entries[0] != "arch.conf" || got.Entries[1] != "mesh-linux-lts.conf" {
t.Fatalf("entries: %v", got.Entries)
}
if len(got.Kernels) != 2 || got.Kernels[0] != "vmlinuz-linux" || got.Kernels[1] != "vmlinuz-linux-lts" {
t.Fatalf("kernels: %v", got.Kernels)
}
if got.BootFreeKiB <= 0 {
t.Fatalf("free: %d", got.BootFreeKiB)
}
}
func TestKernelBootSaysASystemdBootMachineHasNoFallbackYet(t *testing.T) {
root := t.TempDir()
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
put(t, root, "boot/vmlinuz-linux", "k")
// The entry file alone is not the fallback: the kernel it names must be in /boot.
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "linux /vmlinuz-linux-lts\n")
if got := bootOf(t, root); got.FallbackPresent {
t.Fatalf("a fallback entry without its kernel: %+v", got)
}
}
func TestKernelBootReadsAGrubMachine(t *testing.T) {
root := t.TempDir()
put(t, root, "boot/grub/grub.cfg", grubMenu)
put(t, root, "boot/vmlinuz-linux", "k")
put(t, root, "proc/cmdline", bootedCmdline)
got := bootOf(t, root)
if got.Loader != "grub" || got.FallbackPresent || got.Booted != "vmlinuz-linux" {
t.Fatalf("%+v", got)
}
if len(got.Entries) != 2 || got.Entries[0] != "Arch Linux" {
t.Fatalf("entries: %v", got.Entries)
}
put(t, root, "boot/vmlinuz-linux-lts", "k")
put(t, root, "boot/grub/grub.cfg", grubMenu+"menuentry 'Arch Linux LTS' {\n\tlinux\t/boot/vmlinuz-linux-lts root=x rw\n}\n")
if got := bootOf(t, root); !got.FallbackPresent {
t.Fatalf("%+v", got)
}
}
func TestKernelBootSaysWhenThereIsNoLoader(t *testing.T) {
got := bootOf(t, t.TempDir())
if got.Loader != "none" || got.FallbackPresent || len(got.Entries) != 0 {
t.Fatalf("%+v", got)
}
}
@@ -1,284 +0,0 @@
package main
// The boot-entries script as the mesh writes it, run with sh over a stand-in root (novox/hq ADR 0508 §9 and
// §10, issue 1841). The fixtures are the shapes read from machines: a systemd-boot entries directory, a GRUB
// menu, a hook that copies every kernel image to the default kernel's name, and the command line a machine
// booted with.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const bootedCmdline = "initrd=\\initramfs-linux.img BOOT_IMAGE=/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3 initrd=\\intel-ucode.img\n"
const defaultEntry = "title Arch Linux\nlinux /vmlinuz-linux\ninitrd /intel-ucode.img\ninitrd /initramfs-linux.img\noptions root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet\n"
const loaderConf = "default arch.conf\ntimeout 3\nconsole-mode max\n"
// grubMenu is the part of a generated grub.cfg that names kernels, for a machine holding one kernel.
const grubMenu = `menuentry 'Arch Linux' --class arch {
set root='hd0,gpt2'
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
initrd /boot/initramfs-linux.img
}
menuentry 'Arch Linux, with fallback initramfs' --class arch {
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
initrd /boot/initramfs-linux-fallback.img
}
`
// kernelCopyingHook copies every installed kernel image to the default kernel's name on the boot partition.
const kernelCopyingHook = `[Trigger]
Operation = Install
Operation = Upgrade
Type = Path
Target = usr/lib/modules/*/vmlinuz
[Action]
Description = Copying the kernel to the default name
When = PostTransaction
Exec = /usr/bin/sh -c '/usr/bin/cp /usr/lib/modules/$(uname -r)/vmlinuz /boot/vmlinuz-linux'
`
// otherHook is a hook of the distribution's shape that has nothing to do with kernel images.
const otherHook = `[Trigger]
Operation = Upgrade
Type = Package
Target = pacman-mirrorlist
[Action]
Description = Updating the mirror list
When = PostTransaction
Exec = /usr/bin/cp /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist
`
type bootBed struct {
script, root, state, bin, mkconfig string
need string
}
func newBootBed(t *testing.T) bootBed {
t.Helper()
dir := t.TempDir()
b := bootBed{script: filepath.Join(dir, "boot-entries"), root: filepath.Join(dir, "root"), state: filepath.Join(dir, "state"),
bin: filepath.Join(dir, "bin"), mkconfig: filepath.Join(dir, "mkconfig")}
content := manifest(t).resource(t, "boot-entries").text(t)
content = strings.ReplaceAll(content, "${dir:state}", b.state)
if strings.Contains(content, "${") {
t.Fatalf("a substitution the test does not make is left in the script")
}
for _, d := range []string{b.root + "/boot", b.root + "/proc", b.root + "/etc/pacman.d/hooks", b.state, b.bin} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write(t, b.script, content, 0o755)
write(t, b.root+"/proc/cmdline", bootedCmdline, 0o644)
// the stand-in grub-mkconfig records the kernel it was told to put first, then its arguments.
write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"top=$GRUB_TOP_LEVEL $@\" >>"+b.mkconfig+"\n", 0o755)
return b
}
func (b bootBed) put(t *testing.T, p, content string) {
t.Helper()
full := filepath.Join(b.root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
write(t, full, content, 0o644)
}
func (b bootBed) systemdBoot(t *testing.T) {
b.put(t, "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f", "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
b.put(t, "boot/loader/loader.conf", loaderConf)
b.put(t, "boot/loader/entries/arch.conf", defaultEntry)
b.put(t, "boot/vmlinuz-linux", "k")
}
func (b bootBed) run(t *testing.T) (string, error) {
t.Helper()
args := []string{b.script, "run", b.root, b.state}
if b.need != "" {
args = append(args, b.need)
}
cmd := exec.Command("sh", args...)
cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
return string(out), err
}
func (b bootBed) read(t *testing.T, p string) string {
t.Helper()
raw, _ := os.ReadFile(filepath.Join(b.root, p))
return string(raw)
}
func TestASystemdBootMachineGetsTheFallbackEntryFromItsBootedCommandLine(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.put(t, "boot/intel-ucode.img", "u")
out, err := b.run(t)
if err != nil || !strings.Contains(out, "wrote") {
t.Fatalf("%v %s", err, out)
}
want := "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\ninitrd /intel-ucode.img\ninitrd /initramfs-linux-lts.img\n" +
"options root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3\n"
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); got != want {
t.Fatalf("entry:\n%s\nwant:\n%s", got, want)
}
// The default entry and the loader's own file are as they were.
if b.read(t, "boot/loader/entries/arch.conf") != defaultEntry || b.read(t, "boot/loader/loader.conf") != loaderConf {
t.Fatalf("the default entry or loader.conf was touched")
}
if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") {
t.Fatalf("again: %v %s", err, out)
}
}
func TestWithoutMicrocodeThereIsNoMicrocodeLine(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
if out, err := b.run(t); err != nil {
t.Fatalf("%v %s", err, out)
}
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); strings.Contains(got, "ucode") || !strings.Contains(got, "initrd /initramfs-linux-lts.img") {
t.Fatalf("entry: %s", got)
}
}
func TestAGrubMachineIsGeneratedAgainOnlyWhenItsKernelsChanged(t *testing.T) {
b := newBootBed(t)
b.put(t, "boot/grub/grub.cfg", grubMenu)
b.put(t, "boot/vmlinuz-linux", "k")
if out, err := b.run(t); err != nil || !strings.Contains(out, "left as it is") {
t.Fatalf("same: %v %s", err, out)
}
if _, err := os.Stat(b.mkconfig); err == nil {
t.Fatalf("the menu was generated again with the same kernels")
}
b.put(t, "boot/vmlinuz-linux-lts", "k")
if out, err := b.run(t); err != nil || !strings.Contains(out, "generated again") {
t.Fatalf("changed: %v %s", err, out)
}
raw, _ := os.ReadFile(b.mkconfig)
if string(raw) != "top=/boot/vmlinuz-linux -o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" {
t.Fatalf("grub-mkconfig was asked %q", raw)
}
}
// ADR 0508 §9: GRUB's menu lists kernels in reverse version order, so linux-lts would come first and become the
// default entry (GRUB_DEFAULT=0); the kernel the machine booted is put first, and the fallback kernel never.
func TestGrubKeepsTheBootedKernelFirstAndNeverTheFallback(t *testing.T) {
for name, c := range map[string]struct{ cmdline, want string }{
"booted from the default kernel": {"BOOT_IMAGE=/vmlinuz-linux root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"booted from a grub device path": {"BOOT_IMAGE=(hd0,msdos1)/vmlinuz-linux-zen root=UUID=x rw\n", "top=/boot/vmlinuz-linux-zen"},
"booted from the fallback kernel": {"BOOT_IMAGE=/vmlinuz-linux-lts root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"no image on the command line": {"root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"an image no longer in /boot": {"BOOT_IMAGE=/vmlinuz-linux-gone root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
} {
b := newBootBed(t)
b.put(t, "boot/grub/grub.cfg", grubMenu)
b.put(t, "proc/cmdline", c.cmdline)
for _, k := range []string{"vmlinuz-linux", "vmlinuz-linux-lts", "vmlinuz-linux-zen"} {
b.put(t, "boot/"+k, "k")
}
if out, err := b.run(t); err != nil {
t.Fatalf("%s: %v %s", name, err, out)
}
raw, _ := os.ReadFile(b.mkconfig)
if !strings.HasPrefix(string(raw), c.want+" -o ") {
t.Errorf("%s: grub-mkconfig was asked %q, want %s first", name, raw, c.want)
}
}
}
func TestAMachineWithNoLoaderFailsLoudly(t *testing.T) {
b := newBootBed(t)
out, err := b.run(t)
if err == nil || !strings.Contains(out, "no boot loader found") {
t.Fatalf("%v %s", err, out)
}
}
func TestABootPartitionWithoutRoomFailsBeforeAnythingIsWritten(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.need = "999999999999"
out, err := b.run(t)
if err == nil || !strings.Contains(out, "free room there before the kernel is installed") {
t.Fatalf("%v %s", err, out)
}
if b.read(t, "boot/loader/entries/mesh-linux-lts.conf") != "" {
t.Fatalf("an entry was written")
}
// Once the fallback kernel is in place the room is not asked for again.
b.put(t, "boot/vmlinuz-linux-lts", "k")
if out, err := b.run(t); err != nil {
t.Fatalf("%v %s", err, out)
}
}
func TestAHookThatCopiesKernelsToOneFixedNameIsMovedIntoTheStateAndNamed(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.put(t, "etc/pacman.d/hooks/60-copy-kernel.hook", kernelCopyingHook)
b.put(t, "etc/pacman.d/hooks/70-mirrorlist.hook", otherHook)
b.put(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", "[Trigger]\nTarget = usr/lib/modules/*/vmlinuz\n[Action]\nExec = /bin/cp x /boot/y\n")
out, err := b.run(t)
if err != nil || !strings.Contains(out, "taken over: the pacman hook 60-copy-kernel.hook") {
t.Fatalf("%v %s", err, out)
}
if b.read(t, "etc/pacman.d/hooks/60-copy-kernel.hook") != "" {
t.Fatalf("the copying hook is still in the hook directory")
}
if raw, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "60-copy-kernel.hook")); string(raw) != kernelCopyingHook {
t.Fatalf("moved hook: %q", raw)
}
if b.read(t, "etc/pacman.d/hooks/70-mirrorlist.hook") != otherHook || b.read(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook") == "" {
t.Fatalf("a hook that copies no kernel was moved")
}
}
func TestTheBootEntriesUnitAndHookAreTheModules(t *testing.T) {
m := manifest(t)
unit := m.resource(t, "boot-unit").text(t)
for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/boot-entries run", "ProtectSystem=strict",
"ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks", "NoNewPrivileges=yes", "WantedBy=multi-user.target"} {
if !strings.Contains(unit, line+"\n") {
t.Errorf("the unit lacks %q", line)
}
}
hook := m.resource(t, "boot-hook")
text := hook.text(t)
if hook["path"] != "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook" || !strings.Contains(text, "Target = usr/lib/modules/*/vmlinuz\n") ||
!strings.Contains(text, "When = PostTransaction\n") || !strings.Contains(text, "Exec = ${dir:state}/boot-entries run\n") {
t.Fatalf("the hook: %v\n%s", hook["path"], text)
}
apply := m.resource(t, "boot-apply")
if apply["unit"] != "mesh-kernel-boot-entries.service" || apply["state"] != "running" || apply["boot"] != "enabled" {
t.Fatalf("apply: %v", apply)
}
}
// The fallback kernel and its headers are declared after the boot-entries service, so the entry is placed
// before the kernel is installed and every DKMS module is built for it (ADR 0508 §9).
func TestTheFallbackKernelAndItsHeadersAreDeclaredAfterTheBootEntriesUnit(t *testing.T) {
m := manifest(t)
at := map[string]int{}
for i, r := range m.Resources {
at[r["id"].(string)] = i
}
for id, pkg := range map[string]string{"linux-lts": "linux-lts", "linux-lts-headers": "linux-lts-headers"} {
r := m.resource(t, id)
if r["type"] != "package" || r["package"] != pkg || r["absent"] == true {
t.Errorf("%s: %v", id, r)
}
if at[id] < at["boot-apply"] || at[id] < at["boot-hook"] {
t.Errorf("%s is declared before the boot-entries unit and hook", id)
}
}
}
@@ -1,6 +1,6 @@
package main
// Over a stand-in tree holding the files of a real machine of 2026-10-14 23:25 (read through node-reads for
// Over a stand-in tree holding novox's own files of 2026-10-14 23:25 (read through node-reads for
// novox/hq issue 746): the mode, the per-size controls, meminfo's huge page lines, and fail2ban's and
// journald's memory.stat and io.stat, cut to the lines the tool reads.
@@ -26,7 +26,7 @@ func tree(t *testing.T, files map[string]string) *Machine {
return &Machine{Root: root}
}
func capturedMachine(t *testing.T) *Machine {
func novox(t *testing.T) *Machine {
thp := "sys/kernel/mm/transparent_hugepage/"
cg := "sys/fs/cgroup/system.slice/"
files := map[string]string{
@@ -53,8 +53,8 @@ func capturedMachine(t *testing.T) *Machine {
return tree(t, files)
}
func TestTheCapturedMachineBeforeTheChange(t *testing.T) {
a, err := capturedMachine(t).HugePages(10)
func TestNovoxBeforeTheChange(t *testing.T) {
a, err := novox(t).HugePages(10)
if err != nil {
t.Fatal(err)
}
@@ -86,7 +86,7 @@ func TestTheCapturedMachineBeforeTheChange(t *testing.T) {
}
func TestUnitsAreBoundedAndNoneIsSaid(t *testing.T) {
a, err := capturedMachine(t).HugePages(1)
a, err := novox(t).HugePages(1)
if err != nil || len(a.Units) != 1 {
t.Fatalf("%v %+v", err, a)
}
-10
View File
@@ -46,15 +46,5 @@ func tools(m *Machine) []stdio.Tool {
return m.HugePages(n)
},
},
{
Name: "kernel_boot",
Description: "How this machine boots and whether the fallback kernel is on its menu (novox/hq ADR 0508 §9): the loader that booted it " +
"(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " +
"the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " +
"the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB). " +
"Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)",
Input: schema(map[string]any{}),
Run: func(args map[string]any) (any, error) { return m.Boot() },
},
}
}
@@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
}
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
if strings.Join(ids, " ") != "state huge-pages unit apply" {
t.Fatalf("order: %v", ids)
}
script := m.resource(t, "huge-pages")
@@ -129,7 +129,7 @@ func (b bed) file(t *testing.T, p string) string {
}
func TestApplySetsTheModeRotatesTheJournalOnceAndRestoreGivesBackWhatWasFound(t *testing.T) {
// a real machine's file on 2026-10-14, read through node-reads.
// novox's file on 2026-10-14, read through node-reads.
b := newBed(t, "madvise", "[always] madvise never\n")
out, err := b.run(t, "apply")
if err != nil || !strings.Contains(out, "always -> madvise") {
@@ -1,13 +0,0 @@
# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the
# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module.
[Trigger]
Operation = Install
Operation = Upgrade
Operation = Remove
Type = Path
Target = usr/lib/modules/*/vmlinuz
[Action]
Description = Placing the fallback kernel on the boot menu
When = PostTransaction
Exec = ${dir:state}/boot-entries run
-133
View File
@@ -1,133 +0,0 @@
#!/bin/sh
# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10,
# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the
# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed,
# upgraded or removed. Replaced on every push; change the module, never this file.
#
# boot-entries run [<root> [<this module's state> [<room needed in /boot, KiB>]]]
#
# The three are arguments only so the module's tests can run this against a stand-in tree.
#
# It reads from the machine which loader booted it, never from a setting:
# systemd-boot the EFI variable LoaderEntrySelected is present: the entry mesh-linux-lts.conf is written
# from the command line the machine booted with.
# GRUB /boot/grub/grub.cfg exists and that variable does not: the menu is generated again, only
# when the set of kernels in /boot differs from the menu's.
# neither it fails loudly. The default entry is never touched.
# And a pacman hook of someone else's that copies kernel images to one fixed name in /boot is moved into this
# module's state first (ADR 0508 §10): it would put the fallback kernel in the default kernel's place.
set -eu
root=/
state='${dir:state}'
[ $# -ge 2 ] && root=$(printf '%s' "$2" | sed 's|/$||')
[ $# -ge 3 ] && state=$3
boot=$root/boot
hooks=$root/etc/pacman.d/hooks
efivar=$root/sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f
ours=90-mesh-kernel-boot-entries.hook
# The fallback kernel and its initramfs are about 72 MB in /boot (issue 1836): less room fails the unit
# before a package writes half of them.
need_kib=98304
[ $# -ge 4 ] && need_kib=$4
[ $# -ge 1 ] && [ "$1" = run ] || {
echo "usage: boot-entries run [<root> [<state directory> [<KiB needed>]]]" >&2
exit 2
}
# A hook that is not ours, triggered by kernel images, whose action copies to one fixed name in /boot.
copies_kernels() {
grep -Eqi '^[[:space:]]*Target[[:space:]]*=.*(vmlinuz|usr/lib/modules)' "$1" &&
grep -Eq '^[[:space:]]*Exec[[:space:]]*=.*(cp|install|ln|rsync)[[:space:]].*/boot/[A-Za-z0-9._+-]+([^A-Za-z0-9._+/%$*-]|$)' "$1"
}
if [ -d "$hooks" ]; then
for hook in "$hooks"/*.hook; do
[ -f "$hook" ] || continue
[ "$(basename "$hook")" = "$ours" ] && continue
copies_kernels "$hook" || continue
mkdir -p "$state/taken-over"
mv -f "$hook" "$state/taken-over/$(basename "$hook")"
echo "taken over: the pacman hook $(basename "$hook") copied kernel images to one fixed name in /boot; moved to $state/taken-over/ so each kernel keeps its own package's name"
done
fi
if [ -e "$efivar" ]; then
loader=systemd-boot
elif [ -f "$boot/grub/grub.cfg" ]; then
loader=grub
else
echo "no boot loader found: the machine was not booted by systemd-boot (no LoaderEntrySelected variable) and there is no $boot/grub/grub.cfg; the fallback kernel has no menu to be placed on" >&2
exit 1
fi
# Room first, only while the fallback kernel is not in /boot yet.
if [ ! -e "$boot/vmlinuz-linux-lts" ]; then
free=$(df -Pk "$boot" | awk 'NR==2 {print $4}')
[ -n "$free" ] || free=0
if [ "$free" -lt "$need_kib" ]; then
echo "$boot has $free KiB free and the fallback kernel needs $need_kib KiB: free room there before the kernel is installed" >&2
exit 1
fi
fi
case "$loader" in
systemd-boot)
entries=$boot/loader/entries
cmdline=$(sed -e 's/BOOT_IMAGE=[^ ]*//g' -e 's/initrd=[^ ]*//g' "$root/proc/cmdline" | tr -s ' \n' ' ' | sed -e 's/^ //' -e 's/ $//')
[ -n "$cmdline" ] || {
echo "$root/proc/cmdline holds no command line to boot the fallback kernel with" >&2
exit 1
}
want=$(
echo "title Arch Linux (linux-lts, fallback)"
echo "linux /vmlinuz-linux-lts"
for ucode in intel-ucode.img amd-ucode.img; do
[ -e "$boot/$ucode" ] && echo "initrd /$ucode"
done
echo "initrd /initramfs-linux-lts.img"
echo "options $cmdline"
)
mkdir -p "$entries"
if [ "$(cat "$entries/mesh-linux-lts.conf" 2>/dev/null || true)" = "$want" ]; then
echo "systemd-boot: mesh-linux-lts.conf, as it was"
else
printf '%s\n' "$want" >"$entries/mesh-linux-lts.conf"
echo "systemd-boot: wrote $entries/mesh-linux-lts.conf from the command line the machine booted with; the default entry is as it was"
fi
;;
grub)
cfg=$boot/grub/grub.cfg
menu=$(sed -n 's|^[[:space:]]*linux[[:space:]]*\(/boot\)\{0,1\}/\(vmlinuz-[^[:space:]]*\).*|\2|p' "$cfg" | sort -u)
have=$(cd "$boot" && ls -d vmlinuz-* 2>/dev/null | sort -u || true)
if [ "$menu" = "$have" ]; then
echo "grub: the menu lists the kernels in /boot, left as it is"
else
# GRUB lists kernels in reverse version order, so the fallback kernel would come first and become the
# default entry: the kernel the machine booted is put first (GRUB_TOP_LEVEL; /etc/default/grub still
# overrides it when it sets one), the fallback kernel never.
top=$(tr ' ' '\n' <"$root/proc/cmdline" | sed -n 's|^BOOT_IMAGE=\(.*\)$|\1|p' | head -n 1)
top=$(printf '%s' "$top" | sed 's|.*/||')
case "$top" in
vmlinuz-linux-lts | "") top= ;;
esac
[ -n "$top" ] && [ -e "$boot/$top" ] || top=
if [ -z "$top" ]; then
if [ -e "$boot/vmlinuz-linux" ]; then
top=vmlinuz-linux
else
top=$(printf '%s\n' $have | grep -v '^vmlinuz-linux-lts$' | head -n 1 || true)
fi
fi
if [ -n "$top" ]; then
GRUB_TOP_LEVEL=/boot/$top grub-mkconfig -o "$cfg"
else
grub-mkconfig -o "$cfg"
fi
first=$top
[ -n "$first" ] || first="the menu's own order"
echo "grub: the kernels in /boot changed ($(echo $have | tr '\n' ' ')); the menu is generated again with $first first; the default entry is as it was"
fi
;;
esac
@@ -1,22 +0,0 @@
[Unit]
Description=The fallback kernel's place on this machine's boot menu
# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and
# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says
# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this
# unit, so the entry is in place before they are installed.
After=local-fs.target
Wants=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${dir:state}/boot-entries run
# It writes the boot partition, the pacman hook directory and its own state, and nothing else.
ProtectSystem=strict
ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks
ProtectHome=yes
PrivateNetwork=yes
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target
+1 -48
View File
@@ -13,8 +13,7 @@
}
},
"tools": [
"kernel_huge_pages",
"kernel_boot"
"kernel_huge_pages"
],
"resources": [
{
@@ -54,52 +53,6 @@
"health": {
"kind": "unit"
}
},
{
"id": "boot-entries",
"type": "file",
"path": "${dir:state}/boot-entries",
"mode": "0755",
"trusted": true,
"template": "files/boot-entries.sh"
},
{
"id": "boot-unit",
"type": "file",
"path": "/etc/systemd/system/mesh-kernel-boot-entries.service",
"mode": "0644",
"template": "files/mesh-kernel-boot-entries.service"
},
{
"id": "boot-hook",
"type": "file",
"path": "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook",
"mode": "0644",
"template": "files/90-mesh-kernel-boot-entries.hook"
},
{
"id": "boot-apply",
"type": "service",
"unit": "mesh-kernel-boot-entries.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"boot-unit",
"boot-entries"
],
"health": {
"kind": "unit"
}
},
{
"id": "linux-lts",
"type": "package",
"package": "linux-lts"
},
{
"id": "linux-lts-headers",
"type": "package",
"package": "linux-lts-headers"
}
],
"build": {
+1 -13
View File
@@ -16,18 +16,6 @@ installation's are settings on the assignment, `settings set mailu <file>`:
header from — the address the proxy forwards with. The front's own hostname is the name of its
`web` route, told to it by the mesh.
## Mail client autoconfiguration
`mailu-automx` answers what mail clients ask for their settings (Thunderbird, Outlook, Apple) for the domains in
`AMX_MAIL_DOMAINS`, naming the IMAP and SMTP servers and ports of mailu's environment (`AMX_IMAP_ADDRESS`,
`AMX_IMAP_PORT`, `AMX_SMTP_ADDRESS`, `AMX_SMTP_PORT`). It is `cmd/automx`, a Go program of the standard library
only in this module's `go.mod` (ADR 0480 clauses 1, 2, 9). It runs in the mirrored alpine image the route proxy's
`trust` step runs, from the `program-automx` archive mounted read-only at `/opt/mesh`; the container is restarted
with the archive and with `mailu.env`, which the program reads at each start. It keeps no database. The file the
Python automx kept in `data-automx` is left where it is on the machine, not deleted by the mesh (ADR 0480
clause 10). The answers it gave before are the fixtures in `cmd/automx/testdata/captured`, with how they were
taken (`CAPTURED`) and what the program answers differently (`DIFFERENCES`).
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
@@ -35,7 +23,7 @@ Each directory or archive this module declares says how it treats a file it did
- `${dir:mesh-state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:grants}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- the archive `program-automx` (`${dir:state}/automx`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-automx}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-certs}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-data}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-dkim}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
+87
View File
@@ -0,0 +1,87 @@
// What the autoconfiguration module (automx2) holds, as stored: its provider, server, domain and server_domain
// rows, read from its SQLite file inside its container with the sqlite3 program the image carries, opened
// read-only. Nothing is written and nothing is interpreted: the rows are the evidence for what automx answers
// to mail clients (novox/hq issue 1630, ADR 0480), and the reason the answer may be empty.
import type { Exec } from "./health.js";
export const AUTOMX_DATABASE = "/data/db.sqlite";
/** The tables read, each with its columns and its ordering. Fixed text: the tool takes no query from its caller. */
export const AUTOMX_TABLES: Record<string, { columns: string[]; order: string }> = {
provider: { columns: ["id", "name", "short_name"], order: "id" },
server: { columns: ["id", "prio", "name", "port", "type", "socket_type", "user_name", "authentication"], order: "id" },
domain: { columns: ["id", "name", "provider_id", "ldapserver_id"], order: "id" },
server_domain: { columns: ["server_id", "domain_id"], order: "domain_id, server_id" },
};
/**
* The one statement that reads a table. The sqlite3 in the mailu-automx image does not know `-json` (it answers
* "sqlite3: Error: unknown option: -json", issue 1630), so each column is printed as `<type>:<hex of its bytes>`, which
* the oldest sqlite3 prints in its default list mode: no quoting, no separator or newline inside a cell, and a NULL
* (`null:`) apart from an empty text (`text:`).
*/
export function selectOf(table: string): string {
const t = AUTOMX_TABLES[table];
return `SELECT ${t.columns.map((c) => `typeof(${c}) || ':' || hex(${c})`).join(", ")} FROM ${table} ORDER BY ${t.order}`;
}
export type Row = Record<string, string | number | null>;
export type AutomxRows = {
database: string;
tables: Record<string, Row[]>;
/** servers whose name is empty: the client is told an empty host name for them */
empty_names: { id: number; type: string }[];
/** tables that could not be read, each with what sqlite3 or docker said on one line */
unreadable: Record<string, string>;
};
/** One cell as selectOf prints it: `<type>:<hex>`; text is the bytes read as UTF-8, a number is read from its text. */
function cellOf(cell: string): string | number | null {
const at = cell.indexOf(":");
if (at < 0 || cell.length - at - 1 > 0 && !/^([0-9A-Fa-f]{2})+$/.test(cell.slice(at + 1))) throw new Error(`sqlite3 printed ${JSON.stringify(cell.slice(0, 40))}, not a cell`);
const type = cell.slice(0, at);
const bytes = Buffer.from(cell.slice(at + 1), "hex").toString("utf8");
switch (type) {
case "null": return null;
case "integer": case "real": return Number(bytes);
default: return bytes;
}
}
/** sqlite3 prints one line per row, cells apart by `|`, and nothing at all for a table without rows. */
export function parseRows(table: string, output: string): Row[] {
const columns = AUTOMX_TABLES[table].columns;
const rows: Row[] = [];
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
const cells = line.replace(/\r$/, "").split("|");
if (cells.length !== columns.length) throw new Error(`sqlite3 printed ${cells.length} cells for ${columns.length} columns`);
rows.push(Object.fromEntries(columns.map((c, i) => [c, cellOf(cells[i])])));
}
return rows;
}
/** The command that reads one table: read-only, one fixed statement. */
export function readCommand(table: string): string[] {
return ["sqlite3", "-readonly", AUTOMX_DATABASE, selectOf(table)];
}
/** Read every table; one that fails is said in `unreadable` and does not hide the others. */
export async function readAutomx(exec: Exec, container: string): Promise<AutomxRows> {
const answer: AutomxRows = { database: AUTOMX_DATABASE, tables: {}, empty_names: [], unreadable: {} };
for (const table of Object.keys(AUTOMX_TABLES)) {
try {
answer.tables[table] = parseRows(table, await exec(container, readCommand(table)));
} catch (e) {
const err = e as { stderr?: string; message?: string };
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
answer.unreadable[table] = text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
}
}
for (const s of answer.tables.server ?? []) {
if (String(s.name ?? "").trim() === "") answer.empty_names.push({ id: Number(s.id), type: String(s.type) });
}
return answer;
}
+44
View File
@@ -0,0 +1,44 @@
# automx2 — the autoconfig/autodiscover responder, carried by the mailu module as its own
# artifact: it is a config-baked sidecar of this mail server, not a standalone application
# (novox/hq ADR 0015 draws that line at applications).
#
# The base is named rather than pinned (novox/hq issue 044): declared in module.json's
# `build.on`. The build context is the module's own directory; every ADD says so.
ARG PYTHON_BASE
FROM ${PYTHON_BASE}
RUN apk add --no-cache bash sqlite
WORKDIR /automx2
ADD automx/files/setupvenv.sh /automx2/setupvenv.sh
ADD automx/files/start /automx2/start
ADD automx/files/setup /automx2/setup
ADD automx/files/setup-db /automx2/setup-db
ADD automx/files/add-domains /automx2/add-domains
RUN chmod u+x setupvenv.sh start add-domains setup setup-db
RUN ./setupvenv.sh \
&& . .venv/bin/activate \
&& pip install automx2==2021.6
# The launcher `start` expects. In the predecessor's image this wrapper appeared during a build
# step that never made it into the files this module carries — the image worked and the recipe
# could not reproduce it. Written here explicitly, verbatim from the proven image, so the build
# is the whole truth about the image again.
RUN mkdir -p .venv/scripts && printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'. .venv/bin/activate' \
"export FLASK_ENV='production'" \
"export FLASK_APP='automx2.server:app'" \
'flask "$@"' > .venv/scripts/flask.sh && chmod +x .venv/scripts/flask.sh
ENV AUTOMX2_CONF=/etc/automx2.conf
ADD automx/files/automx2.conf /etc/automx2.conf
# VOLUME deliberately absent: the anonymous /data volume is exactly what lost db.sqlite on
# every recreate (measured on novox 2026-08-10). The manifest binds a real directory instead.
ENTRYPOINT ["/bin/sh"]
CMD ["./start"]
EXPOSE 4243
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -e
echo "${MAIL_DOMAINS}"
# Split domains into array
IFS=', ' read -r -a array <<< "${AMX_MAIL_DOMAINS}"
# User configurable section -- START
PROVIDER_ID=001
SQL_CMD="";
# Iterate domains resulting from split on second arg
for element in "${array[@]}"
do
# Set vars
DOMAIN=$element
PROVIDER_NAME=$DOMAIN
PROVIDER_SHORTNAME=$DOMAIN
# Optional LDAP server
#LDAP_SERVER="ldap.${DOMAIN}"
# User configurable section -- END
s1_id=$((PROVIDER_ID + 1))
s2_id=$((PROVIDER_ID + 2))
s3_id=$((PROVIDER_ID + 3))
dom_id=$((PROVIDER_ID + 4))
s3_id='NULL'
SQL_CMD=$(cat <<EOT
$SQL_CMD
INSERT INTO provider(id, name, short_name) VALUES(${PROVIDER_ID}, '${PROVIDER_NAME}', '${PROVIDER_SHORTNAME}');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s1_id}, ${AMX_IMAP_PORT}, 'imap', '${AMX_IMAP_SERVER}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s2_id}, ${AMX_SMTP_PORT}, 'smtp', '${AMX_SMTP_ADDRESS}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO domain(id, name, provider_id, ldapserver_id) VALUES(${dom_id}, '${DOMAIN}', ${PROVIDER_ID}, ${s3_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s1_id}, ${dom_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s2_id}, ${dom_id});
EOT
)
PROVIDER_ID=$((PROVIDER_ID+10))
done
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
+21
View File
@@ -0,0 +1,21 @@
[automx2]
# A typical production setup would use loglevel = WARNING
loglevel = WARNING
# Echo SQL commands into log? Used for debugging.
db_echo = false
# In-memory SQLite database
# db_uri = sqlite:///:memory:
# SQLite database in a UNIX-like file system
db_uri = sqlite:////data/db.sqlite
# MySQL database on a remote server. This example does not use an encrypted
# connection and is therefore *not* recommended for production use.
#db_uri = mysql://username:password@server.example.com/db
# Number of proxy servers between automx2 and the client (default: 0).
# If your logs only show 127.0.0.1 or ::1 as the source IP for incoming
# connections, proxy_count probably needs to be changed.
proxy_count = 1

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