Compare commits

..
Author SHA1 Message Date
jschoubben 16f1bc1417 Probe: the placement files with every seat held once (issue 1126); not to be merged
mesh/delivery closed: the pull request closed unmerged
mesh/check failed: ace: ace composes node-tools differently with the candidate placement, which the change does not intend: the resource differs; g14 …
2026-10-28 12:47:08 +01:00
122 changed files with 2823 additions and 3084 deletions
+1
View File
@@ -36,6 +36,7 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
/modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate
modules/mesh-config/cmd/mesh-config/mesh-config
checks/placement/placement
# Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/noplacement
go 1.22
-99
View File
@@ -1,99 +0,0 @@
// Command noplacement refuses what the repository path of placement left behind (novox/hq ADR 0567 §10, §12).
//
// Placement, which module runs on which node, is bus state held by the seat mesh-placement; no file of the
// catalogue says it, and no manifest reads it from the delivery indexer, which no longer keeps it. It refuses:
//
// 1. a `placement` directory (or file) at the root of the catalogue;
//
// 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16);
//
// 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…`
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Prefixes are the names of the indexer's placement parts; a manifest string starting with one names them.
var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement"}
// Findings is every refusal in the catalogue at root, sorted.
func Findings(root string) ([]string, error) {
var out []string
if _, err := os.Lstat(filepath.Join(root, "placement")); err == nil {
out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-placement (novox/hq ADR 0567 §10)")
}
if _, err := os.Lstat(filepath.Join(root, "particulars")); err == nil {
out = append(out, "particulars: the repository lists no node names; the checker hands them to the check (novox/hq ADR 0567 §16)")
}
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
for _, file := range manifests {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var doc any
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%s: %w", file, err)
}
module := filepath.Base(filepath.Dir(file))
for _, s := range named(doc, nil) {
out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s))
}
}
return out, nil
}
// named collects the strings of a decoded manifest that start with one of the Prefixes.
func named(v any, into []string) []string {
switch v := v.(type) {
case string:
for _, p := range Prefixes {
if strings.HasPrefix(v, p) {
return append(into, v)
}
}
case []any:
for _, e := range v {
into = named(e, into)
}
case map[string]any:
for _, e := range v {
into = named(e, into)
}
}
return into
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: noplacement <catalogue root>")
os.Exit(2)
}
found, err := Findings(os.Args[1])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("no placement directory, and no manifest reads the indexer's placement")
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
for _, row := range []struct {
name string
files map[string]string
want string // empty: clean
}{
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-controller.assignments"]}`}, ""},
{"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"},
{"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"},
{"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`},
{"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"},
{"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"},
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
for path, text := range row.files {
p := filepath.Join(root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := Findings(root)
if err != nil {
t.Fatal(err)
}
if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) {
t.Fatalf("findings = %q, want one containing %q", all, row.want)
}
})
}
}
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2).
func TestTheCatalogueHoldsNoPlacementOfTheRepositoryPath(t *testing.T) {
got, err := Findings(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
for _, f := range got {
t.Error(f)
}
}
-6
View File
@@ -1,6 +0,0 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the root, for a placement directory, and every manifest, for a read of the indexer's placement states.
?placement
modules/*/module.json
+7 -22
View File
@@ -2,9 +2,8 @@
// 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 the mesh's own, not the repository's: the checker hands them in the environment value
// MESH_NODE_NAMES, comma-separated, from the node records it reads (novox/hq ADR 0567 §16). A run with none fails
// loudly, since it would pass on anything. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
// 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.
@@ -35,28 +34,14 @@ func main() {
if len(os.Args) > 1 {
root = os.Args[1]
}
os.Exit(check(root, namesFromEnv()))
}
// NamesEnv is the environment value that carries the mesh's node names (mesh-sdk delivery.NodeNamesEnv).
const NamesEnv = "MESH_NODE_NAMES"
// namesFromEnv is the names NamesEnv holds, comma-separated, trimmed, empty entries left out.
func namesFromEnv() []string {
var out []string
for _, n := range strings.Split(os.Getenv(NamesEnv), ",") {
if n = strings.TrimSpace(n); n != "" {
out = append(out, n)
}
}
return out
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, names []string) int {
var err error
if len(names) == 0 {
err = fmt.Errorf("no node names (%s is empty) — the check would pass on anything", NamesEnv)
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 {
+8 -9
View File
@@ -74,24 +74,26 @@ func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
}
}
func TestCheckReturnsOneOnAFindingAndTwoOnNoNames(t *testing.T) {
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, []string{"ace"}); got != 0 {
if got := check(root); got != 0 {
t.Errorf("clean = %d, want 0", got)
}
write("modules/kernel/a", "on ace\n")
if got := check(root, []string{"ace"}); got != 1 {
if got := check(root); got != 1 {
t.Errorf("finding = %d, want 1", got)
}
if got := check(root, nil); got != 2 {
t.Errorf("no names = %d, want 2", got)
os.Remove(filepath.Join(root, "particulars"))
if got := check(root); got != 2 {
t.Errorf("no list = %d, want 2", got)
}
}
@@ -115,10 +117,7 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) {
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module.
func TestTheCoveredModulesNameNoNode(t *testing.T) {
if _, set := os.LookupEnv(NamesEnv); !set {
t.Skip("ADR 0567 §16: this checker hands no node names (before D14 is delivered); the skip goes once it is")
}
if code := check("../..", namesFromEnv()); code != 0 {
if code := check("../.."); code != 0 {
t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code)
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# check("../.."): every file of the modules the covered list names. The node names come from the run (MESH_NODE_NAMES).
# check("../.."): the root's list and every file of the modules it names.
particulars
modules
+5
View File
@@ -0,0 +1,5 @@
module checks/placement
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f
+18
View File
@@ -0,0 +1,18 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117 h1:88CNZOoQgu7YPYKW16qkWT8Gw9Ucg07aTrIImA2nYUk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f h1:AKzV973UTWe0OYFoIjgGu0f+B26xh8QsAHzCpsTIjM8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f h1:mimAGERguQmCGYVNdRTrFcZ1dhpU6GWo+Q3EDhHOrVk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+80
View File
@@ -0,0 +1,80 @@
// Command placement is the catalogue's check of its placement files (novox/hq ADR 0549 §1, ADR 0555; issue 1126): every
// `placement/<node>.json` reads as a placement of the node its name says, and the files together are refused, every
// finding named at once, for two modules of one node on one host port, a pin, binding or holding naming a provider not
// placed where it says, a seat held once for the mesh held twice, two nodes with one address on the private network, or
// more than one hub (mesh-sdk delivery.CheckPlacements).
//
// A module the catalogue does not hold and a node not enrolled are the composition check's to refuse: placement names
// modules of every repository the indexer reads and the nodes the mesh enrolled, neither of which this tree holds.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Check reads every placement file under root and answers each finding; none is a catalogue whose placement holds. A
// catalogue with no placement directory has none to check.
func Check(root string) ([]string, error) {
dir := filepath.Join(root, delivery.PlacementDir)
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var found []string
placements := map[string]delivery.Placement{}
for _, e := range entries {
rel := delivery.PlacementDir + "/" + e.Name()
node, ok := delivery.PlacementFileNode(rel)
if e.IsDir() || !ok {
found = append(found, fmt.Sprintf("%s is not a placement file: the directory holds only <node>.json, one per node", rel))
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
p, err := delivery.DecodePlacement(raw)
if err != nil {
found = append(found, fmt.Sprintf("%s does not read as a placement: %v", rel, err))
continue
}
if p.Revision != "" {
found = append(found, fmt.Sprintf("%s names a revision, which only the indexer writes into the key", rel))
}
placements[node] = p
}
all := func(string) bool { return true }
found = append(found, delivery.CheckPlacements(placements, all, all)...)
sort.Strings(found)
return found, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: placement <catalogue root>")
os.Exit(2)
}
found, err := Check(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The catalogue's placement files as the merge check reads them (ADR 0549 §1, ADR 0555): the real tree holds every
// rule, and a tree that breaks them is refused with every finding named at once.
func TestPlacementFiles(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s (novox/hq ADR 0549 §1)", f)
}
root := t.TempDir()
write := func(name, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "placement"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "placement", name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if found, err := Check(root); err != nil || len(found) != 0 {
t.Fatalf("a catalogue with no placement directory: %v, %v", found, err)
}
write("ace.json", `{"v":1,"node":"ace","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"a","ports":{"80":8080}},{"module":"b","ports":{"81":8080}}]}`)
write("g14.json", `{"v":1,"node":"g14","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"c","bindings":{"pg":{"node":"ace","module":"pg"}}}]}`)
write("novox.json", `{"v":1,"node":"shanks","modules":[]}`)
write("bad.json", `{"v":1,"node":"bad","modules":{}}`)
write("dated.json", `{"v":1,"node":"dated","revision":"abc","modules":[]}`)
write("notes.md", "x")
found, err = Check(root)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"a and b are both published on host port 8080", "bound to ace/pg", "both have the address 10.77.0.1",
"ace, g14 are each placed as it", "placement file of novox names the node", "bad.json does not read", "dated.json names a revision",
"notes.md is not a placement file"} {
hit := false
for _, f := range found {
hit = hit || strings.Contains(f, want)
}
if !hit {
t.Errorf("no finding says %q; found %v", want, found)
}
}
}
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
@@ -4,9 +4,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
@@ -65,60 +62,3 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
// The packaged dnsmasq.service orders itself Before= network-online.target and nss-lookup.target. A drop-in
// that orders it After= either of them makes an ordering cycle, and systemd drops the start job: the mesh
// resolver stayed down for 90 minutes at a boot (novox/hq issue 1126). Wants=, Requires= and BindsTo= are
// refused too: they pull the target in, and with the packaged ordering they are the same mistake. Because the
// tunnel is ordered after those targets, the drop-in cannot wait for the address either: a start that finds it
// missing fails at once and Restart=on-failure brings it up (see the next test).
func TestDropInDoesNotOrderAfterATargetThePackagedUnitOrdersBefore(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
if err != nil {
t.Fatal(err)
}
forbidden := []string{"network-online.target", "nss-lookup.target"}
for n, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
if !ok || (key != "After" && key != "Requires" && key != "Wants" && key != "BindsTo") {
continue
}
for _, target := range forbidden {
if strings.Contains(val, target) {
t.Errorf("mesh.conf line %d: %s=%s orders dnsmasq after a target its packaged unit orders itself before: a cycle", n+1, key, val)
}
}
}
}
// The wait for the address in ExecStartPre is at most two seconds: the tunnel that brings the address is ordered
// after network-online.target, which the resolver is ordered before, so a longer wait would hold the tunnel back.
func TestAddressWaitIsAtMostTwoSeconds(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
if err != nil {
t.Fatal(err)
}
found := false
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "ExecStartPre=") {
continue
}
m := regexp.MustCompile(`seq 1 (\d+)`).FindStringSubmatch(line)
if m == nil {
t.Fatalf("ExecStartPre has no bounded wait (seq 1 N): %s", line)
}
found = true
if n, _ := strconv.Atoi(m[1]); n > 2 {
t.Errorf("ExecStartPre waits up to %d s for the address, more than 2 s", n)
}
}
if !found {
t.Fatal("mesh.conf has no ExecStartPre")
}
}
+5 -6
View File
@@ -1,15 +1,14 @@
# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push.
#
# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this
# machine's private address is there: a start that finds it missing fails after at most two seconds and
# is tried again every ten seconds (Restart=on-failure, no start limit) until the tunnel has brought it.
# It does not order itself After= or Wants= network-online.target, and its wait is short: the packaged
# unit is Before= network-online.target and nss-lookup.target, and the tunnel is ordered after them, so
# a long wait would hold the tunnel back and a drop-in ordering would be a cycle (issue 1126).
# machine's private address is there: it waits up to 80 seconds for it, and a start that found it
# missing is tried again every ten seconds rather than left failed.
[Unit]
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 2); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1'
ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1'
Restart=on-failure
RestartSec=10s
+19 -37
View File
@@ -560,7 +560,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, paths?: Record<string, string[]>, listed?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; listed?: Record<string, 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[] = [];
@@ -587,7 +587,23 @@ 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 };
// **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name
// to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a
// merge. A directory the commit lacks lists nothing (the catalogue before its first placement file).
let files: Record<string, Record<string, string>> | undefined;
if (listed !== undefined) {
files = {};
for (const dir of listed) {
files[dir] = {};
const prefix = dir === "" ? "" : `${dir}/`;
for (const e of entries) {
if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue;
const name = e.path.slice(prefix.length);
if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha);
}
}
}
if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files };
// **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.
@@ -602,7 +618,7 @@ export class GiteaClient {
named[dir][p] = id;
}
}
return { root, trees, paths: named };
return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files };
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -742,15 +758,6 @@ export class GiteaClient {
return { created: true, rule };
}
/** Write a branch's rule with exactly the fields given (gitea_branch_protection_set_full): an edit of the rule named
* for the branch, or a new one. As this client's account, which the forge refuses where it is no administrator. */
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
return creating
? this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections`, { method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) })
: this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`,
{ method: "PATCH", body: JSON.stringify(body) });
}
async createPullRequest(
owner: string,
repo: string,
@@ -1045,31 +1052,6 @@ export class GiteaAdmin {
return Array.isArray(res.body) ? res.body : [];
}
/** Write a branch's rule with exactly the fields given, as the forge's administrator (gitea_branch_protection_set_full;
* the operator, 2026-10-28): an edit of the rule named for the branch, or a new one. */
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
const path = creating ? `${repoPath(owner, repo)}/branch_protections` : `${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`;
const res = await this.request(path, { method: creating ? "POST" : "PATCH", body: JSON.stringify(creating ? { rule_name: branch, ...body } : body) });
if (res.status !== 200 && res.status !== 201) GiteaAdmin.fail(path, res);
return res.body as BranchProtection;
}
/** A repository as the forge answers it to its administrator: every setting (gitea_repo_settings_set_full). */
async repoSettings(owner: string, repo: string): Promise<Record<string, unknown>> {
const path = repoPath(owner, repo);
const res = await this.request(path);
if (res.status !== 200) GiteaAdmin.fail(path, res);
return res.body as Record<string, unknown>;
}
/** Edit a repository's settings with exactly the fields given, as the forge's administrator. */
async editRepoSettings(owner: string, repo: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
const path = repoPath(owner, repo);
const res = await this.request(path, { method: "PATCH", body: JSON.stringify(body) });
if (res.status !== 200) GiteaAdmin.fail(path, res);
return res.body as Record<string, unknown>;
}
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only
* the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no
* rule for main gets one. Answers the repositories it touched. With the administrator's login:
+1 -2
View File
@@ -569,7 +569,6 @@
"mesh-build-node.keys",
"mesh-build-image.keys",
"mesh-delivery-checker.owed",
"mesh-delivery-indexer.modules",
"mesh-delivery-gate.source"
"mesh-delivery-indexer.modules"
]
}
-257
View File
@@ -1,257 +0,0 @@
// Every field of a branch protection rule, set through one tool (gitea_branch_protection_set_full; the operator,
// 2026-10-28: "just make a generic tool so you can setup any configuration"). Pure, so it is tested without a forge:
// which fields the forge's rule takes and of what kind, the body a call sends, and whether the rule the forge then
// applies to the branch is one the controller registers a module from (judgedRule, mesh-control
// cmd/mesh-controller/build_source.go).
import { TOOLS_ACCOUNT } from "./tools-account.js";
import { MAIN_BRANCH, type BranchProtection } from "./protection.js";
import { ruleCovers } from "./guard.js";
type Kind = "boolean" | "logins" | "strings" | "number" | "string";
/** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the
* forge's "8h0m0s"). */
type Field = { kind: Kind; says: string; parent?: string; unread?: boolean; duration?: boolean };
/** Every field gitea's branch protection API takes on an edit at 1.27.3 (EditBranchProtectionOption, 32 fields), by its
* own name, and its kind; rule_name is the branch argument, never a field. A field with a parent is read by the forge
* only when the same call sets that parent true, and setting the parent false clears it (gitea's
* routers/api/v1/repo/branch.go): such a field is refused without its parent true in the call. */
export const PROTECTION_FIELDS: Record<string, Field> = {
enable_push: { kind: "boolean", says: "whether anyone who writes may push to the branch directly (false: no direct push; also clears enable_push_whitelist and push_whitelist_deploy_keys)" },
enable_push_whitelist: { kind: "boolean", says: "only the push whitelist may push; needs enable_push: true in the same call", parent: "enable_push" },
push_whitelist_usernames: { kind: "logins", says: "the logins that may push directly" },
push_whitelist_teams: { kind: "strings", says: "the teams that may push directly" },
push_whitelist_deploy_keys: { kind: "boolean", says: "whether deploy keys may push; needs enable_push: true in the same call", parent: "enable_push" },
enable_force_push: { kind: "boolean", says: "whether a force push is taken (false also clears the force-push allowlist flags)" },
enable_force_push_allowlist: { kind: "boolean", says: "only the force-push allowlist may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
force_push_allowlist_usernames: { kind: "logins", says: "the logins that may force-push" },
force_push_allowlist_teams: { kind: "strings", says: "the teams that may force-push" },
force_push_allowlist_deploy_keys: { kind: "boolean", says: "whether deploy keys may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
enable_merge_whitelist: { kind: "boolean", says: "whether only the merge whitelist may merge" },
merge_whitelist_usernames: { kind: "logins", says: "the logins that may merge (the controller's trunk rule wants mesh-tools, no agent-<node>)" },
merge_whitelist_teams: { kind: "strings", says: "the teams that may merge" },
enable_status_check: { kind: "boolean", says: "whether the statuses in status_check_contexts must pass to merge" },
status_check_contexts: { kind: "strings", says: "the required commit statuses, e.g. mesh/check" },
required_approvals: { kind: "number", says: "how many approving reviews a merge needs" },
enable_approvals_whitelist: { kind: "boolean", says: "whether only the approvals whitelist's reviews count" },
approvals_whitelist_username: { kind: "logins", says: "the logins whose approvals count" },
approvals_whitelist_teams: { kind: "strings", says: "the teams whose approvals count" },
block_on_rejected_reviews: { kind: "boolean", says: "a review asking changes blocks the merge" },
block_on_official_review_requests: { kind: "boolean", says: "an open official review request blocks the merge" },
block_on_outdated_branch: { kind: "boolean", says: "a head behind the branch blocks the merge" },
dismiss_stale_approvals: { kind: "boolean", says: "a new commit dismisses earlier approvals" },
ignore_stale_approvals: { kind: "boolean", says: "approvals of an earlier commit do not count" },
require_signed_commits: { kind: "boolean", says: "every commit must be signed" },
protected_file_patterns: { kind: "string", says: "files no push may change, ';'-separated globs" },
unprotected_file_patterns: { kind: "string", says: "files a push may change even without push rights, ';'-separated globs" },
block_admin_merge_override: { kind: "boolean", says: "true: an administrator cannot merge past the rule (the controller's trunk rule wants true)" },
enable_bypass_allowlist: { kind: "boolean", says: "whether the bypass allowlist may merge past the rule's checks" },
bypass_allowlist_usernames: { kind: "logins", says: "the logins that may merge past the rule's checks" },
bypass_allowlist_teams: { kind: "strings", says: "the teams that may merge past the rule's checks" },
priority: { kind: "number", says: "the rule's order among glob rules" },
};
/** The repository settings gitea_repo_settings_set_full sets (gitea's EditRepoOption at 1.27.3), by their own names. Left
* out: name (a rename moves every module's source address), the internal tracker, external tracker and external wiki
* objects, and mirror_username, mirror_password and mirror_token, which are credentials. The forge ignores a
* default_branch that does not exist; the read-back below says so. */
export const REPO_FIELDS: Record<string, Field> = Object.fromEntries(([
["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"],
["default_branch", "string"], ["has_code", "boolean"], ["has_issues", "boolean"], ["has_wiki", "boolean"],
["has_pull_requests", "boolean"], ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"],
["has_packages", "boolean"], ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"],
["allow_merge_commits", "boolean"], ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"],
["allow_squash_merge", "boolean"], ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"],
["autodetect_manual_merge", "boolean"], ["allow_rebase_update", "boolean"], ["allow_merge_update", "boolean"],
["default_update_style", "string"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"],
["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"],
] as [string, Kind][]).map(([name, kind]) => [name, {
kind, says: `the repository's ${name.replace(/_/g, " ")}`,
...(name === "projects_mode" ? { parent: "has_projects", says: "the repository's projects mode; needs has_projects: true in the same call" } : {}),
...(name === "enable_prune" ? { unread: true, says: "a mirror's prune setting; the forge never answers it, so it is not read back" } : {}),
...(name === "mirror_interval" ? { duration: true, says: "a mirror's interval, a Go duration such as 8h (the forge answers 8h0m0s)" } : {}),
}]));
/** A Go duration ("8h", "1h30m", "8h0m0s", "90s") in milliseconds; NaN when it is none. */
export function durationMs(v: unknown): number {
if (typeof v !== "string" || !/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(v)) return v === "0" ? 0 : NaN;
const unit: Record<string, number> = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1e3, m: 6e4, h: 3.6e6 };
let ms = 0;
for (const m of v.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) ms += Number(m[1]) * unit[m[2]];
return ms;
}
/** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it
* answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */
export function unapplied(sent: Record<string, unknown>, held: Record<string, unknown>, fields: Record<string, Field> = PROTECTION_FIELDS): string[] {
const norm = (k: string, v: unknown) => fields[k]?.duration ? String(durationMs(v))
: Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null);
return Object.keys(sent).filter((k) => !fields[k]?.unread && norm(k, sent[k]) !== norm(k, held[k]))
.map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`);
}
/** Fields that changed between two reads but were not sent: what the forge changed beside the call (enable_push: false
* clears the push whitelist flags). */
export function alsoChanged(before: Record<string, unknown> | null, after: Record<string, unknown>, sent: Record<string, unknown>): string[] {
if (!before) return [];
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
return [...keys].filter((k) => !(k in sent) && !["updated_at", "created_at"].includes(k) &&
JSON.stringify(before[k] ?? null) !== JSON.stringify(after[k] ?? null));
}
/** A forge login. */
const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
/** The forge body for the fields given: each checked against its kind, a field not given left out (the forge keeps it on
* an edit), an unknown field refused before anything is sent. Throws on the first wrong field. */
export function fullProtectionBody(args: Record<string, unknown>, given: string[],
fields: Record<string, Field> = PROTECTION_FIELDS, what = "a branch protection rule"): Record<string, unknown> {
const body: Record<string, unknown> = {};
for (const name of given) {
const field = fields[name];
if (!field) throw new Error(`${name} is no field of ${what}; the fields are ${Object.keys(fields).join(", ")}`);
// The parent false clears the field, so only a field false goes beside a parent false.
if (field.parent && args[field.parent] !== true && !(args[field.parent] === false && args[name] === false)) {
throw new Error(`${name} is read by the forge only when the same call sets ${field.parent}: true (gitea ignores it otherwise). Nothing was sent`);
}
const v = args[name];
switch (field.kind) {
case "boolean":
if (typeof v !== "boolean") throw new Error(`${name} is true or false`);
body[name] = v;
break;
case "number":
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) throw new Error(`${name} is a whole number, 0 or more`);
body[name] = v;
break;
case "string":
if (typeof v !== "string") throw new Error(`${name} is a string`);
if (field.duration && Number.isNaN(durationMs(v))) throw new Error(`${name} is a duration, such as 8h or 1h30m`);
body[name] = v;
break;
case "strings":
case "logins": {
if (!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.trim() === "")) throw new Error(`${name} is a list of names`);
const names = [...new Set((v as string[]).map((s) => s.trim()))];
if (field.kind === "logins") {
const bad = names.find((s) => !LOGIN.test(s) || s.includes(".."));
if (bad !== undefined) throw new Error(`${name}: ${JSON.stringify(bad)} is no forge login`);
}
body[name] = names;
break;
}
}
}
return body;
}
/** The fields of a call that are protection fields: every argument but owner, repo, branch and why. */
export function givenFields(args: Record<string, unknown>): string[] {
return Object.keys(args).filter((k) => !["owner", "repo", "branch", "why"].includes(k) && args[k] !== undefined);
}
/** A rule as the controller reads it from gitea_branch_protection_get (protectionRule in build_source.go). */
interface ControllerRule { rule: string; push: boolean; admin_may_override: boolean; merge_whitelist: string[] | null }
function asControllerRule(p: BranchProtection): ControllerRule {
return {
rule: String(p.rule_name ?? p.branch_name ?? ""),
push: p.enable_push ?? false,
admin_may_override: !(p.block_admin_merge_override ?? false),
merge_whitelist: p.enable_merge_whitelist ? (p.merge_whitelist_usernames ?? []) : null,
};
}
/** ruleCovers of build_source.go, as the controller reads a rule's glob: `**` any, `*` within a segment, `?` one. */
function controllerCovers(rule: string, branch: string): boolean {
if (rule === branch) return true;
if (!/[*?[]/.test(rule)) return false;
let re = "^";
for (let i = 0; i < rule.length; i++) {
const c = rule[i];
if (c === "*" && rule[i + 1] === "*") { re += ".*"; i++; }
else if (c === "*") re += "[^/]*";
else if (c === "?") re += "[^/]";
else re += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
return new RegExp(re + "$").test(branch);
}
/** Whether the controller registers a module from this branch, judged as judgedRule in mesh-control
* cmd/mesh-controller/build_source.go does (ADR 0534 §6): the rule named for the branch, else the first glob rule that
* covers it; it takes no push, its merge whitelist names mesh-tools and no agent-<node>, and no administrator merges
* past it. A mirror, not the judge: the controller's own verdict is the one that counts. */
export function trunkRule(rules: BranchProtection[], branch: string, trunks: string[] = [MAIN_BRANCH]): { holds: boolean; why: string; is_trunk: boolean } {
const judged = judgedTrunkRule(rules, branch);
return { ...judged, is_trunk: trunks.includes(branch) };
}
function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: boolean; why: string } {
const all = rules.map(asControllerRule);
const r = all.find((x) => x.rule === branch) ?? all.find((x) => controllerCovers(x.rule, branch));
const no = (why: string) => ({ holds: false, why });
if (!r) return no(`no protection rule covers ${branch}`);
if (r.push) return no(`the rule ${r.rule} lets a person push to ${branch} directly`);
if (!r.merge_whitelist || r.merge_whitelist.length === 0) {
return no(`the rule ${r.rule} keeps no merge whitelist naming ${TOOLS_ACCOUNT} alone for ${branch}`);
}
if (!r.merge_whitelist.includes(TOOLS_ACCOUNT)) {
return no(`the rule ${r.rule} lets ${r.merge_whitelist.join(", ")} merge into ${branch}, and not ${TOOLS_ACCOUNT}`);
}
if (r.merge_whitelist.some((l) => l.startsWith("agent-"))) {
return no(`the rule ${r.rule} lets an agent's account merge into ${branch}: ${r.merge_whitelist.join(", ")}`);
}
if (r.admin_may_override) return no(`the rule ${r.rule} lets an administrator merge into ${branch} past it`);
return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` };
}
/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */
export type SourceRead = () => Promise<Record<string, unknown>>;
/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each
* repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */
export const TRUNK_SOURCE = "mesh-delivery-gate.source";
/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's
* source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and
* `from` says which. The one place trunks are read: several trunks later change only this. */
export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> {
const full = `${owner}/${repo}`;
let src: Record<string, unknown>;
try {
src = await read();
} catch (err) {
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` };
}
const names = Object.keys(src).filter((k) => {
const heads = (src[k] as { heads?: Record<string, unknown> } | null)?.heads;
return !!heads && typeof heads === "object" && full in heads;
}).sort();
if (names.length) return { names, from: TRUNK_SOURCE };
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` };
}
/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */
export function sourceFromBus(get: (name: string) => { keys(): Promise<string[]>; get(k: string): Promise<{ value: unknown } | null> }): SourceRead {
return async () => {
const st = get(TRUNK_SOURCE);
const out: Record<string, unknown> = {};
for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null;
return out;
};
}
/** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28
* 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). No
* tool of this module can ask for that Approve yet, so such a call is refused with nothing sent. */
export function forcePushToTrunk(rule: string, body: Record<string, unknown>, trunks: string[]): string | null {
const covered = trunks.filter((t) => ruleCovers(rule, t));
if (!covered.length) return null;
const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true ||
(["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0);
if (!opens) return null;
return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: a force-push to a trunk is ` +
`the operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's ` +
`at the forge. Nothing was sent`;
}
+15 -1
View File
@@ -183,6 +183,15 @@ test("named paths: each asked path's git object id at the commit, a file's or 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");
// Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none.
const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]);
assert.deepEqual(listed.listed, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": {},
"": { "README.md": h("1"), "module.json": h("2") },
placement: {},
});
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed");
} finally {
server.close();
}
@@ -209,7 +218,12 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
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 arguments");
assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments");
const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any);
assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } });
for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad));
}
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;
-250
View File
@@ -1,250 +0,0 @@
// gitea_branch_protection_set_full (the operator, 2026-10-28: "just make a generic tool so you can setup any
// configuration"): any field of a branch protection rule, only the fields given, as the forge's administrator; it
// answers the rule before and after, and whether the controller's trunk rule (judgedRule, mesh-control
// build_source.go) now holds. Against a fake forge that keeps the rules and answers them to the administrator alone.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { GiteaAdmin, GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { trunkRule } from "../dist/protectfull.js";
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
let rules: any[] = [];
let repoState: any = {};
const branches = ["master", "main"];
let sent: { method: string; path: string; body: any }[] = [];
beforeEach(() => {
// de-spiegel's main as found: no push, but the operator alone merges and an administrator merges past it.
rules = [{ rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0,
enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false,
force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }];
source = {};
repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true };
sent = [];
});
// What gitea 1.27.3's EditBranchProtection does with a body (routers/api/v1/repo/branch.go): the push whitelist flags are
// read only beside enable_push true and cleared by enable_push false, the force-push allowlist flags likewise with
// enable_force_push; teams are passed over on a repository no organisation owns (de-spiegel here, for the test).
function giteaEdit(r: any, body: any) {
const nested = ["enable_push", "enable_push_whitelist", "push_whitelist_deploy_keys", "enable_force_push",
"enable_force_push_allowlist", "force_push_allowlist_deploy_keys", "merge_whitelist_teams", "push_whitelist_teams"];
for (const [k, v] of Object.entries(body)) if (!nested.includes(k) && k !== "rule_name") r[k] = v;
if (body.enable_push === false) Object.assign(r, { enable_push: false, enable_push_whitelist: false, push_whitelist_deploy_keys: false });
if (body.enable_push === true) {
r.enable_push = true;
if (body.enable_push_whitelist !== undefined) r.enable_push_whitelist = body.enable_push_whitelist;
if (body.push_whitelist_deploy_keys !== undefined) r.push_whitelist_deploy_keys = body.push_whitelist_deploy_keys;
}
if (body.enable_force_push === false) Object.assign(r, { enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false });
if (body.enable_force_push === true) {
r.enable_force_push = true;
if (body.enable_force_push_allowlist !== undefined) r.enable_force_push_allowlist = body.enable_force_push_allowlist;
if (body.force_push_allowlist_deploy_keys !== undefined) r.force_push_allowlist_deploy_keys = body.force_push_allowlist_deploy_keys;
}
}
const server = createServer((req, res) => {
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
if (req.headers.authorization !== adminBasic) return send(403, { message: "not an administrator of the repository" });
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
const body = raw ? JSON.parse(raw) : undefined;
if (req.method !== "GET") sent.push({ method: req.method!, path, body });
if (path === "/repos/novox/de-spiegel") {
if (req.method === "GET") return send(200, repoState);
if (req.method === "PATCH") {
// gitea's updateBasicProperties: a default branch that does not exist is passed over, the call still 200.
// enable_prune is a mirror's, kept beside the repository and never in its answer; mirror_interval is answered in Go's
// duration form.
const { default_branch, enable_prune, mirror_interval, ...rest } = body;
Object.assign(repoState, rest);
if (mirror_interval !== undefined) repoState.mirror_interval = mirror_interval === "8h" ? "8h0m0s" : mirror_interval;
if (default_branch !== undefined && branches.includes(default_branch)) repoState.default_branch = default_branch;
return send(200, repoState);
}
}
const m = path.match(/^\/repos\/novox\/de-spiegel\/branch_protections(?:\/(.+))?$/);
if (!m) return send(404, { message: "not found" });
if (req.method === "GET" && !m[1]) return send(200, rules);
if (req.method === "PATCH" && m[1]) {
const r = rules.find((x) => x.rule_name === decodeURIComponent(m[1]));
if (!r) return send(404, { message: "no rule" });
giteaEdit(r, body);
return send(200, r);
}
if (req.method === "POST" && !m[1]) { const r = { rule_name: body.rule_name }; giteaEdit(r, body); rules.push(r); return send(201, r); }
send(405, { message: "no" });
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((r) => server.close(() => r())));
const { port } = server.address() as { port: number };
const url = `http://127.0.0.1:${port}`;
// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories
// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel.
let source: Record<string, { heads: Record<string, string> }> = {};
const readSource = async () => source;
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource);
const setRepo = (args: Record<string, unknown>) =>
tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args });
const setFull = (args: Record<string, unknown>) =>
tools.find((t) => t.name === "gitea_branch_protection_set_full")!.run({ owner: "novox", repo: "de-spiegel", branch: "main", why: "L2", ...args });
test("it sets the merge whitelist and the administrator's override, only the fields given, and says the trunk rule now holds", async () => {
const out: any = await setFull({ merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true });
assert.deepEqual(sent, [{ method: "PATCH", path: "/repos/novox/de-spiegel/branch_protections/main",
body: { merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true } }]);
assert.deepEqual(out.before.merge_whitelist, ["jschoubben"]);
assert.equal(out.before.admin_may_override, true);
assert.deepEqual(out.after.merge_whitelist, ["mesh-tools", "jschoubben"]);
assert.equal(out.after.admin_may_override, false);
assert.equal(out.why, "L2");
assert.equal(out.trunk_rule.holds, true, out.trunk_rule.why);
});
test("it loosens as told too, and says the trunk rule no longer holds and why", async () => {
const out: any = await setFull({ enable_push: true, status_check_contexts: ["mesh/check"], enable_status_check: true, required_approvals: 1 });
assert.equal(out.after.push, true);
assert.deepEqual(out.after.required_statuses, ["mesh/check"]);
assert.equal(out.after.required_approvals, 1);
assert.equal(out.trunk_rule.holds, false);
assert.match(out.trunk_rule.why, /push to main directly/);
});
test("a branch with no rule gets one with the fields given", async () => {
const out: any = await setFull({ branch: "release/*", enable_push: false, protected_file_patterns: "*.lock" });
assert.equal(out.created, true);
assert.deepEqual(sent[0], { method: "POST", path: "/repos/novox/de-spiegel/branch_protections",
body: { rule_name: "release/*", enable_push: false, protected_file_patterns: "*.lock" } });
});
test("an unknown field, a field of the wrong kind, no field, no reason or a login that is none: refused, nothing sent", async () => {
await assert.rejects(setFull({ enable_pushes: true }), /no field of a branch protection rule/);
await assert.rejects(setFull({ enable_push: "yes" }), /true or false/);
await assert.rejects(setFull({ required_approvals: -1 }), /whole number/);
await assert.rejects(setFull({ merge_whitelist_usernames: ["../x"] }), /no forge login/);
await assert.rejects(setFull({}), /at least one field/);
await assert.rejects(setFull({ why: "", enable_push: false }), /say why/);
assert.deepEqual(sent, []);
});
// judgedRule's cases (mesh-control cmd/mesh-controller/build_source.go), each in its own words.
test("the trunk rule is judged as the controller judges it", () => {
const ok = { rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true };
const cases: [any[], string, boolean, RegExp][] = [
[[], "main", false, /no protection rule covers main/],
[[{ ...ok, enable_push: true }], "main", false, /push to main directly/],
[[{ ...ok, enable_merge_whitelist: false }], "main", false, /no merge whitelist/],
[[{ ...ok, merge_whitelist_usernames: ["jschoubben"] }], "main", false, /and not mesh-tools/],
[[{ ...ok, merge_whitelist_usernames: ["mesh-tools", "agent-g14"] }], "main", false, /agent's account/],
[[{ ...ok, block_admin_merge_override: false }], "main", false, /administrator/],
[[ok], "main", true, /takes no push/],
[[{ ...ok, rule_name: "ma*" }], "main", true, /ma\*/],
[[{ ...ok, rule_name: "ma*", enable_push: true }, { ...ok }], "main", true, /rule main/],
];
for (const [rs, branch, holds, why] of cases) {
const got = trunkRule(rs, branch);
assert.equal(got.holds, holds, `${JSON.stringify(rs)}: ${got.why}`);
assert.match(got.why, why);
}
});
// Review round 1 of feat/protect-as-trunk: gitea passes over a dependent field without its parent set true, and answers 200.
test("a field the forge reads only beside its parent is refused without it; with it, it is applied", async () => {
await assert.rejects(setFull({ enable_push_whitelist: true }), /only when the same call sets enable_push: true/);
await assert.rejects(setFull({ force_push_allowlist_deploy_keys: true, enable_force_push: false }), /enable_force_push: true/);
assert.deepEqual(sent, []);
const out: any = await setFull({ enable_push: true, enable_push_whitelist: true, push_whitelist_usernames: ["jschoubben"] });
assert.equal(out.after.fields.enable_push_whitelist, true);
await setFull({ enable_push: false, enable_push_whitelist: false }); // a false beside a false parent is what the forge does
});
test("a field the forge answered and did not apply fails the call, naming it; what it changed beside the call is said", async () => {
await assert.rejects(setFull({ merge_whitelist_teams: ["owners"] }), /did not apply merge_whitelist_teams \(sent \["owners"\], the forge holds \[\]\)/);
rules[0].enable_push = true; rules[0].enable_push_whitelist = true;
const out: any = await setFull({ enable_push: false });
assert.deepEqual(out.also_changed, ["enable_push_whitelist"]);
});
test("gitea_repo_settings_set_full sets the default branch, and fails loudly on a branch the forge passed over", async () => {
const out: any = await setRepo({ default_branch: "main" });
assert.deepEqual(sent.at(-1), { method: "PATCH", path: "/repos/novox/de-spiegel", body: { default_branch: "main" } });
assert.equal(out.before.default_branch, "master");
assert.equal(out.after.default_branch, "main");
await assert.rejects(setRepo({ default_branch: "trunk" }), /did not apply default_branch/);
await assert.rejects(setRepo({ name: "x" }), /no field of a repository's settings/);
await assert.rejects(setRepo({ why: "", has_wiki: false }), /say why/);
});
test("every field gitea 1.27.3 edits on a rule is taken: 32, the bypass allowlist among them", async () => {
const { PROTECTION_FIELDS } = await import("../dist/protectfull.js");
assert.equal(Object.keys(PROTECTION_FIELDS).length, 32);
for (const f of ["enable_bypass_allowlist", "bypass_allowlist_usernames", "bypass_allowlist_teams"]) assert.ok(f in PROTECTION_FIELDS, f);
});
// Review round 2: the settings tool's read-back meets the forge's own answer.
test("A: enable_prune, which the forge never answers, is said as not read back, never failed", async () => {
const pruned: any = await setRepo({ enable_prune: true });
assert.deepEqual(pruned.not_read_back, ["enable_prune"]);
});
test("B: mirror_interval is compared as a duration: 8h and the forge's 8h0m0s are one", async () => {
const mirror: any = await setRepo({ mirror_interval: "8h" });
assert.equal(mirror.after.mirror_interval, "8h0m0s");
await assert.rejects(setRepo({ mirror_interval: "often" }), /duration/);
});
test("C: allow_merge_update, default_update_style and has_code are taken; projects_mode needs has_projects; what is left out is named", async () => {
const more: any = await setRepo({ allow_merge_update: true, default_update_style: "rebase", has_code: true });
assert.deepEqual(more.set, { allow_merge_update: true, default_update_style: "rebase", has_code: true });
await assert.rejects(setRepo({ projects_mode: "repo" }), /only when the same call sets has_projects: true/);
await setRepo({ has_projects: true, projects_mode: "repo" });
const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description;
for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w));
});
// The operator, 2026-10-28 16:4x: a call waits for their Approve only if it removes branch protection or allows a
// force-push to main. No tool of this module can ask yet (no TypeScript asker; ADR 0286's mark is unbuilt in the
// runner), so until one can, such a call is refused, with nothing sent, and says it is the operator's; everything else
// runs at once.
test("a force-push onto a rule covering main is refused, nothing sent; on another branch, and tightening, it runs", async () => {
for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true },
{ force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] },
{ branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) {
await assert.rejects(setFull(args), /force-push to a trunk is the operator's/, JSON.stringify(args));
}
assert.deepEqual(sent, []);
await setFull({ branch: "release/*", enable_force_push: true });
const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] });
assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer");
});
// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as
// the gate's source names them; "main" only when it names none, and said.
test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => {
source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } };
await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/);
await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/);
assert.deepEqual(sent, []);
const main: any = await setFull({ enable_force_push: true });
assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" });
assert.equal(main.trunk_rule.is_trunk, false);
const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true });
assert.equal(beta.trunk_rule.is_trunk, true);
assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why);
});
test("a source that names no trunk of the repository falls back to main, and says so", async () => {
const out: any = await setFull({ block_admin_merge_override: true });
assert.equal(out.trunks.names[0], "main");
assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/);
assert.equal(out.trunk_rule.is_trunk, true);
});
+1 -1
View File
@@ -538,7 +538,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_get_file",
"gitea_list_branches",
"gitea_delete_branch",
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full",
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set",
"gitea_default_branch_set", "gitea_pull_base_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
"gitea_list_labels", "gitea_create_label",
+19 -103
View File
@@ -12,8 +12,6 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
import { emit } from "@novox/mesh-sdk/events";
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js";
import { readTeamRights } from "../teamrights.js";
import { state } from "@novox/mesh-sdk/state";
import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js";
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
@@ -79,13 +77,24 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
return out;
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin, source));
/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root
* the empty name); anything else is refused before the forge is asked. Absent is absent. */
function listedDirs(raw: unknown): string[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]");
for (const d of raw as string[]) {
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`);
}
return raw as string[];
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
}
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
// writer (novox/hq issue 456, ADR 0323).
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] {
function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return [
// ---- Repositories ----
{
@@ -422,9 +431,8 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo);
// Read as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the repository
// administrator's, which the tools' account is not. gitea_team_rights reads, and gitea_branch_protection_set_full
// and gitea_repo_settings_set_full write, with the same account.
// The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
// repository administrator's, which the tools' account is not. No other tool uses this account.
const rules = await (admin ? admin() : gitea).branchProtections(owner, repo);
if (!args.branch) return { rules: rules.map(summarised) };
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null;
@@ -480,99 +488,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
},
},
{
name: "gitea_branch_protection_set_full",
description: "Set any field of a branch protection rule — protect a branch as a trunk, set its merge whitelist (users and teams), " +
"push and push whitelist, force push and its allowlist, the bypass allowlist, required status checks, required approvals and " +
"the approvals whitelist, block on rejected reviews, block on an outdated branch, dismiss stale approvals, signed commits, " +
"protected and unprotected file patterns, and the administrator's merge override (block_admin_merge_override): the 32 " +
"fields gitea 1.27.3's API edits, by their own names. Only the fields given change; a branch with no rule gets one. " +
"enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " +
"flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " +
"forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " +
"tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " +
"administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " +
"this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " +
"agent-<node>, no administrator override) and why not.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch (the rule's name, or a glob) whose rule is set" },
why: { type: "string", description: "why, in a sentence: logged and answered" },
...Object.fromEntries(Object.entries(PROTECTION_FIELDS).map(([name, f]) => [name, f.kind === "logins" || f.kind === "strings"
? { type: "array", items: { type: "string" }, description: f.says }
: { type: f.kind === "number" ? "number" : f.kind, description: f.says }])),
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
const why = String(args.why ?? "").trim();
if (!branch) throw new Error("name the branch whose rule is set");
if (!why) throw new Error("say why (why): a branch's protection is changed for a reason, which is logged");
pathSegments("branch", branch);
const given = givenFields(args);
if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`);
const body = fullProtectionBody(args, given);
const trunks = await trunksOf(owner, repo, source);
const operators = forcePushToTrunk(branch, body, trunks.names);
if (operators) throw new Refused(operators);
const forge = admin ? admin() : gitea;
const rules = await forge.branchProtections(owner, repo);
const before = rules.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null;
console.log(`[gitea] gitea_branch_protection_set_full ${owner}/${repo} ${branch}: ${JSON.stringify(body)} — why: ${why}`);
const rule = await forge.writeBranchProtection(owner, repo, branch, body, !before);
const after = await forge.branchProtections(owner, repo);
const read = after.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? rule;
const missed = unapplied(body, read);
if (missed.length) {
throw new Error(`the forge answered the write of ${owner}/${repo} ${branch}'s rule but did not apply ${missed.join("; ")}. ` +
`The rule it holds now: ${JSON.stringify(summarised(read))}`);
}
return {
branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body),
before: before ? { ...summarised(before), fields: before } : null,
after: { ...summarised(read), fields: read },
trunks, trunk_rule: trunkRule(after, branch, trunks.names),
};
},
},
{
name: "gitea_repo_settings_set_full",
description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " +
"description, website, private, template, archived, the issues, wiki, pull requests, projects, releases, packages and " +
"actions units, the merge styles it allows and its default merge style, delete the branch after merge, and the mirror " +
"settings: gitea 1.27.3's EditRepoOption by its own names. Left out: name (a rename moves every module's source " +
"address), the internal tracker, external tracker and external wiki objects, and mirror_username, mirror_password " +
"and mirror_token, which are credentials. projects_mode needs has_projects: true in the same call. Only the fields " +
"given change. The repository is read back: a field the forge did not apply (a default branch that does not exist) " +
"fails the call, naming it; mirror_interval is compared as a duration, and enable_prune, which the forge never " +
"answers, is listed in not_read_back. A reason (why) is required and logged. Answers the settings before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
why: { type: "string", description: "why, in a sentence: logged and answered" },
...Object.fromEntries(Object.entries(REPO_FIELDS).map(([name, f]) => [name, { type: f.kind, description: f.says }])),
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), why = String(args.why ?? "").trim();
if (!why) throw new Error("say why (why): a repository's settings are changed for a reason, which is logged");
if (!admin) throw new Error("the forge's administrator login is not set: a repository's settings are written with it");
const given = givenFields(args);
if (!given.length) throw new Error(`name at least one setting: ${Object.keys(REPO_FIELDS).join(", ")}`);
const body = fullProtectionBody(args, given, REPO_FIELDS, "a repository's settings");
const forge = admin();
const before = await forge.repoSettings(owner, repo);
console.log(`[gitea] gitea_repo_settings_set_full ${owner}/${repo}: ${JSON.stringify(body)} — why: ${why}`);
await forge.editRepoSettings(owner, repo, body);
const after = await forge.repoSettings(owner, repo);
const missed = unapplied(body, after, REPO_FIELDS);
if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo}'s settings but did not apply ${missed.join("; ")}`);
const pick = (r: Record<string, unknown>) => Object.fromEntries(Object.keys(REPO_FIELDS).filter((k) => k in r).map((k) => [k, r[k]]));
return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after),
not_read_back: Object.keys(body).filter((k) => REPO_FIELDS[k]?.unread) };
},
},
// ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ----
{
name: "gitea_default_branch_set",
@@ -722,19 +637,20 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
},
{
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). 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. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalog's placement files, novox/hq ADR 0549 §2). 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" },
listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" },
},
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, namedPaths(args.paths), listedDirs(args.listed));
},
},
{
+1 -17
View File
@@ -53,22 +53,6 @@ built for the fallback kernel too) after its boot-entries service, so the entry
| 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 |
## Kernel modules at boot, and the running kernel's modules kept
A machine that runs for weeks meets a kernel upgrade that removes `/usr/lib/modules/<the running kernel>`: from then on
no module that was not loaded yet can be loaded until the next boot. On 2026-10-28 a build node, up 24 days on
`linux-lts` 6.18.54 with only 6.18.58's modules on disk, had `/dev/net/tun` and no tun driver, and every image build
there failed in buildah's network setup ("Failed to open() /dev/net/tun: No such device").
| | what | notes |
|---|---|---|
| file | `/etc/modules-load.d/mesh-kernel.conf` | the kernel modules loaded at every boot by systemd-modules-load: `tun` (pasta, which buildah uses for a RUN line's network, and every user-space tunnel open `/dev/net/tun`). Read at boot only: nothing is loaded by an apply |
| package | `kernel-modules-hook` | keeps the running kernel's modules across an upgrade (saved before the transaction, put back after), so a module first needed later still loads |
| service | `linux-modules-cleanup.service` | the package's oneshot, enabled at boot and stopped otherwise: it moves the modules of a kernel that is neither running nor owned by a package to `/usr/lib/modules/.old`, which the package's tmpfiles rule empties after four weeks. An apply never starts it |
A machine whose running kernel's modules are already gone loads nothing new until it is started again; the image build
module says so in its health and takes no image build there meanwhile.
## Tools
| tool | | answers |
@@ -79,7 +63,7 @@ module says so in its health and takes no image build there meanwhile.
## 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 kernel modules loaded at boot (`tun`) and the package and boot service that keep the running kernel's modules; 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); 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
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
@@ -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 modules-load modules-keep modules-cleanup" {
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
t.Fatalf("order: %v", ids)
}
script := m.resource(t, "huge-pages")
@@ -57,31 +57,6 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
if h := apply["health"].(map[string]any); h["kind"] != "unit" {
t.Fatalf("health: %v", h)
}
// A build node, 2026-10-28: up 24 days on linux-lts 6.18.54 after an upgrade to 6.18.58 removed the running kernel's modules,
// so tun could not be loaded when a build first asked for it. tun is loaded at every boot, before an upgrade can take
// its file away, and the running kernel's modules are kept across an upgrade (kernel-modules-hook) and cleaned up at
// the next boot.
load := m.resource(t, "modules-load")
if load["path"] != "/etc/modules-load.d/mesh-kernel.conf" || load["mode"] != "0644" {
t.Fatalf("modules-load: %v", load)
}
var names []string
for _, l := range strings.Split(load.text(t), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
names = append(names, l)
}
}
if strings.Join(names, " ") != "tun" {
t.Fatalf("the modules loaded at boot: %v", names)
}
if keep := m.resource(t, "modules-keep"); keep["type"] != "package" || keep["package"] != "kernel-modules-hook" {
t.Fatalf("modules-keep: %v", keep)
}
// A oneshot at boot: enabled, and stopped once it ran, never started by an apply (it moves the modules of kernels no
// package owns away).
if c := m.resource(t, "modules-cleanup"); c["unit"] != "linux-modules-cleanup.service" || c["state"] != "stopped" || c["boot"] != "enabled" {
t.Fatalf("modules-cleanup: %v", c)
}
}
// bed is the script as the mesh writes it for one value of the setting, a stand-in transparent_hugepage
-4
View File
@@ -1,4 +0,0 @@
# Written by the mesh's kernel module (systemd-modules-load reads it at boot).
# tun: buildah's network for a RUN line (pasta) and every other user-space tunnel open /dev/net/tun.
# Loaded at boot, so an upgrade that removes the running kernel's modules cannot keep it out later.
tun
-19
View File
@@ -100,25 +100,6 @@
"id": "linux-lts-headers",
"type": "package",
"package": "linux-lts-headers"
},
{
"id": "modules-load",
"type": "file",
"path": "/etc/modules-load.d/mesh-kernel.conf",
"mode": "0644",
"template": "files/modules-load.conf"
},
{
"id": "modules-keep",
"type": "package",
"package": "kernel-modules-hook"
},
{
"id": "modules-cleanup",
"type": "service",
"unit": "linux-modules-cleanup.service",
"state": "stopped",
"boot": "enabled"
}
],
"build": {
@@ -87,13 +87,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (a node may build only in a window): outside them it takes
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new.
var cores func() float64
var hours func() (*buildstep.BuildWindow, string)
if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say)
}
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"regexp"
"slices"
"strings"
"testing"
@@ -127,8 +126,7 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct {
Config map[string]struct {
Default json.RawMessage `json:"default"`
Pattern, Description string
Default json.RawMessage `json:"default"`
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
@@ -146,20 +144,10 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
}
// ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0.
window := regexp.MustCompile("^(?:" + doc.Config[buildstep.BuildHoursSetting].Pattern + ")$")
for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} {
if window.MatchString(value) != ok {
t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok)
}
}
if strings.Contains(doc.Config[buildstep.BuildHoursSetting].Description, "=") {
t.Errorf("the build-hours description names a node pair")
}
var capacity float64
var capacity string
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != 0 {
t.Errorf("the capacity's default = %g, want 0", capacity)
if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q, want %q", capacity, buildstep.DefaultCapacities)
}
settings := resource(t, m, "settings")
if settings["path"] != "${dir:state}/settings.json" {
@@ -73,7 +73,6 @@ func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) {
states := newStates()
m := moduleWith(states, "go1.26.0")
m.Work, m.Cache, m.Inside = t.TempDir(), t.TempDir(), true
m.Capacity = func() int { return 1 } // its own slot: no settings file, and a node with no capacity set pulls nothing (ADR 0567 §15)
m.Run = StepRunner(t.TempDir())
m.GoModCache = goEnvOf(t, "GOMODCACHE") // the module cache this module's own build filled, read and not fetched
run, gocache := m.Run, goEnvOf(t, "GOCACHE") // this machine's build cache, so the composer's packages compile once
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)()
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
return buildstep.NodeLimits(slots)
}
@@ -37,10 +37,6 @@ const DigestVersion = "6"
// depends on the siblings' commits.
const besideVariable = "MESH_CHECK_BESIDE"
// namesVariable is the same for the mesh's node names: a package whose tests reach a file naming it depends on the node
// list the ask carries, so its digest holds the sorted names and a new node misses a kept verdict (ADR 0567 §15).
const namesVariable = "MESH_NODE_NAMES"
// ReadsOutsideMarker is the name of a file in a package's directory that says its tests read files outside it (a
// repository's documents, a sibling directory), which the Go toolchain does not list (issue 1935). It lists, one per
// line, what those tests read, as paths from the repository's root; a path may be a glob (filepath.Match), and a
@@ -168,8 +164,6 @@ func (m *Module) digestTool() StepTool {
"workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"},
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
"runner": runnerProperty,
delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "optional, the names of the mesh's nodes: a package whose tests read " + delivery.NodeNamesEnv + " has them in its digest"},
},
"required": []string{"workspace", "level"},
},
@@ -184,10 +178,6 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
if err != nil {
return nil, err
}
names, err := nodeNamesArg(args[delivery.NodeNamesArg])
if err != nil {
return nil, err
}
if runner == RunnerWorkspace {
if err := m.workspaceThere(workspace); err != nil {
return nil, err
@@ -201,7 +191,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
default:
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
}
a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace, NodeNames: names})
a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace})
if err != nil {
return nil, err
}
@@ -211,7 +201,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
a.Digest.JudgedBy = judged
return *a.Digest, nil
}
res, err := m.digestHere(ctx, workspace, level, names)
res, err := m.digestHere(ctx, workspace, level)
if err != nil {
return nil, err
}
@@ -220,7 +210,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
}
// digestHere is the digest step as this program runs it.
func (m *Module) digestHere(ctx context.Context, workspace, level string, names []string) (DigestResult, error) {
func (m *Module) digestHere(ctx context.Context, workspace, level string) (DigestResult, error) {
if !workspaceOK.MatchString(workspace) {
return DigestResult{}, fmt.Errorf("%q is not a workspace id", workspace)
}
@@ -251,14 +241,6 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string, names
besideHeader = append(besideHeader, "beside "+b)
}
// The node names, sorted, as a header line for a package that reads them.
sortedNames := append([]string{}, names...)
sort.Strings(sortedNames)
var namesHeader []string
if len(sortedNames) > 0 {
namesHeader = []string{"node-names " + strings.Join(sortedNames, ",")}
}
dirs, err := m.goModDirs(ctx, tree)
if err != nil {
return DigestResult{}, err
@@ -269,7 +251,7 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string, names
if err != nil {
return DigestResult{}, err
}
if err := digestModule(&res, pkgs, tree, header, besideHeader, namesHeader, level == LevelIntegration); err != nil {
if err := digestModule(&res, pkgs, tree, header, besideHeader, level == LevelIntegration); err != nil {
return DigestResult{}, err
}
}
@@ -320,7 +302,7 @@ func parsePackages(out string) (map[string]*goPackage, error) {
// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main
// module that is listed, not only a dependency, and not a variant of another.
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside, names []string, needs bool) error {
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string, needs bool) error {
roots := []string{}
for path, p := range pkgs {
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
@@ -367,7 +349,7 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
res.neverKept[path] = why
continue
}
d, err := packageDigest(root, testMain, pkgs, tree, header, beside, names, declared)
d, err := packageDigest(root, testMain, pkgs, tree, header, beside, declared)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
@@ -379,9 +361,9 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
// packageDigest is the digest of one package: a hash over a sorted text of the header, the files of every package its
// tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of
// every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header
// only when a file the tests reach names MESH_CHECK_BESIDE, and the node names likewise for MESH_NODE_NAMES. A package of the standard library is the Go version's.
// only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's.
// The lines a package's ReadsOutsideMarker declares join them (declaredReads).
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, names, declared []string) (string, error) {
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, declared []string) (string, error) {
reached := map[string]*goPackage{root.ImportPath: root}
for _, dep := range testMain.Deps {
if p := pkgs[dep]; p != nil {
@@ -389,7 +371,7 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
}
}
lines := append([]string{}, declared...)
readsBeside, readsNames := false, false
readsBeside := false
for _, p := range reached {
if p.Standard || p.Module == nil {
continue
@@ -408,10 +390,9 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
return "", err
}
lines = append(lines, l)
if !readsBeside || !readsNames {
if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil {
readsBeside = readsBeside || bytes.Contains(raw, []byte(besideVariable))
readsNames = readsNames || bytes.Contains(raw, []byte(namesVariable))
if !readsBeside {
if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil && bytes.Contains(raw, []byte(besideVariable)) {
readsBeside = true
}
}
}
@@ -443,9 +424,6 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
if readsBeside {
header = append(append([]string{}, header...), beside...)
}
if readsNames {
header = append(append([]string{}, header...), names...)
}
h := sha256.New()
for _, l := range append(append([]string{}, header...), lines...) {
h.Write([]byte(l + "\n"))
@@ -172,9 +172,8 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
root := &goPackage{ImportPath: "x/r", Dir: dir, Module: &goModule{Path: "x", Main: true}, GoFiles: []string{"r.go"}, TestGoFiles: []string{"r.go"}}
testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}}
pkgs[root.ImportPath] = root
var names []string
digest := func() string {
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, names, nil)
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, nil)
if err != nil {
t.Fatal(err)
}
@@ -189,20 +188,6 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
if d3 := digest(); d3 == d1 {
t.Errorf("a new version left the digest as it was")
}
// A different node list misses the cache only for a package whose tests read MESH_NODE_NAMES (ADR 0567 §15).
names = []string{"node-names a"}
d4 := digest()
names = []string{"node-names a", "node-names b"}
if d5 := digest(); d5 != d4 {
t.Errorf("a package that does not read the node names changed with them")
}
os.WriteFile(filepath.Join(dir, "r.go"), []byte("package r\n// "+namesVariable+"\n"), 0o644)
names = []string{"node-names a"}
d6 := digest()
names = []string{"node-names a", "node-names b"}
if d7 := digest(); d7 == d6 {
t.Errorf("a new node left the digest of a package that reads the node names as it was")
}
}
// Issue 1935, 1936: a package whose reads-outside marker lists nothing it can trust is never kept: its digest is one no
@@ -72,7 +72,7 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa
return nil, fmt.Errorf("the integration level's bus: %w", err)
}
defer stopBus()
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace, nil)...)
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace)...)
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
// Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory.
@@ -43,7 +43,6 @@ type levelSpec struct {
Run string `json:"run,omitempty"`
Setup time.Duration `json:"setup"`
Bound time.Duration `json:"bound"`
NodeNames []string `json:"node-names,omitempty"`
}
// levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run.
@@ -222,14 +221,14 @@ func (m *Module) answer(ctx context.Context, spec levelSpec) levelAnswer {
tree := filepath.Join(m.Work, spec.Workspace, "tree")
switch spec.Step {
case stepDigest:
d, err := m.digestHere(ctx, spec.Workspace, spec.Level, spec.NodeNames)
d, err := m.digestHere(ctx, spec.Workspace, spec.Level)
if err != nil {
return levelAnswer{Error: err.Error()}
}
return levelAnswer{Digest: &d}
case stepUnit:
res, err := m.runLevel(ctx, tree, levelOpts{workspace: spec.Workspace, level: LevelUnit, packages: spec.Packages, run: spec.Run,
bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace, spec.NodeNames)})
bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace)})
if err != nil {
return levelAnswer{Error: err.Error()}
}
@@ -2,10 +2,7 @@ package main
import (
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Issue 1309: a test that runs a step in bubblewrap is proof only where it runs. Where a build module judges (it hands
@@ -32,12 +29,8 @@ func TestAMissingToolSkipsExceptBubblewrapOnABuildNode(t *testing.T) {
// The levels a build module judges hand the tests the build node mark, with the siblings they read.
func TestEveryLevelHandsTheTestsTheBuildNodeMark(t *testing.T) {
m := &Module{Work: t.TempDir()}
if env := m.checkEnv("ws1", nil); !slices.Contains(env, BuildNodeEnv+"=1") || slices.ContainsFunc(env, func(e string) bool { return strings.HasPrefix(e, delivery.NodeNamesEnv) }) {
t.Errorf("env %v lacks %s=1, or names nodes when none were asked", env, BuildNodeEnv)
}
// And the mesh's node names, when the ask carried them (ADR 0567 §12).
if env := m.checkEnv("ws1", []string{"a", "b"}); !slices.Contains(env, delivery.NodeNamesEnv+"=a,b") {
t.Errorf("env %v lacks the node names", env)
if env := m.checkEnv("ws1"); !slices.Contains(env, BuildNodeEnv+"=1") {
t.Errorf("env %v lacks %s=1", env, BuildNodeEnv)
}
}
@@ -16,7 +16,6 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -39,9 +38,7 @@ func (m *Module) testTool() StepTool {
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
"packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "package patterns of each module, ./... when none"},
"run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"},
delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "optional, the names of the mesh's nodes: the tests get them comma-separated in " + delivery.NodeNamesEnv + " (a check that keeps node names out of the repository reads them there)"},
"run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"},
"runner": runnerProperty,
},
"required": []string{"workspace", "level"},
@@ -81,16 +78,12 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
if err != nil {
return nil, err
}
names, err := nodeNamesArg(args[delivery.NodeNamesArg])
if err != nil {
return nil, err
}
var res *TestResult
switch {
case runner == RunnerWorkspace && (level == LevelUnit || level == LevelIntegration):
return m.testAsCandidate(ctx, workspace, level, packages, pattern, names)
return m.testAsCandidate(ctx, workspace, level, packages, pattern)
case level == LevelUnit:
res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace, names)})
res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace)})
case level == LevelIntegration:
res, err = m.integration(ctx, workspace, tree, packages, pattern)
default:
@@ -105,14 +98,14 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
// testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the
// installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process.
func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) {
spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound(), NodeNames: names}
func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string) (*TestResult, error) {
spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound()}
if level == LevelIntegration {
if _, err := m.checkPostgres(ctx); err != nil {
return nil, err // a *buildstep.Refusal: this level only
}
b := m.integrationBounds()
spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run, NodeNames: names}
spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run}
}
a, judged, err := m.asCandidate(ctx, spec)
if err != nil {
@@ -139,35 +132,9 @@ func (m *Module) besideEnv(workspace string) []string {
// and present, so a test that needs it fails where it is missing instead of skipping (issue 1309).
const BuildNodeEnv = "MESH_BUILD_NODE"
// checkEnv is what every level hands the tests it runs: the build node mark, the siblings (besideEnv) and, when the
// ask carried them, the mesh's node names (delivery.NodeNamesEnv, novox/hq ADR 0567 §12).
func (m *Module) checkEnv(workspace string, names []string) []string {
env := append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...)
if len(names) > 0 {
env = append(env, delivery.NodeNamesEnv+"="+strings.Join(names, ","))
}
return env
}
// nodeNamesArg reads the node-names argument: a list of names, absent when none.
func nodeNamesArg(raw any) ([]string, error) {
switch v := raw.(type) {
case nil:
return nil, nil
case []string:
return v, nil
case []any:
var out []string
for _, e := range v {
n, ok := e.(string)
if !ok || n == "" || strings.ContainsAny(n, ", \n") {
return nil, fmt.Errorf("%s is a list of node names, not %v", delivery.NodeNamesArg, e)
}
out = append(out, n)
}
return out, nil
}
return nil, fmt.Errorf("%s is a list of node names", delivery.NodeNamesArg)
// checkEnv is what every level hands the tests it runs: the build node mark and the siblings (besideEnv).
func (m *Module) checkEnv(workspace string) []string {
return append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...)
}
// packagesArg reads the packages argument: a list of package patterns, as []any when it came over the bus as JSON and
+1 -1
View File
@@ -1,5 +1,5 @@
{
"capacity": 0,
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 6,
"build-hours": ""
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+6 -6
View File
@@ -25,15 +25,15 @@
],
"config": {
"capacity": {
"type": "number",
"description": "How many asks of the Go build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.",
"minimum": 0,
"default": 0
"type": "string",
"description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0"
},
"build-hours": {
"type": "string",
"description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.",
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]",
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": ""
},
"cores-per-step": {
+1 -8
View File
@@ -16,14 +16,7 @@ credential and an environment built from scratch.
The account's subordinate user and group ranges are not declared anywhere: the node-engine makes every `user` account
with `useradd`, which allocates them. The module's health reads `/etc/subuid` and `/etc/subgid` and is unhealthy
when the module cannot run: without a range for the account, without buildah, when a user namespace cannot be made,
or when the disk is under its floor. A node where `/dev/net/tun` cannot be opened stays **healthy** and is **not
eligible** (buildah makes a RUN line's network with pasta, which needs the tun driver; on 2026-10-28 a build node had
the device node and no driver, so every image build there failed with "Failed to open() /dev/net/tun: No such
device"): the seat's puller judges by a capacity of 0 with that reason (`capacity.<node>`, which `build_usage` shows),
so the asks go to a node that can build them, and its health says "not eligible" with the cause, so a node that
cannot build here now never holds the module's delivery for every node. The kernel module loads `tun` at boot; a look
that passes again wakes the puller.
without a range for the account, without buildah, or when a user namespace cannot be made.
The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key.
@@ -43,13 +43,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (a node may build only in a window): outside them it takes
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new.
var cores func() float64
var hours func() (*buildstep.BuildWindow, string)
if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say)
}
@@ -59,7 +59,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
}
return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Eligible: m.Eligible, Logf: m.Say,
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
@@ -71,17 +71,4 @@ func TestAPulledImageAskCopiesThenBuilds(t *testing.T) {
t.Errorf("%s.%s is not served", Seat, verb)
}
}
// A node without /dev/net/tun (a build node, 2026-10-28) takes no ask: its capacity is 0 and says why, so the ask goes to a node
// that can build it instead of failing in buildah's network setup; once it is there again, the capacity is back.
tun := m.Tun
m.Tun = t.TempDir() + "/none"
_ = m.Check(context.Background())
if c := p.CapacityDetail(); c.Effective != 0 || !strings.Contains(c.Why, "not eligible: "+m.Tun+" cannot be opened") {
t.Errorf("without /dev/net/tun the capacity = %+v", c)
}
m.Tun = tun
_ = m.Check(context.Background())
if c := p.CapacityDetail(); c.Effective != 1 || c.Ineligible != "" {
t.Errorf("with /dev/net/tun again the capacity = %+v", c)
}
}
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)()
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
return buildstep.NodeLimits(slots)
}
@@ -190,7 +190,6 @@ func moduleWith(t *testing.T, f *fake, states *keptStates) *Module {
SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"),
SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"),
RegistryFile: write("registry", f.address()+"\n"),
Tun: write("tun", ""),
RegistryWait: 2 * time.Second,
RegistryPause: 5 * time.Millisecond,
}
@@ -1,10 +1,8 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
@@ -123,30 +121,6 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
if strings.TrimSpace(string(tpl)) != "${seat:mesh-artifact-store:reach}" {
t.Errorf("registry template = %q", tpl)
}
raw, _ = os.ReadFile(filepath.Join(moduleDir, "module.json"))
var doc struct {
Config map[string]struct {
Default json.RawMessage `json:"default"`
Pattern, Description string
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
var capacity float64 = -1
_ = json.Unmarshal(doc.Config["capacity"].Default, &capacity)
if capacity != 0 {
t.Errorf("the capacity's default = %g, want 0", capacity)
}
// ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0.
window := regexp.MustCompile("^(?:" + doc.Config["build-hours"].Pattern + ")$")
for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} {
if window.MatchString(value) != ok {
t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok)
}
}
if strings.Contains(doc.Config["build-hours"].Description, "=") {
t.Errorf("the build-hours description names a node pair")
}
}
// Every tool the manifest names is served, and every tool served is named.
@@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
@@ -39,8 +38,6 @@ type Module struct {
Work, Store string
// SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty.
SubUIDFile, SubGIDFile string
// Tun is the tun device buildah's network for a RUN line (pasta) opens; TunDevice when empty.
Tun string
// RegistryFile holds the registry's address, host:port, which the manifest binds from the artifact-store provision.
RegistryFile string
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
@@ -65,10 +62,6 @@ type Module struct {
spacesOnce sync.Once
workspaces *buildstep.Workspaces
// lastMu guards last: the refusal of the last look ("" when it could build), which Eligible answers.
lastMu sync.Mutex
last string
}
// Look is what the module found out about its machine.
@@ -97,65 +90,15 @@ func (m *Module) look(ctx context.Context) (Look, error) {
if err := m.userNamespace(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
if err := m.tunDevice(); err != nil {
return l, &notEligible{&buildstep.Refusal{Text: err.Error(), Have: l.Buildah}}
}
return l, nil
}
// notEligible is a refusal of a machine that runs the module fine but cannot build here now (no tun device: its asks go
// to another node). It is a *buildstep.Refusal for a step, and the health stays healthy for it (issue 1126): the
// assigner reads unhealthy as a failed delivery and holds the build for every node. What cannot run (buildah missing,
// no ranges, no namespace, no runtime directory, no room) stays unhealthy.
type notEligible struct{ *buildstep.Refusal }
func (e *notEligible) Unwrap() error { return e.Refusal }
// isNotEligible says whether err is a refusal that leaves the module healthy.
func isNotEligible(err error) bool {
var n *notEligible
return errors.As(err, &n)
}
// Check is the guard: nil when the machine can build, else the refusal. Every look is kept for Eligible, and a change
// wakes the puller, so a node that can build again takes asks at once.
// Check is the guard: nil when the machine can build, else the refusal.
func (m *Module) Check(ctx context.Context) error {
_, err := m.look(ctx)
m.keep(err)
return err
}
// keep records a look's outcome for Eligible and wakes the puller when it changed.
func (m *Module) keep(err error) {
why := ""
if err != nil {
why = err.Error()
}
m.lastMu.Lock()
changed := m.last != why
m.last = why
m.lastMu.Unlock()
if changed && m.Say != nil {
if why == "" {
m.Say("this machine is eligible to build images again")
} else {
m.Say("this machine takes no image build now: %s", why)
}
}
if changed && m.Held != nil {
m.Held.Wake()
}
}
// Eligible is the puller's eligibility (buildstep.Puller.Eligible): the refusal of the module's last look, "" when it
// could build. While the machine cannot build, the node's capacity is 0 and says why, so its asks go to a node that can
// build them instead of failing here (a build node without /dev/net/tun, 2026-10-28).
func (m *Module) Eligible() string {
m.lastMu.Lock()
defer m.lastMu.Unlock()
return m.last
}
// Start looks once, as the module starts, and then every CheckRound until ctx ends; it also removes old workspaces.
func (m *Module) Start(ctx context.Context) {
for {
@@ -176,25 +119,20 @@ func (m *Module) Start(ctx context.Context) {
// healthy it names the buildah installed and the account's ranges (ADR 0487 §2: the ranges are proven here, not declared).
func (m *Module) Health(ctx context.Context) health.Answer {
l, err := m.look(ctx)
m.keep(err)
if err != nil && !isNotEligible(err) {
if err != nil {
return health.Unhealthy(err.Error())
}
if err := m.room(); err != nil {
return health.Unhealthy(err.Error())
}
if err != nil {
// Not eligible: the module runs, it takes no image ask here now, and says why; the capacity record says it too.
return health.Healthy("not eligible to build images, so it takes no image ask: " + err.Error())
}
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made; %s opens", l.Buildah, l.Account, l.UIDs, l.GIDs, m.tunPath()))
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made", l.Buildah, l.Account, l.UIDs, l.GIDs))
}
// Status is what the module reports of itself.
func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx)
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil,
"eligible": err == nil, "cpu": m.cpuStatus()}
"cpu": m.cpuStatus()}
if err != nil {
out["why"] = err.Error()
}
@@ -2,9 +2,7 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -45,88 +43,21 @@ func TestHealthIsUnhealthyWithoutBuildah(t *testing.T) {
}
// The ranges are read from the two files, one each: without one in /etc/subuid or in /etc/subgid the account has no ids to
// map, and the health says which file. And without /dev/net/tun (a build node on 2026-10-28: its running kernel's modules were
// gone, so tun could not be loaded) buildah's network for a RUN line cannot be made: the health says the node is not
// eligible, and the puller judges by 0 so its asks go to a node that can build them.
func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) {
for _, c := range []struct {
name string
set func(*Module, string)
says []string
}{
{"subuid", func(m *Module, p string) { m.SubUIDFile = p }, []string{"/subuid", "mesh-build-image"}},
{"subgid", func(m *Module, p string) { m.SubGIDFile = p }, []string{"/subgid", "mesh-build-image"}},
// map, and the health says which file.
func TestHealthIsUnhealthyWithoutARangeInEitherFile(t *testing.T) {
for file, set := range map[string]func(*Module, string){
"subuid": func(m *Module, p string) { m.SubUIDFile = p },
"subgid": func(m *Module, p string) { m.SubGIDFile = p },
} {
f := newFake(t)
m := moduleWith(t, f, newStates())
p := t.TempDir() + "/" + c.name
p := t.TempDir() + "/" + file
os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644)
c.set(m, p)
set(m, p)
a := m.Health(context.Background())
for _, want := range c.says {
if a.Healthy || !strings.Contains(a.Why, want) {
t.Errorf("%s: health = %+v, want unhealthy saying %q", c.name, a, want)
}
if a.Healthy || !strings.Contains(a.Why, p) || !strings.Contains(a.Why, "mesh-build-image") {
t.Errorf("%s: health = %+v, want unhealthy naming %s and the account", file, a, p)
}
// Whatever the look refuses, the node is not eligible, with the same words.
if got := m.Eligible(); got != a.Why {
t.Errorf("%s: eligible says %q, the health %q", c.name, got, a.Why)
}
}
}
// 1126: a node that cannot build here now (no tun device) stays healthy, saying "not eligible" and why; the assigner reads
// unhealthy as a failed delivery and held the new build for every node. The eligibility, the status and the log say it too.
func TestANodeWithoutTunIsHealthyAndSaysItIsNotEligible(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
var said []string
m.Say = func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
m.Tun = t.TempDir() + "/dev/net/tun"
a := m.Health(context.Background())
if a.State != health.StateHealthy || !a.Healthy {
t.Fatalf("health = %+v, want healthy", a)
}
for _, want := range []string{"not eligible", "/dev/net/tun cannot be opened", "tun"} {
if !strings.Contains(a.Why, want) {
t.Errorf("health says %q, lacks %q", a.Why, want)
}
}
if got := m.Eligible(); !strings.Contains(got, "not eligible: ") {
t.Errorf("eligible says %q", got)
}
if s := m.Status(context.Background()); s["eligible"] != false || s["refusing"] != true {
t.Errorf("status = %v, want not eligible", s)
}
// One log line for the change, none for the same look again; one when it can build again.
m.Health(context.Background())
if len(said) != 1 || !strings.Contains(said[0], "takes no image build now") {
t.Errorf("said = %q, want one line for the change", said)
}
m.Tun = filepath.Join(t.TempDir(), "tun")
os.WriteFile(m.Tun, nil, 0o644)
if a := m.Health(context.Background()); !a.Healthy || strings.Contains(a.Why, "not eligible") {
t.Errorf("health = %+v, want plain healthy", a)
}
if len(said) != 2 || !strings.Contains(said[1], "eligible to build images again") {
t.Errorf("said = %q, want the return said", said)
}
// A step still ends refused with the cause named.
m.Tun = t.TempDir() + "/none"
if err := m.Check(context.Background()); err == nil || !strings.Contains(err.Error(), "not eligible") {
t.Errorf("check = %v", err)
}
}
// 1126: what cannot run stays unhealthy even beside a missing tun: buildah missing is not "not eligible".
func TestWithoutBuildahTheNodeIsUnhealthyNotIneligible(t *testing.T) {
m := moduleWith(t, newFake(t), newStates())
m.Tun = t.TempDir() + "/none"
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return forgekey.Ran{Status: 127, Err: "exec: \"buildah\": executable file not found in $PATH"}
}
if a := m.Health(context.Background()); a.Healthy {
t.Errorf("health = %+v, want unhealthy", a)
}
}
@@ -2,13 +2,11 @@ package main
// What the module proves about its own machine before it builds (novox/hq ADR 0487 §2, issue 1703a): buildah runs,
// the account holds subordinate user and group ranges (the node-engine's useradd allocates them for every `user`
// account, so nothing declares them; this reads them), a user namespace can be made, and the tun device opens (buildah
// makes a RUN line's network with pasta, which needs it; a build node on 2026-10-28 had the device node and no tun driver).
// account, so nothing declares them; this reads them), and a user namespace can be made.
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"path/filepath"
@@ -20,9 +18,6 @@ import (
// ProbeTimeout bounds each probe: they read the machine and fetch nothing.
const ProbeTimeout = 20 * time.Second
// TunDevice is the tun device pasta opens for a RUN line's network.
const TunDevice = "/dev/net/tun"
// Default subordinate id files.
const (
SubUIDFile = "/etc/subuid"
@@ -138,23 +133,3 @@ func (m *Module) ensureRuntimeDir() error {
}
return nil
}
func (m *Module) tunPath() string {
if m.Tun != "" {
return m.Tun
}
return TunDevice
}
// tunDevice opens the tun device as the account does for a build, and says the node is not eligible when it cannot: a
// device node without its driver answers "no such device" (the tun kernel module is not loaded and cannot be, as when the
// running kernel's modules were removed by an upgrade), and every RUN line would fail in buildah's network setup.
func (m *Module) tunDevice() error {
f, err := os.OpenFile(m.tunPath(), os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("not eligible: %s cannot be opened (%v), so buildah cannot make a RUN line's network (pasta): "+
"the tun kernel module is not loaded here (the kernel module loads it at boot; a machine whose running kernel's "+
"modules are gone needs a reboot)", m.tunPath(), errors.Unwrap(err))
}
return f.Close()
}
@@ -54,7 +54,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
Run: func(map[string]any) (any, error) { return m.Status(m.Ctx), nil }},
stdio.Tool{Name: HealthTool,
Description: "How the image build module is: unhealthy while buildah cannot be run, while its account holds no " +
"subordinate user or group range, or while a user namespace cannot be made, saying which; healthy otherwise. A machine that cannot build here now (no tun device) stays healthy and says in its words \"not eligible\", with the cause; it takes no image ask meanwhile (issue 1126). Read-only.",
"subordinate user or group range, or while a user namespace cannot be made, saying which; healthy otherwise. Read-only.",
Run: func(map[string]any) (any, error) { return m.Health(m.Ctx), nil }},
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"capacity": 0,
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2,
"build-hours": ""
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/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=
+6 -6
View File
@@ -20,15 +20,15 @@
],
"config": {
"capacity": {
"type": "number",
"description": "How many asks of the image build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.",
"minimum": 0,
"default": 0
"type": "string",
"description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0"
},
"build-hours": {
"type": "string",
"description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.",
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]",
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": ""
},
"cores-per-step": {
@@ -51,13 +51,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (a node may build only in a window): outside them it takes
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new.
var cores func() float64
var hours func() (*buildstep.BuildWindow, string)
if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say)
}
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
@@ -85,8 +84,7 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct {
Config map[string]struct {
Default json.RawMessage `json:"default"`
Pattern, Description string
Default json.RawMessage `json:"default"`
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
@@ -104,20 +102,10 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
}
// ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0.
window := regexp.MustCompile("^(?:" + doc.Config[buildstep.BuildHoursSetting].Pattern + ")$")
for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} {
if window.MatchString(value) != ok {
t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok)
}
}
if strings.Contains(doc.Config[buildstep.BuildHoursSetting].Description, "=") {
t.Errorf("the build-hours description names a node pair")
}
var capacity float64
var capacity string
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != 0 {
t.Errorf("the capacity's default = %g, want 0", capacity)
if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q", capacity)
}
if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" {
t.Error("no settings file")
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)()
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
return buildstep.NodeLimits(slots)
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"capacity": 0,
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2,
"build-hours": ""
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+6 -6
View File
@@ -21,15 +21,15 @@
],
"config": {
"capacity": {
"type": "number",
"description": "How many asks of the Node build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.",
"minimum": 0,
"default": 0
"type": "string",
"description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0"
},
"build-hours": {
"type": "string",
"description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.",
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]",
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": ""
},
"cores-per-step": {
@@ -21,9 +21,9 @@ const (
ProposalsState = "proposals"
AssignmentsRead = "mesh-controller.assignments"
// PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): each
// PlacementRead is the indexer's placement, read from the catalogue at each merge (novox/hq ADR 0549 §2, §3): each
// node's key `placement.<node>` with the modules placed there.
PlacementRead = "mesh-delivery-assigner.placement"
PlacementRead = "mesh-delivery-indexer.placement"
DeclaredConfigRead = "mesh-controller.declared-config"
PredefinedRead = "mesh-controller.predefined"
@@ -27,9 +27,9 @@ func (c *Config) sources() (Sources, error) {
}); err != nil {
return s, fmt.Errorf("the assignments cannot be read: %w", err)
}
// And every module the seat's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
// And every module the catalog's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
// Placement that cannot be read yet (the seat's state not on the bus) leaves the assignments alone, said.
// Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said.
standing := map[string]bool{}
for _, a := range s.Assignments {
standing[a.Node+"."+a.Module] = true
@@ -57,7 +57,7 @@ func (c *Config) sources() (Sources, error) {
}
return nil
}); err != nil {
problems = append(problems, fmt.Sprintf("the seat's placement cannot be read, so the assignments alone are resolved: %v", err))
problems = append(problems, fmt.Sprintf("the catalog's placement cannot be read, so the assignments alone are resolved: %v", err))
}
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
m, err := ParseModuleDeclarations(raw)
+1 -1
View File
@@ -2,4 +2,4 @@ module mesh-config
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49
+4 -2
View File
@@ -1,2 +1,4 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49 h1:h1hE+H1TzmeWNFP3EnfhUXTZq2gyiyeND32Tsg83ygY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+1 -1
View File
@@ -29,7 +29,7 @@
],
"reads": [
"mesh-controller.assignments",
"mesh-delivery-assigner.placement",
"mesh-delivery-indexer.placement",
"mesh-controller.declared-config",
"mesh-controller.predefined"
],
@@ -59,7 +59,7 @@ type checkerVerdict struct {
Words string `json:"words,omitempty"`
} `json:"failed,omitzero"`
// Composition is what the check of the machines' composition made of it (mesh-sdk delivery.Composition): its state
// not-run says the composition was not judged.
// not-run says the composition, and with it a placement change's answers from its nodes, was not judged.
Composition *struct {
State string `json:"state"`
Words string `json:"words,omitempty"`
@@ -341,7 +341,7 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
if ok {
switch v.State {
case checkerPass:
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v)}
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v, members)}
case checkerRunning:
words := "its check has no verdict yet: it is running"
if since, held := h.runningSince[hash]; held && !since.IsZero() {
@@ -384,13 +384,20 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"}
}
// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun).
// Empty otherwise.
func notComposed(v checkerVerdict) string {
// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun): a
// placement change's pass then holds no node's answer to its placement (ADR 0559 §5), which the ask says. Empty otherwise.
func notComposed(v checkerVerdict, members []*Delivery) string {
if v.Composition == nil || v.Composition.State != sdk.CompositionNotRun {
return ""
}
why := firstOf(oneLine(v.Composition.Words), "it was not run")
for _, m := range members {
for _, p := range m.Paths {
if strings.HasPrefix(p, "placement/") && strings.HasSuffix(p, ".json") {
return ", but its composition was not judged (" + why + "), so no node answered its placement"
}
}
}
return ", but its composition was not judged (" + why + ")"
}
@@ -465,7 +465,7 @@ const waitsCheckBeforeAsking = "waits its check before asking the operator's App
// has no verdict (the operator, 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request").
// A pass or a failure is asked (a failure in its words: the operator may still merge it), and so is a check that has not
// answered within askCheckBound of the heads becoming ready, which the ask says. For a group the check is the one of all its
// heads together. Under the lock.
// heads together; for a placement change it ends only once every node it places answered (ADR 0559 §5). Under the lock.
func (h *Holder) verdictWaits(d *Delivery) string {
members := h.askMembers(d)
c := h.checkAt(members)
@@ -75,11 +75,12 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
// past which it is asked anyway, saying so, and asked again in new words once the check passes. The verdict is the
// checker's verdicts bucket value as captured on the bus (mesh-delivery-checker, `<group>.<heads hash>`).
for _, row := range []struct {
name, state, failed, then string
gated bool
after time.Duration
asks, open int
want, not []string
name, state, failed, then, path string
gated bool
notComposed bool
after time.Duration
asks, open int
want, not []string
}{
{name: "gated, passed", gated: true, state: "pass", asks: 1,
want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
@@ -99,6 +100,9 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running (since 15:00 CEST); asked anyway at 16:00 CEST"}},
{name: "ungated, passed, its verdict gone past its bound", state: "pass", after: askCheckBound, then: "gone", asks: 1, open: -1,
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 16:00 CEST"}},
// A placement change's pass with its composition not judged holds no node's answer, and the ask says so (ADR 0559 §5).
{name: "ungated, placement passed uncomposed", state: "pass", path: "placement/node-a.json", notComposed: true, asks: 1,
want: []string{"passed its check, but its composition was not judged (the assigner is not running), so no node answered its placement"}},
{name: "ungated, failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 1,
want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed", "anyway"}},
} {
@@ -108,7 +112,7 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
ungated(v)
}
v.op.auto = false
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p := pr("novox/app", 7, readyHead, "feat/x", firstOf(row.path, "src/a.go"))
p.Title = "Fix it"
v.h.PullUpdated(p)
head := headOf("novox/app", 7, readyHead)
@@ -117,7 +121,11 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
}
v.settleAll()
if !row.gated && row.state != "" {
checkerSaysWith(v, "g", row.state, row.failed, nil, head)
var more map[string]any
if row.notComposed {
more = map[string]any{"composition": map[string]string{"state": "not-run", "words": "the assigner is not running"}}
}
checkerSaysWith(v, "g", row.state, row.failed, more, head)
v.settleAll()
}
if row.after > 0 {
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-gate
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+6 -2
View File
@@ -1,5 +1,9 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61 h1:/c6ncICWjq55nBf8TlDM0ROe/PuH+qZIdnuVbtg8UuM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f h1:AKzV973UTWe0OYFoIjgGu0f+B26xh8QsAHzCpsTIjM8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f h1:mimAGERguQmCGYVNdRTrFcZ1dhpU6GWo+Q3EDhHOrVk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f/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=
-1
View File
@@ -33,7 +33,6 @@ what it refuses.
| `reserve` | `kind` (`decision` or `research`), optionally `for` (one line: what the record is about) | the next number of that kind, spent forever; answers `kind` and `number` (and `prefix`, the number as the record's name starts with it); see below |
| `fix-times` | optionally `days` (1 to 90, default 7) | each class's fix budget, the median and worst fix time of the issues whose last fix was delivered in the window, within and over, and every issue over its budget with its longest phase; see *A fix's time* |
| `learn-fixes` | `number` | asks again for the fixes of one issue the tracker keeps no record of (a pull request merged before it heard deliveries): the delivery, then the forge, say whether each is merged (and the commit it landed as) or closed without merging; answers what each is kept as and the delivery line |
| `changes` | `since` (a window such as `60m`, at most `24h`), `number` (optional) | each issue's moves over the window, read from the tracker's own log `changes`: opened, moved, resolved or wontfix, the status it moved from and to, who moved it (and the worker, for diagnosing or fixing) and when, oldest first, at most 500; the window's first entry is found by its time, so a day's window reads only that day of the log. knowledge-base.progress with `since` asks it for each move's "from" |
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
folders until the import, and hq ADR 0284): a status is one of `open`,
+1 -2
View File
@@ -23,7 +23,7 @@ const Seat = "issue-tracker"
// Verbs are the seat's verbs, as the manifest declares and claims them.
var Verbs = []string{"open", "get", "list", "move", "note", "link", "milestones", "milestone-set", "milestone-move", "import",
"groom", "set", "tracking", "reserve", "fix-times", "learn-fixes", "changes"}
"groom", "set", "tracking", "reserve", "fix-times", "learn-fixes"}
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -943,6 +943,5 @@ func Tools(t *Tracker) []stdio.Tool {
}
return t.LearnFixes(n)
}},
changesTool(t),
}
}
@@ -72,13 +72,6 @@ func TestAnIssueRaceIntoTwoMilestonesOverTheBusEndsInOne(t *testing.T) {
if len(hist.History) != 1 {
t.Fatalf("history %d values", len(hist.History))
}
// The moves history over the real log: the open, from nothing to open, read by the other instance.
var ch ChangesRead
_ = json.Unmarshal(must(two.call("changes", map[string]any{"since": "60m"})), &ch)
if len(ch.Changes) != 1 || ch.Changes[0].Number != 365 || ch.Changes[0].Kind != OpOpened || ch.Changes[0].To != StatusOpen ||
ch.Changes[0].Title != "a milestone's issue" {
t.Fatalf("changes %+v", ch)
}
evMu.Lock()
defer evMu.Unlock()
var kinds []string
@@ -1,222 +0,0 @@
package main
// changes: each issue's moves over a window, read from the tracker's own record, its log `changes` (novox/hq ADR 0300),
// which holds every operation with who made it and when. The bucket `issues` keeps only what they add up to, so this
// is the one place an issue's earlier status is known: knowledge-base.progress with since asks it for the "from" of
// each move (the operator, 2026-10-28: the progress made in the last 60 minutes).
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// MostChangesWindow is the longest window changes reads.
const MostChangesWindow = 24 * time.Hour
// MostChanges is the most changes one answer holds, the newest kept.
const MostChanges = 500
// changesSlack is how far before the window the log is read from: an entry's time is the bus's, an operation's the
// tracker's, and the two may differ by a little.
const changesSlack = time.Minute
// Change is one issue's status changing: opened, moved, resolved or wontfix, from what to what, by whom and when.
type Change struct {
Number uint64 `json:"number"`
Title string `json:"title,omitempty"`
Kind string `json:"kind"`
From string `json:"from"`
To string `json:"to"`
By string `json:"by"`
// Worker is who works on it, for a move to diagnosing or fixing: whom by names (the caller is By).
Worker string `json:"worker,omitempty"`
At time.Time `json:"at"`
}
// ChangesRead is the answer of changes.
type ChangesRead struct {
Since time.Time `json:"since"`
Changes []Change `json:"changes"`
// Entries is how many entries of the log were read for it.
Entries int `json:"entries-read"`
Said string `json:"said"`
}
// Changes reads the moves of the last window, of every issue or of one.
func (t *Tracker) Changes(window time.Duration, number uint64) (*ChangesRead, error) {
if window <= 0 || window > MostChangesWindow {
return nil, fmt.Errorf("since is a window more than nothing and at most 24h, not %s", window)
}
if t.Log == nil {
return nil, errNoLog
}
from := t.now().Add(-window)
out := &ChangesRead{Since: from, Changes: []Change{}}
var numbers []uint64
if number != 0 {
numbers = []uint64{number}
} else {
var err error
if numbers, err = t.movedSince(from, out); err != nil {
return nil, err
}
}
for _, n := range numbers {
entries, err := t.readKey(n, 1)
if err != nil {
return nil, err
}
if len(entries) == 0 {
if number != 0 {
return nil, fmt.Errorf("%d is not an issue the tracker holds", n)
}
continue
}
out.Entries += len(entries)
var cur *Issue
for _, e := range entries {
var op Operation
if err := json.Unmarshal(e.Value, &op); err != nil {
return nil, fmt.Errorf("the entry at %d is no operation this module reads: %v", e.Sequence, err)
}
next, err := fold(cur, &op, e.Sequence)
if err != nil {
return nil, fmt.Errorf("issue %d cannot be folded from its log: %v", n, err)
}
was := ""
if cur != nil {
was = cur.Status
}
cur = next
if op.Op == OpCarried || op.At.Before(from) || (was == next.Status && was != "") {
continue
}
kind := EventMoved
switch {
case was == "" && (op.Op == OpOpened || op.Op == OpImported):
kind = op.Op
case next.Status == StatusResolved || next.Status == StatusWontfix:
kind = next.Status
}
c := Change{Number: n, Kind: kind, From: was, To: next.Status, By: op.By, At: op.At}
if next.Worker != nil && (next.Status == StatusDiagnosing || next.Status == StatusFixing) {
c.Worker = next.Worker.By
}
out.Changes = append(out.Changes, c)
}
for i := range out.Changes {
if out.Changes[i].Number == n && cur != nil {
out.Changes[i].Title = cur.Title
}
}
}
sort.SliceStable(out.Changes, func(i, j int) bool {
if !out.Changes[i].At.Equal(out.Changes[j].At) {
return out.Changes[i].At.Before(out.Changes[j].At)
}
return out.Changes[i].Number < out.Changes[j].Number
})
cut := ""
if len(out.Changes) > MostChanges {
cut = fmt.Sprintf("; the oldest %d are left out", len(out.Changes)-MostChanges)
out.Changes = out.Changes[len(out.Changes)-MostChanges:]
}
out.Said = fmt.Sprintf("%d changes of status since %s, from the tracker's log (%d entries read)%s.",
len(out.Changes), from.Format(time.RFC3339), out.Entries, cut)
return out, nil
}
// movedSince is every issue an operation of which was written from the window on, found in the whole log by its
// entries' times: the first entry of the window is searched for, then the log is read on from it.
func (t *Tracker) movedSince(from time.Time, out *ChangesRead) ([]uint64, error) {
first, err := t.Log.Read(1, "", 1)
if err != nil {
return nil, fmt.Errorf("the log could not be read: %v", err)
}
if len(first.Entries) == 0 {
return nil, nil
}
lo, hi := first.Entries[0].Sequence, first.Last
start := from.Add(-changesSlack)
// The first sequence whose entry is at or after start: the entries are in the order they were written.
for lo < hi {
mid := lo + (hi-lo)/2
p, err := t.Log.Read(mid, "", 1)
if err != nil {
return nil, fmt.Errorf("the log could not be read at %d: %v", mid, err)
}
out.Entries++
if len(p.Entries) == 0 || !p.Entries[0].At.Before(start) {
hi = mid
} else {
lo = p.Entries[0].Sequence + 1
}
}
seen := map[uint64]bool{}
var numbers []uint64
for seq := lo; seq != 0; {
p, err := t.Log.Read(seq, "", logPage)
if err != nil {
return nil, fmt.Errorf("the log could not be read from %d: %v", seq, err)
}
out.Entries += len(p.Entries)
for _, e := range p.Entries {
n, ok := numberOfKey(e.Key)
if !ok || seen[n] || e.At.Before(start) || !movesStatus(e) {
continue
}
seen[n] = true
numbers = append(numbers, n)
}
seq = p.Next
}
return numbers, nil
}
// movesStatus says whether a log entry may change an issue's status: one that holds the issue whole, or sets it.
func movesStatus(e stdio.LogEntry) bool {
var op Operation
if json.Unmarshal(e.Value, &op) != nil {
return true
}
_, sets := op.Set["status"]
return sets || (op.Issue != nil && op.Op != OpCarried)
}
// changesTool is the verb, its arguments read as the other verbs read them.
func changesTool(t *Tracker) stdio.Tool {
return stdio.Tool{Name: Seat + ".changes",
Description: "Each issue's moves over a window, from the tracker's own log of every change: the issue moves history " +
"of the last minutes or hours (since, such as 60m, at most 24h), every issue's or one's (number), each change " +
"opened, moved, resolved or wontfix with the status it moved from and to, who moved it and when, oldest first. " +
"What the issues are now is list and get; this is what they were before. Refused: a window of nothing or over " +
"24h, a number that is not an issue.",
Input: schema([]string{"since"}, map[string]any{
"since": text("the window, back from now: 60m, 5m, 2h, at most 24h"),
"number": number("only this issue"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("changes", m, "since", "number")
if err != nil {
return nil, err
}
s, err := a.str("since")
if err != nil {
return nil, err
}
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return nil, fmt.Errorf("since is a window such as 60m, 5m or 2h, not %q", s)
}
n, err := a.number("number", false)
if err != nil {
return nil, err
}
return t.Changes(d, n)
}}
}
@@ -1,76 +0,0 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
// changes reads the tracker's own log over a window (the lead, 2026-10-28, for knowledge-base.progress since): each
// move from what to what, by whom and when, and each open, as the tracker's verbs wrote them into its log here.
func TestChangesSaysEachMoveFromWhatToWhatOverAWindow(t *testing.T) {
h := newHarness(t)
clock := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC)
h.Tracker.Now = func() time.Time { return clock }
at := func(hh, mm int) { clock = time.Date(2026, 10, 28, hh, mm, 0, 0, time.UTC); h.log.clock = clock }
at(9, 0)
a := h.open(t, "an issue fixed in the window", "hq")
at(9, 10)
b := h.open(t, "an issue given up in the window")
at(10, 0)
if _, err := h.Move(MoveAsk{Number: a, Status: StatusDiagnosing, By: "a@g14"}); err != nil {
t.Fatal(err)
}
at(10, 30)
if _, err := h.Move(MoveAsk{Number: a, Status: StatusFixing, By: "a@g14"}); err != nil {
t.Fatal(err)
}
at(10, 40)
if _, err := h.Note(a, "a note moves nothing"); err != nil {
t.Fatal(err)
}
at(11, 0)
if _, err := h.Move(MoveAsk{Number: a, Status: StatusResolved, FixedBy: []string{"hq#1"}}); err != nil {
t.Fatal(err)
}
at(11, 5)
if _, err := h.Move(MoveAsk{Number: b, Status: StatusWontfix, Reason: "the module is retired"}); err != nil {
t.Fatal(err)
}
at(11, 30)
say := func(r *ChangesRead) string {
var lines []string
for _, c := range r.Changes {
lines = append(lines, strings.Join([]string{c.At.Format("15:04"), c.Kind, itoa(int(c.Number)), c.From + ">" + c.To, c.By + "/" + c.Worker}, " "))
}
return strings.Join(lines, "\n")
}
got, err := h.call(t, "changes", map[string]any{"since": "60m"})
if err != nil {
t.Fatal(err)
}
r := got.(*ChangesRead)
if want := "10:30 moved 365 diagnosing>fixing " + Unproven + "/a@g14\n11:00 resolved 365 fixing>resolved " + Unproven + "/\n11:05 wontfix 366 open>wontfix " + Unproven + "/"; say(r) != want {
t.Errorf("the last hour:\n%s\nwant\n%s", say(r), want)
}
if !r.Since.Equal(time.Date(2026, 10, 28, 10, 30, 0, 0, time.UTC)) || r.Changes[0].Title != "an issue fixed in the window" || !strings.Contains(r.Said, "3 changes") {
t.Errorf("since %v, title %q, said %q", r.Since, r.Changes[0].Title, r.Said)
}
got, err = h.call(t, "changes", map[string]any{"since": "3h", "number": b})
if err != nil {
t.Fatal(err)
}
if want := "09:10 opened 366 >open " + Unproven + "/\n11:05 wontfix 366 open>wontfix " + Unproven + "/"; say(got.(*ChangesRead)) != want {
t.Errorf("one issue:\n%s", say(got.(*ChangesRead)))
}
raw, _ := json.Marshal(got)
if !strings.Contains(string(raw), `"from":"open","to":"wontfix"`) {
t.Errorf("as JSON: %s", raw)
}
for _, bad := range []map[string]any{{"since": "25h"}, {"since": "0m"}, {}, {"since": "60m", "number": 9999}} {
if _, err := h.call(t, "changes", bad); err == nil {
t.Errorf("%v was answered", bad)
}
}
}
+2 -4
View File
@@ -22,8 +22,7 @@
"tracking",
"reserve",
"fix-times",
"learn-fixes",
"changes"
"learn-fixes"
]
}
],
@@ -47,8 +46,7 @@
"tracking",
"reserve",
"fix-times",
"learn-fixes",
"changes"
"learn-fixes"
]
}
],
+1 -1
View File
@@ -27,7 +27,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
| `withdraw` | `number`, `kind`, `why` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn |
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound`, `since` (a window such as `60m`, at most `24h`) | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`). With `since`, only what changed in the window under `of`, headed "N changes in the last 60 min": milestones moved (from what to what, from `issue-tracker.milestones` `one`), issues opened or moved from what to what (from `issue-tracker.changes`, the tracker's log), noted or linked (from `issue-tracker.get`), pull requests opened, checked, merged, held or closed (from `mesh-delivery.show`'s transitions and check). It keeps no state; its warnings say when the tracker cannot answer `changes` (a move is then said by the status it reached, not from what) and that mesh-delivery keeps no time a merge reached its machines |
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound` | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`) |
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
@@ -34,8 +34,6 @@ type Milestone struct {
// Status is open or closed, with its Reason; a closed milestone holding no issue is done.
Status string
Reason string
// Moved is who last moved it and when, as the tracker says it; nil before its first move.
Moved *stamp
}
var (
@@ -1130,19 +1130,14 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
"milestone, cheap enough for a report every five minutes; detailed gives the report's tree: each " +
"milestone not done with its issues, their pull requests, the groups holding them and what each waits " +
"for, for about every thirty. A design's milestones and their issues are the issue tracker's milestone " +
"records (its milestones); a design with none is refused, naming the seed. Within a bound it states, 4 KiB unless asked for more, saying what it cut. " +
"With since, a window such as 60m or 5m (at most 24h), it says only the progress made since then, such as in the last hour: " +
"the milestones and issues that moved (from what to what where the tracker keeps it), were opened, noted or resolved, " +
"the pull requests opened, checked, merged or held and the deliveries that reached their machines, and what became stuck, " +
"headed by how many changes in the last N minutes.",
"records (its milestones); a design with none is refused, naming the seed. Within a bound it states, 4 KiB unless asked for more, saying what it cut.",
Input: schema([]string{"of"}, map[string]any{
"of": text("what: mesh, design 47, milestone 47/R6, issue 1945, decision 493 (or ADR 0493) or research 72"),
"detail": map[string]any{"type": "string", "enum": []string{DetailMinimal, DetailDetailed},
"description": "minimal (the default): one line per child; detailed: the tree with issues, pull requests and groups"},
"bound": boundArg(),
"since": text("a window such as 60m, 5m or 2h, at most 24h: only what changed in it, from the tracker's and mesh-delivery's history")}),
"bound": boundArg()}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("progress", m, "of", "detail", "bound", "since")
a, err := readArgs("progress", m, "of", "detail", "bound")
if err != nil {
return nil, err
}
@@ -1156,9 +1151,6 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
if p.Bound, err = a.num("bound", false); err != nil {
return nil, err
}
if p.Since, err = a.str("since"); err != nil {
return nil, err
}
return k.Progress(p)
}},
}
@@ -80,11 +80,11 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
}
if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status",
"records.records_list", "records.records_read", "seat:operator-channel.notify", "seat:issue-tracker.list", "seat:issue-tracker.get",
"seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
"seat:issue-tracker.milestones", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
t.Fatalf("invokes %v", m.Invokes)
}
src := ""
for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go"} {
for _, f := range []string{"main.go", "hq.go", "progress.go", "milestones.go"} {
src += readFile(t, f)
}
for _, inv := range m.Invokes {
@@ -7,7 +7,6 @@ package main
import (
"encoding/json"
"fmt"
"time"
)
// MilestonesVerb is the tracker's verb that answers the milestones.
@@ -21,13 +20,6 @@ type trackerMilestone struct {
Status string `json:"status"`
Reason string `json:"reason"`
Issues []int `json:"issues"`
Moved *stamp `json:"moved"`
}
// stamp is who did something and when, as the tracker says it.
type stamp struct {
By string `json:"by"`
At time.Time `json:"at"`
}
// withMilestones is each design with its milestones as the tracker holds them, in their table's order; design 0 asks
@@ -49,7 +41,7 @@ func (k *KB) withMilestones(ds []*Design, design int) ([]*Design, error) {
}
by := map[int][]Milestone{}
for _, m := range l.Milestones {
by[m.Design] = append(by[m.Design], Milestone{ID: m.ID, Outcome: m.Outcome, Issues: m.Issues, Status: m.Status, Reason: m.Reason, Moved: m.Moved})
by[m.Design] = append(by[m.Design], Milestone{ID: m.ID, Outcome: m.Outcome, Issues: m.Issues, Status: m.Status, Reason: m.Reason})
}
out := make([]*Design, 0, len(ds))
for _, d := range ds {
@@ -56,8 +56,6 @@ type ProgressAsk struct {
Of string
Detail string
Bound int
// Since is a window, such as 60m: the answer is then only what changed in it (progress_since.go).
Since string
}
// Item is one thing whose progress is said: a design, a milestone, an issue, a pull request.
@@ -83,11 +81,6 @@ type Progress struct {
Source string `json:"source"`
ReadAt time.Time `json:"read-at"`
Cut string `json:"cut,omitempty"`
// Since and Changes are said for an answer about a window: how long it is and how many changes it holds.
Since string `json:"since,omitempty"`
Changes int `json:"changes,omitempty"`
// scope is what the answer is about, kept for a window's answer; nil without since.
scope *sinceScope
}
// trackerRow is a row of `issue-tracker.list`.
@@ -144,15 +137,9 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
if k.Seats == nil {
return nil, errors.New("the knowledge base reaches no other seat here")
}
window, err := parseSince(a.Since)
if err != nil {
return nil, err
}
of := strings.Join(strings.Fields(a.Of), " ")
p := &Progress{Of: of, Detail: detail, ReadAt: k.now()}
if window > 0 {
p.scope = &sinceScope{rows: map[int]trackerRow{}}
}
var err error
switch {
case strings.EqualFold(of, "mesh"):
err = k.progressMesh(p)
@@ -181,9 +168,6 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
if err != nil {
return nil, err
}
if window > 0 {
k.progressSince(p, window)
}
// A cut takes the warnings from the last: the calls that failed are said before the answers that did not parse.
callsFirst(p.Warnings)
fitProgress(p, boundOf(a.Bound))
@@ -356,9 +340,8 @@ func (k *KB) progressDesign(p *Progress, number int, milestone string) error {
}
items, warn := milestoneItems(ms, rows, missing)
p.Warnings = append(p.Warnings, warn...)
p.scope.hold(rows, ms)
var extra *extras
if p.Detail == DetailDetailed && p.scope == nil {
if p.Detail == DetailDetailed {
if extra, err = k.readExtras(items); err != nil {
return err
}
@@ -411,10 +394,6 @@ func (k *KB) progressMesh(p *Progress) error {
for _, n := range missingList {
missing[n] = true
}
p.scope.hold(rows, with...)
if p.scope != nil {
p.scope.every = true
}
top := Item{ID: "mesh", Title: "the designs being built"}
var kids []Item
for _, d := range with {
@@ -462,11 +441,6 @@ func (k *KB) progressIssue(p *Progress, n int) error {
}
it.ID = fmt.Sprintf("issue %d", n)
p.Source = "the issue tracker and mesh-delivery"
if p.scope != nil {
p.scope.hold(rows)
p.State, p.Items = it.State, []Item{it}
return nil
}
extra, err := k.readExtras([]Item{{Children: []Item{{ID: strconv.Itoa(n), State: StateMoving}}}})
if err != nil {
return err
@@ -539,9 +513,6 @@ func (k *KB) progressRecord(p *Progress, kind string, n int) error {
p.Warnings = append(p.Warnings, fmt.Sprintf("%d issues are linked to %s; the first %d are counted", l.Matched, name, len(l.Rows)))
}
for _, r := range l.Rows {
if p.scope != nil {
p.scope.rows[r.Number] = r
}
if it, counts := issueItem(r); counts {
kids = append(kids, it)
}
@@ -58,24 +58,12 @@ func TestProgressOverTheBus(t *testing.T) {
t.Errorf("detailed: no %q in\n%s", want, p.Tree)
}
}
// A window over the bus: since is taken and the answer says how many changes it holds (none moved in the captured rows).
raw, err = b.call(Seat+".progress", map[string]any{"of": "milestone 47/R6", "since": "60m"})
if err != nil {
t.Fatal(err)
}
_ = json.Unmarshal(raw, &p)
if !strings.HasPrefix(p.Tree, "0 changes in the last 60 min under milestone 47/R6 (since ") || p.Since != "1h0m0s" {
t.Errorf("since over the bus:\n%s", p.Tree)
}
if _, err := b.call(Seat+".progress", map[string]any{"of": "design 47", "since": "2d"}); err == nil {
t.Error("a window of two days was taken")
}
if _, err := b.call(Seat+".progress", map[string]any{"of": "design 47", "by": "someone@else"}); err == nil {
t.Error("an argument naming the caller was taken")
}
seats.mu.Lock()
defer seats.mu.Unlock()
if seats.asked["seat:issue-tracker.list"] != 3 || seats.asked["seat:mesh-delivery.groups"] != 1 || seats.asked[MilestonesVerb] != 3 {
if seats.asked["seat:issue-tracker.list"] != 2 || seats.asked["seat:mesh-delivery.groups"] != 1 || seats.asked[MilestonesVerb] != 2 {
t.Fatalf("asked %v", seats.asked)
}
}
@@ -1,498 +0,0 @@
package main
// progress with since: only what changed in a window under of (the operator, 2026-10-28: "can the progress tool also
// report the progress made since the last call, or maybe better, the last 60 minutes"). A window, not a caller's last
// call: the knowledge base keeps no state of its own, so it reads what the other seats keep, each a request and reply
// over the bus:
//
// - the issue tracker's moves over the window, from its own log (`issue-tracker.changes`): each issue opened or
// moved, from what to what, by whom and when;
// - the issue tracker's milestones with their last move, and a moved one's history (`issue-tracker.milestones`
// with one): what it was before, what it is now, who moved it and when;
// - each issue that moved in the window whole (`issue-tracker.get`): when it was opened, its notes and links with who
// and when, its worker since when, and when it was closed;
// - mesh-delivery's deliveries and each one's transitions and its check (`mesh-delivery.deliveries`, `.show`).
//
// When the tracker cannot say its moves (a tracker without `changes`), a move is said by the status it reached, with its
// worker's since or its closed time, not from what it moved, and the answer says so. mesh-delivery keeps no time a
// merge reached its machines, so "on the machines" is as it reads now, said too.
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// MostSince is the longest window since asks: the deliveries mesh-delivery lists end a day back.
const MostSince = 24 * time.Hour
// parseSince reads a window such as 60m, 5m or 2h; empty is none.
func parseSince(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 || d > MostSince {
return 0, fmt.Errorf("since is a window such as 60m, 5m or 2h, more than nothing and at most %s, not %q", "24h", s)
}
return d, nil
}
// sinceScope is what an answer about a window is about: the issues under of, as the tracker answered their rows, and
// the milestones; every says that of is the mesh, so every delivery counts.
type sinceScope struct {
rows map[int]trackerRow
milestones []sinceMilestone
every bool
}
type sinceMilestone struct {
design int
m Milestone
}
// hold keeps the rows and the designs' milestones for a window's answer; on a nil scope it does nothing.
func (s *sinceScope) hold(rows map[int]trackerRow, ds ...*Design) {
if s == nil {
return
}
for n, r := range rows {
s.rows[n] = r
}
for _, d := range ds {
for _, m := range d.Milestones {
s.milestones = append(s.milestones, sinceMilestone{design: d.Number, m: m})
}
}
}
// change is one thing that changed in the window, with its changes, newest last.
type change struct {
line string
last time.Time
events []event
}
type event struct {
at time.Time
line string
// brief is the event in the change's one line, when it differs from line.
brief string
}
// window is the time between from and to.
type window struct{ from, to time.Time }
func (w window) holds(t time.Time) bool { return !t.IsZero() && !t.Before(w.from) && !t.After(w.to) }
// progressSince replaces the answer's tree with what changed in the window under p.scope.
func (k *KB) progressSince(p *Progress, d time.Duration) {
w := window{from: p.ReadAt.Add(-d), to: p.ReadAt}
p.Since, p.Items = d.String(), nil
var milestones, issues, pulls []change
milestones = k.milestoneChanges(p, w)
issues = k.issueChanges(p, w)
pulls = k.pullChanges(p, w)
for _, cs := range [][]change{milestones, issues, pulls} {
sort.SliceStable(cs, func(i, j int) bool { return cs[i].last.After(cs[j].last) })
}
all := append(append(milestones, issues...), pulls...)
for _, c := range all {
p.Changes += len(c.events)
}
var b strings.Builder
noun := "changes"
if p.Changes == 1 {
noun = "change"
}
fmt.Fprintf(&b, "%d %s in the last %d min under %s (since %s)\n", p.Changes, noun, int(d.Minutes()), p.Of, when(&w.from))
for i, c := range all {
branch, under := "├── ", "│ "
if i == len(all)-1 {
branch, under = "└── ", " "
}
b.WriteString(branch + c.line + "\n")
if p.Detail != DetailDetailed {
continue
}
for j, e := range c.events {
br := "├── "
if j == len(c.events)-1 {
br = "└── "
}
b.WriteString(under + br + e.line + "\n")
}
}
p.Tree = strings.TrimRight(b.String(), "\n")
p.Source += "; the window from the tracker's milestones and their history, its issues' notes, links, workers and " +
"closes, and mesh-delivery's transitions and checks"
}
// milestoneChanges is each milestone moved in the window, from its history: what it was and what it is.
func (k *KB) milestoneChanges(p *Progress, w window) []change {
var out []change
asked := 0
for _, sm := range p.scope.milestones {
m := sm.m
if m.Moved == nil || !w.holds(m.Moved.At) {
continue
}
ref := fmt.Sprintf("%d/%s", sm.design, m.ID)
now := m.Status
if m.Reason != "" {
now += ": " + m.Reason
}
was := "a state its history does not say"
if asked < MostGets {
asked++
if prev, err := k.milestoneBefore(ref); err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("issue-tracker.milestones one %s: %v; said without what it was", ref, err))
} else if prev != "" {
was = prev
}
}
mark := marks[StateMoving]
if m.Status == "closed" {
mark = marks[StateDone]
}
line := fmt.Sprintf("%s milestone %s %s → %s · by %s %s", mark, ref, was, now, m.Moved.By, when(&m.Moved.At))
out = append(out, change{line: line, last: m.Moved.At, events: []event{{at: m.Moved.At, line: fmt.Sprintf("moved %s by %s", when(&m.Moved.At), m.Moved.By)}}})
}
return out
}
// milestoneBefore is a milestone's status before its last move, from the tracker's history of it.
func (k *KB) milestoneBefore(ref string) (string, error) {
raw, err := k.Seats(MilestonesVerb, map[string]any{"one": ref})
if err != nil {
return "", err
}
var r struct {
History []struct {
Status string `json:"status"`
} `json:"history"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("answered something else: %v", err)
}
if len(r.History) < 2 {
return "", nil
}
return r.History[len(r.History)-2].Status, nil
}
// issueGotWhole is the part of `issue-tracker.get` a window reads.
type issueGotWhole struct {
Status string `json:"status"`
Opened time.Time `json:"opened"`
OpenedBy string `json:"opened-by"`
Closed *time.Time `json:"closed"`
FixedBy []string `json:"fixed-by"`
Worker *struct {
By string `json:"by"`
Since time.Time `json:"since"`
} `json:"worker"`
Notes []struct {
At time.Time `json:"at"`
By string `json:"by"`
Text string `json:"text"`
} `json:"notes"`
Links []struct {
At time.Time `json:"at"`
By string `json:"by"`
Kind string `json:"kind"`
Target string `json:"target"`
} `json:"links"`
}
// issueChanges is each issue under of that moved in the window, read whole: opened, moved, noted, linked, closed.
func (k *KB) issueChanges(p *Progress, w window) []change {
var moved []trackerRow
for _, r := range p.scope.rows {
if r.Moved != nil && w.holds(*r.Moved) {
moved = append(moved, r)
}
}
sort.Slice(moved, func(i, j int) bool { return moved[i].Moved.After(*moved[j].Moved) })
var moves map[int][]trackerMove
var merr error
if len(moved) > 0 {
if moves, merr = k.trackerMoves(w); merr != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("issue-tracker.changes: %v; a move is said by the status it reached, "+
"its worker's since and its closed time, not from what it moved", merr))
}
}
var out []change
for i, r := range moved {
it, _ := issueItem(r)
if it.State == "" {
it.State = StateDone
}
head := fmt.Sprintf("%s %d %s — ", marks[it.State], r.Number, clip(r.Title, 80))
fallback := func(why string) {
p.Warnings = append(p.Warnings, why)
line := fmt.Sprintf("moved %s, now %s", when(r.Moved), r.Status)
if r.Worker != "" {
line += " (" + r.Worker + ")"
}
out = append(out, change{line: head + line, last: *r.Moved, events: []event{{at: *r.Moved, line: line}}})
}
if i >= MostGets {
fallback(fmt.Sprintf("more than %d issues moved; the rest are said by their rows' moved time", MostGets))
continue
}
raw, err := k.Seats("seat:issue-tracker.get", map[string]any{"number": r.Number})
if err != nil {
fallback(fmt.Sprintf("issue-tracker.get %d: %v; said by its row's moved time", r.Number, err))
continue
}
var g issueGotWhole
if err := json.Unmarshal(raw, &g); err != nil {
fallback(fmt.Sprintf("issue-tracker.get %d answered something else: %v; said by its row's moved time", r.Number, err))
continue
}
var evs []event
var says []string
if merr == nil {
for _, m := range moves[r.Number] {
if m.Kind == "opened" || m.Kind == "imported" {
evs = append(evs, event{at: m.At, line: fmt.Sprintf("%s %s by %s", m.Kind, when(&m.At), m.By)})
says = append(says, m.Kind+" by "+m.By+" "+when(&m.At))
continue
}
who, line := m.By, fmt.Sprintf("%s → %s %s by %s", m.From, m.To, when(&m.At), m.By)
if m.Worker != "" {
who, line = m.Worker, line+", worker "+m.Worker
}
short := fmt.Sprintf("%s → %s by %s %s", m.From, m.To, who, when(&m.At))
if (m.To == "resolved" || m.To == "wontfix") && len(g.FixedBy) > 0 {
short += ", fixed by " + strings.Join(g.FixedBy, ", ")
}
evs = append(evs, event{at: m.At, line: line})
says = append(says, short)
}
}
if merr != nil && w.holds(g.Opened) {
evs = append(evs, event{at: g.Opened, line: fmt.Sprintf("opened %s by %s", when(&g.Opened), g.OpenedBy)})
says = append(says, "opened by "+g.OpenedBy+" "+when(&g.Opened))
}
if merr != nil && g.Worker != nil && w.holds(g.Worker.Since) && (g.Status == "diagnosing" || g.Status == "fixing") {
s := fmt.Sprintf("→ %s by %s %s", g.Status, g.Worker.By, when(&g.Worker.Since))
evs = append(evs, event{at: g.Worker.Since, line: s})
says = append(says, s)
}
if merr != nil && g.Closed != nil && w.holds(*g.Closed) {
s := fmt.Sprintf("→ %s %s", g.Status, when(g.Closed))
if len(g.FixedBy) > 0 {
s += ", fixed by " + strings.Join(g.FixedBy, ", ")
}
evs = append(evs, event{at: *g.Closed, line: s})
says = append(says, s)
}
notes := 0
var last string
for _, n := range g.Notes {
if w.holds(n.At) {
notes++
last = fmt.Sprintf("the last %s by %s", when(&n.At), n.By)
evs = append(evs, event{at: n.At, line: fmt.Sprintf("noted %s by %s: %s", when(&n.At), n.By, clip(n.Text, 80))})
}
}
if notes == 1 {
says = append(says, "1 note, "+last)
} else if notes > 1 {
says = append(says, fmt.Sprintf("%d notes, %s", notes, last))
}
links := 0
for _, l := range g.Links {
if w.holds(l.At) {
links++
evs = append(evs, event{at: l.At, line: fmt.Sprintf("linked %s %s by %s %s", l.Kind, l.Target, l.By, when(&l.At))})
}
}
if links > 0 {
says = append(says, fmt.Sprintf("%d links", links))
}
if len(evs) == 0 {
// It moved by what the tracker records of nobody: its fix's delivery said a new state.
s := fmt.Sprintf("moved %s, now %s", when(r.Moved), it.Why)
evs, says = []event{{at: *r.Moved, line: s}}, []string{s}
}
if it.State == StateStuck {
says = append(says, "stuck: "+it.Why)
}
sort.SliceStable(evs, func(i, j int) bool { return evs[i].at.Before(evs[j].at) })
out = append(out, change{line: head + strings.Join(says, "; "), last: *r.Moved, events: evs})
}
return out
}
// deliveryWhole is the part of `mesh-delivery.show` a window reads.
type deliveryWhole struct {
Delivery struct {
Repository string `json:"repository"`
Number int `json:"number"`
Issues []int `json:"issues"`
State string `json:"state"`
Check *struct {
At time.Time `json:"at"`
Gate string `json:"gate"`
Summary string `json:"summary"`
} `json:"check"`
Transitions []struct {
At time.Time `json:"at"`
Event string `json:"event"`
To string `json:"to"`
Why string `json:"why"`
} `json:"transitions"`
} `json:"delivery"`
Machines string `json:"machines"`
Waits string `json:"waits"`
}
// pullChanges is each pull request naming an issue under of (every one, for the mesh) that was opened, checked,
// merged, held or closed in the window.
func (k *KB) pullChanges(p *Progress, w window) []change {
raw, err := k.Seats("seat:mesh-delivery.deliveries", map[string]any{})
if err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery does not answer, so no pull request is said: %v", err))
return nil
}
var dl struct {
Deliveries []deliveryRow `json:"deliveries"`
}
if err := json.Unmarshal(raw, &dl); err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.deliveries answered something else, so no pull request is said: %v", err))
return nil
}
var out []change
shown, reached := 0, false
for _, d := range dl.Deliveries {
// A delivery's since is its last transition; one not final may still have been checked since.
if final(d.State) && !w.holds(d.Since) {
continue
}
if shown == MostShows {
p.Warnings = append(p.Warnings, fmt.Sprintf("more than %d deliveries moved; the rest are not read", MostShows))
break
}
shown++
raw, err := k.Seats("seat:mesh-delivery.show", map[string]any{"id": d.ID})
if err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.show %s: %v", d.ID, err))
continue
}
var s deliveryWhole
if err := json.Unmarshal(raw, &s); err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.show %s answered something else: %v", d.ID, err))
continue
}
under := p.scope.every
var names []string
for _, n := range s.Delivery.Issues {
names = append(names, strconv.Itoa(n))
if _, ok := p.scope.rows[n]; ok {
under = true
}
}
if !under {
continue
}
var evs []event
for _, t := range s.Delivery.Transitions {
if !w.holds(t.At) {
continue
}
switch {
case t.Event == "announced":
evs = append(evs, event{at: t.At, line: "opened " + when(&t.At)})
case t.To == "merged" || t.To == "closed" || strings.HasPrefix(t.To, "held") || t.To == "delivered":
evs = append(evs, event{t.At, fmt.Sprintf("%s %s: %s", t.To, when(&t.At), clip(t.Why, 80)), t.To + " " + when(&t.At)})
}
}
if c := s.Delivery.Check; c != nil && w.holds(c.At) {
evs = append(evs, event{c.At, fmt.Sprintf("check %s %s: %s", c.Gate, when(&c.At), clip(c.Summary, 80)), fmt.Sprintf("check %s %s", c.Gate, when(&c.At))})
}
if len(evs) == 0 {
continue
}
sort.SliceStable(evs, func(i, j int) bool { return evs[i].at.Before(evs[j].at) })
var says []string
for _, e := range evs {
if e.brief != "" {
says = append(says, e.brief)
} else {
says = append(says, e.line)
}
}
state := s.Delivery.State
mark := marks[StateMoving]
switch {
case state == "merged" || state == "delivered":
mark = marks[StateDone]
case strings.HasPrefix(state, "held"):
mark = marks[StateStuck]
case state == "closed":
mark = marks[StateWaiting]
}
repo := s.Delivery.Repository
if i := strings.LastIndex(repo, "/"); i >= 0 {
repo = repo[i+1:]
}
line := fmt.Sprintf("%s %s#%d (%s) %s", mark, repo, s.Delivery.Number, strings.Join(names, ", "), strings.Join(says, " · "))
switch {
case state == "merged" && s.Machines != "":
line += ", " + s.Machines
reached = true
case !final(state):
line += ", now " + state
if s.Waits != "" {
line += ", waits for " + s.Waits
}
}
out = append(out, change{line: line, last: evs[len(evs)-1].at, events: evs})
}
if reached {
p.Warnings = append(p.Warnings, "mesh-delivery keeps no time a merge reached its machines: \"on the machines\" is as it reads now")
}
return out
}
// trackerMove is one change of `issue-tracker.changes`: an issue opened or moved, from what to what, by whom and when.
type trackerMove struct {
Number int `json:"number"`
Kind string `json:"kind"`
From string `json:"from"`
To string `json:"to"`
By string `json:"by"`
Worker string `json:"worker"`
At time.Time `json:"at"`
}
// trackerMoves asks the tracker for every issue's moves in the window, from its own log, by issue, oldest first.
func (k *KB) trackerMoves(w window) (map[int][]trackerMove, error) {
raw, err := k.Seats(ChangesVerb, map[string]any{"since": w.to.Sub(w.from).String()})
if err != nil {
return nil, err
}
var r struct {
Changes []trackerMove `json:"changes"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("answered something else: %v", err)
}
out := map[int][]trackerMove{}
for _, m := range r.Changes {
if w.holds(m.At) {
out[m.Number] = append(out[m.Number], m)
}
}
return out, nil
}
// ChangesVerb is the tracker's verb that answers its issues' moves over a window.
const ChangesVerb = "seat:issue-tracker.changes"
@@ -2,8 +2,6 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
@@ -395,150 +393,3 @@ func TestProgressOfAnIssueAndARecord(t *testing.T) {
t.Error("an issue the tracker does not hold was answered")
}
}
// sinceWorld plays the tracker and the delivery seat from what they answered on 2026-10-28 (testdata/progress/since):
// a get or a show it holds no answer for is refused, as a seat that cannot answer. The tracker's changes answers what
// mesh-issues' verb made of 1422's open and move to fixing (testdata/progress/README.md); without changes it is
// refused, as by a tracker that has no such verb yet.
func sinceWorld(t *testing.T, changes bool) func(string, any) (json.RawMessage, error) {
t.Helper()
read := func(name string) json.RawMessage { return readFixture(t, filepath.Join("since", name)) }
var rows []json.RawMessage
if err := json.Unmarshal(read("rows.json"), &rows); err != nil {
t.Fatal(err)
}
byNumber := map[int]json.RawMessage{}
for _, r := range rows {
var n struct{ Number int }
_ = json.Unmarshal(r, &n)
byNumber[n.Number] = r
}
shows := map[string]string{"novox/mesh-catalog@470687ea4cce": "show-mesh-catalog-539.json", "novox/mesh-catalog@f5d4b2f86ed4": "show-mesh-catalog-537.json",
"novox/mesh-delivery@2b801f5bd49a": "show-mesh-delivery-94.json", "novox/de-spiegel@fba6dbe3259e": "show-de-spiegel-6.json"}
return func(key string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
var a struct {
Numbers []int `json:"numbers"`
Number int `json:"number"`
ID string `json:"id"`
One string `json:"one"`
}
_ = json.Unmarshal(raw, &a)
switch key {
case MilestonesVerb:
return read("milestone-47-6.json"), nil
case "seat:issue-tracker.list":
var out []json.RawMessage
for _, n := range a.Numbers {
if r, ok := byNumber[n]; ok {
out = append(out, r)
}
}
return json.Marshal(map[string]any{"rows": out, "matched": len(out)})
case "seat:issue-tracker.changes":
if changes {
return read("changes.json"), nil
}
return nil, errors.New("mesh-issues has no tool issue-tracker.changes")
case "seat:issue-tracker.get":
if a.Number == 1422 || a.Number == 1956 {
return read(fmt.Sprintf("get-%d.json", a.Number)), nil
}
case "seat:mesh-delivery.deliveries":
return read("deliveries.json"), nil
case "seat:mesh-delivery.groups":
return json.RawMessage(`[]`), nil
case "seat:mesh-delivery.show":
if f, ok := shows[a.ID]; ok {
return read(f), nil
}
}
return nil, os.ErrNotExist
}
}
// The operator, 2026-10-28: "can the progress tool also report the progress made since the last call, or maybe better,
// the last 60 minutes". With since, an answer lists only what changed in that window under of, read from the
// tracker's notes, links, workers and closes, its milestones' history and mesh-delivery's transitions and checks.
func TestProgressSinceAWindowSaysOnlyWhatChanged(t *testing.T) {
at := func(s string) *time.Time { v, _ := time.Parse(time.RFC3339, s); return &v }
k := &KB{Records: progressRecords(), Seats: sinceWorld(t, true), Now: func() time.Time { return *at("2026-10-28T11:13:30Z") }}
p, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h"})
if err != nil {
t.Fatal(err)
}
lines := strings.Split(p.Tree, "\n")
if lines[0] != "17 changes in the last 120 min under issue 1422 (since "+when(at("2026-10-28T09:13:30Z"))+")" || p.Changes != 17 || p.Since != "2h0m0s" {
t.Fatalf("%d changes, since %q:\n%s", p.Changes, p.Since, p.Tree)
}
for _, want := range []string{
"▶ 1422 " + clip("The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none", 80) + " — open → fixing by fix-trunk-unknown@shanks " + when(at("2026-10-28T09:38:37Z")) +
"; 9 notes, the last " + when(at("2026-10-28T11:05:21Z")) + " by shanks.node-tools",
"✓ mesh-catalog#537 (1422) opened " + when(at("2026-10-28T10:20:56Z")) + " · check pass " + when(at("2026-10-28T10:30:45Z")) + " · merged " + when(at("2026-10-28T10:34:49Z")) + ", on the machines",
"✓ mesh-delivery#94 (1126, 1422) opened ",
"▶ mesh-catalog#539 (1422, 1956) opened " + when(at("2026-10-28T11:07:42Z")) + ", now ready, waits for its merge",
} {
if !strings.Contains(p.Tree, want) {
t.Errorf("no %q in\n%s", want, p.Tree)
}
}
if strings.Contains(p.Tree, "de-spiegel") || strings.Contains(p.Tree, "triage") {
t.Errorf("a change outside issue 1422 or the window is said:\n%s", p.Tree)
}
w := strings.Join(p.Warnings, "\n")
if !strings.Contains(w, "keeps no time a merge reached its machines") || strings.Contains(w, "issue-tracker.changes") {
t.Errorf("the warnings: %v", p.Warnings)
}
// A tracker that cannot say its moves: each is said by the status it reached, and that is said.
old := &KB{Records: progressRecords(), Seats: sinceWorld(t, false), Now: k.Now}
o, err := old.Progress(ProgressAsk{Of: "issue 1422", Since: "2h"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(o.Tree, " — → fixing by fix-trunk-unknown@shanks "+when(at("2026-10-28T09:38:37Z"))) || o.Changes != 17 ||
!strings.Contains(strings.Join(o.Warnings, "\n"), "issue-tracker.changes: mesh-issues has no tool issue-tracker.changes; a move is said by the status it reached") {
t.Errorf("without changes:\n%s\n%v", o.Tree, o.Warnings)
}
d, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h", Detail: DetailDetailed})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"│ ├── open → fixing " + when(at("2026-10-28T09:38:37Z")) + " by shanks.node-tools, worker fix-trunk-unknown@shanks",
"│ ├── noted " + when(at("2026-10-28T09:38:09Z")) + " by shanks.node-tools: lead-47@shanks 2026-10-28 09:40Z.",
" └── merged " + when(at("2026-10-28T10:34:49Z")) + ": on the trunk its modules follow: merged there"} {
if !strings.Contains(d.Tree, want) {
t.Errorf("detailed: no %q in\n%s", want, d.Tree)
}
}
// A milestone's issue the tracker's get cannot answer falls back to its row's moved time, said as such.
m, err := k.Progress(ProgressAsk{Of: "milestone 47/L2", Since: "60m"})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"6 changes in the last 60 min under milestone 47/L2", "▶ 1126 " + clip("About 49,800 controller code lines stay after the delivery model, against a first limit of 25,000", 80) + " — moved " +
when(at("2026-10-28T11:13:28Z")) + ", now fixing (lead-47@shanks)", "✓ de-spiegel#6 (1126) check pass ", "✓ mesh-delivery#94 (1126, 1422) opened "} {
if !strings.Contains(m.Tree, want) {
t.Errorf("milestone: no %q in\n%s", want, m.Tree)
}
}
if w := strings.Join(m.Warnings, "\n"); !strings.Contains(w, "issue-tracker.get 1126") {
t.Errorf("the fallback is not said: %v", m.Warnings)
}
if small, _ := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h", Detail: DetailDetailed, Bound: LeastBound}); small == nil || size(small) > LeastBound || !strings.Contains(small.Cut, "raise bound") {
t.Errorf("a window's answer is not kept within its bound: %+v", small)
}
// A milestone's move, from its history: what it was before, what it is now, who moved it and when.
k.Now = func() time.Time { return *at("2026-10-26T18:40:00Z") }
ds, err := k.Progress(ProgressAsk{Of: "design 47", Since: "60m"})
if err != nil {
t.Fatal(err)
}
if ds.Tree != "1 change in the last 60 min under design 47 (since "+when(at("2026-10-26T17:40:00Z"))+")\n└── ✓ milestone 47/6 open → closed: folded into R4g · by shanks.node-tools "+when(at("2026-10-26T18:10:10Z")) {
t.Errorf("design:\n%s", ds.Tree)
}
for _, bad := range []string{"0m", "-5m", "25h", "a day", "5"} {
if _, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: bad}); err == nil || !strings.Contains(err.Error(), "since") {
t.Errorf("since %q: %v", bad, err)
}
}
}
@@ -8,12 +8,3 @@ Captured for the progress verb's tests (novox/hq issue 1947), never written by h
- `tracker-milestones-47.json`: the answer of `issue-tracker.milestones` for design 47 (issue 1781), made by running
the tracker's own table parse (mesh-issues `parseTable`, its seed) over `hq/`'s design 47, each issue two rows name
left in neither, as the seed refuses it; the tracker held no milestone records yet to capture from.
- `since/`: answers of the live tracker and delivery seat on 2026-10-28 between 11:10Z and 11:20Z, for `since`
(issue 1422 and its pull requests): `issue-tracker.list` rows, `issue-tracker.get` 1422 and 1956 (the report,
the triage and each note's text after its first sentence or two cut), `mesh-delivery.deliveries` (its first ten
rows) and `.show` of four deliveries (the ready record, the ask and the paths cut), and `issue-tracker.milestones`
with `one` 47/6, its milestones joined by 47/L2's row from `one` 47/L2.
- `since/changes.json`: the answer of mesh-issues' `issue-tracker.changes` (window 2h at 2026-10-28 11:13:30Z) made by
the tracker's own code over its log, replaying issue 1422's open (10-20 14:36:07Z) and its move to fixing by
fix-trunk-unknown@shanks (10-28 09:38:37Z) through its verbs: the live verb was not delivered yet to capture from, and
the status 1422 moved from (open) is the replay's, as no verb read the live log then. Recapture once it is delivered.
@@ -1,17 +0,0 @@
{
"since": "2026-10-28T09:13:30Z",
"changes": [
{
"number": 1422,
"title": "The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none",
"kind": "moved",
"from": "open",
"to": "fixing",
"by": "shanks.node-tools",
"worker": "fix-trunk-unknown@shanks",
"at": "2026-10-28T09:38:37Z"
}
],
"entries-read": 4,
"said": "1 changes of status since 2026-10-28T09:13:30Z, from the tracker's log (4 entries read)."
}
@@ -1,12 +0,0 @@
{"deliveries": [
{"for": "4m8s", "id": "novox/mesh-catalog@470687ea4cce", "number": 539, "since": "2026-10-28T11:07:43Z", "state": "ready", "waits": "its merge"},
{"for": "14m17s", "id": "novox/mesh-catalog@fc5335360967", "number": 538, "since": "2026-10-28T10:57:33Z", "state": "closed"},
{"for": "26m23s", "id": "novox/de-spiegel@fba6dbe3259e", "machines": "on the machines", "number": 6, "since": "2026-10-28T10:45:28Z", "state": "merged"},
{"for": "36m56s", "group": "fix/1422-trunk-unknown", "id": "novox/mesh-delivery@2b801f5bd49a", "machines": "on the machines", "number": 94, "since": "2026-10-28T10:34:54Z", "state": "merged"},
{"for": "37m2s", "group": "fix/1422-trunk-unknown", "id": "novox/mesh-catalog@f5d4b2f86ed4", "machines": "on the machines", "number": 537, "since": "2026-10-28T10:34:49Z", "state": "merged"},
{"for": "56m30s", "id": "novox/hq@dd36b7557ae6", "machines": "on the machines", "number": 489, "since": "2026-10-28T10:15:20Z", "state": "merged"},
{"for": "1h3m4s", "id": "novox/mesh-host@6502ba6ad6e6", "machines": "on the machines", "number": 159, "since": "2026-10-28T10:08:47Z", "state": "merged"},
{"for": "1h24m56s", "group": "fix/1126-seed-same", "id": "novox/mesh-host@c912bcab6e73", "machines": "on the machines", "number": 158, "since": "2026-10-28T09:46:55Z", "state": "merged"},
{"for": "2h51m31s", "id": "novox/mesh-catalog@c2606f99178a", "number": 534, "since": "2026-10-28T08:20:20Z", "state": "closed"},
{"for": "3h25m11s", "group": "fix/1126-shadow-bodies", "id": "novox/mesh-host@82d0e125b2c0", "machines": "on the machines", "number": 157, "since": "2026-10-28T07:46:40Z", "state": "merged"}
]}
@@ -1,31 +0,0 @@
{
"fixing-from": "2026-10-28T09:38:37Z",
"links": [
{"at": "2026-10-20T14:37:00Z", "by": "triage", "kind": "relates-to", "target": "1421"},
{"at": "2026-10-20T14:37:00Z", "by": "triage", "kind": "relates-to", "target": "1420"}
],
"located-in": ["mesh-controller cmd/mesh-controller/catch_up.go"],
"needs": "none",
"notes": [
{"at": "2026-10-20T14:37:00Z", "by": "triage", "text": "Triage of this issue: none of 429 older issues not resolved (the 429 most recent of 493; the titles of the rest did not fit the question) asks the same question."},
{"at": "2026-10-20T14:44:57Z", "by": "triage", "text": "Triage: issue 1424 relates to this one, opened later."},
{"at": "2026-10-20T14:45:10Z", "by": "triage", "text": "Triage: issue 1425 relates to this one, opened later."},
{"at": "2026-10-28T09:38:09Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 09:40Z. New kind beside this issue (diag-despiegel-check@shanks): the checker judges a pull request only when the gate's source.main names its repository"},
{"at": "2026-10-28T09:43:50Z", "by": "shanks.node-tools", "text": "fix-trunk-unknown@shanks, 2026-10-28. Quick fix: the checker says loudly that a pull request cannot be judged when source.main names no trunk head of its repository."},
{"at": "2026-10-28T09:51:00Z", "by": "shanks.node-tools", "text": "fix-trunk-unknown@shanks, 2026-10-28, part (b) built locally, not pushed."},
{"at": "2026-10-28T10:00:02Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:05Z. de-spiegel to main (the operator, 09:58Z: 'yes use the main branch please')."},
{"at": "2026-10-28T10:07:56Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:10Z. Side finding (tools-gitea-base@shanks): modules/gitea guard.ts and gitea_api's refusal text cite 'ADR 0266'"},
{"at": "2026-10-28T10:40:43Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:41Z. Fix group delivered in part (gate 4c091b08, gitea a6cc3509 on novox at 10:39Z; indexer applying; bridge and checker pending)."},
{"at": "2026-10-28T10:44:38Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:46Z. Subtask (the operator asked why a retarget waits for a poll)"},
{"at": "2026-10-28T10:45:16Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:49Z. Correction to note 10"},
{"at": "2026-10-28T11:05:21Z", "by": "shanks.node-tools", "text": "Gitea polling, quick versus thorough (fix-gitea-events@shanks, branch fix/1422-gitea-events, commit 470687ea)."}
],
"number": 1422,
"opened": "2026-10-20T14:36:07Z",
"opened-by": "shanks.node-tools",
"ready": true,
"severity": "minor",
"status": "fixing",
"title": "The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none",
"worker": {"active": "2026-10-28T11:05:21Z", "by": "fix-trunk-unknown@shanks", "since": "2026-10-28T09:38:37Z"}
}
@@ -1,19 +0,0 @@
{
"links": [
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1903"},
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1327"},
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1420"},
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1184"}
],
"needs": "decision",
"notes": [
{"at": "2026-10-28T11:07:31Z", "by": "triage", "text": "Triage of this issue: not a duplicate; closest is 1903."}
],
"number": 1956,
"opened": "2026-10-28T11:07:10Z",
"opened-by": "shanks.node-tools",
"ready": true,
"severity": "major",
"status": "open",
"title": "Parts of the mesh still poll or wait on timers instead of reacting to events on the bus"
}
@@ -1,9 +0,0 @@
{"history": [
{"design": 47, "id": "6", "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "order": 9, "outcome": "Folded into release 4 by ADR 0429 §10 (the fetch over SSH in R4c, the cut-over in R4e): installed toolchains, builders fetching over SSH with their own key, and an SDK change shipping with its users are built in the builder's new home, so the builder is changed once", "status": "open"},
{"design": 47, "id": "6", "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "moved": {"at": "2026-10-26T18:10:10Z", "by": "shanks.node-tools"}, "order": 9, "outcome": "Folded into release 4 by ADR 0429 §10 (the fetch over SSH in R4c, the cut-over in R4e): installed toolchains, builders fetching over SSH with their own key, and an SDK change shipping with its users are built in the builder's new home, so the builder is changed once", "reason": "folded into R4g", "status": "closed"}
],
"milestones": [
{"design": 47, "done": 0, "id": "6", "issues": [], "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "moved": {"at": "2026-10-26T18:10:10Z", "by": "shanks.node-tools"}, "order": 9, "outcome": "Folded into release 4 by ADR 0429 §10 (the fetch over SSH in R4c, the cut-over in R4e): installed toolchains, builders fetching over SSH with their own key, and an SDK change shipping with its users are built in the builder's new home, so the builder is changed once", "reason": "folded into R4g", "ref": "47/6", "status": "closed"},
{"design": 47, "done": 0, "id": "L2", "issues": [1126], "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "order": 31, "outcome": "Nodes compose: each node agent composes its own modules, and no node applies a composition made elsewhere but the bus's user list (ADR 0547 §11)", "ref": "47/L2", "status": "open"}
],
"said": "2 milestones."}
@@ -1,5 +0,0 @@
[
{"moved": "2026-10-28T11:13:28Z", "needs": "decision", "number": 1126, "owner": "mesh-controller", "ready": false, "severity": "minor", "since": "2026-10-27T01:01:41Z", "status": "fixing", "title": "About 49,800 controller code lines stay after the delivery model, against a first limit of 25,000", "worker": "lead-47@shanks"},
{"moved": "2026-10-28T11:05:21Z", "needs": "none", "number": 1422, "owner": "mesh-controller cmd/mesh-controller/catch_up.go", "ready": true, "severity": "minor", "since": "2026-10-28T09:38:37Z", "status": "fixing", "title": "The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none", "worker": "fix-trunk-unknown@shanks"},
{"moved": "2026-10-28T11:07:31Z", "needs": "decision", "number": 1956, "owner": "mesh-host; mesh-catalog modules/gitea; mesh-delivery; mesh-controller", "ready": true, "severity": "major", "status": "open", "title": "Parts of the mesh still poll or wait on timers instead of reacting to events on the bus"}
]
@@ -1,10 +0,0 @@
{"delivery": {"id": "novox/de-spiegel@fba6dbe3259e", "number": 6, "repository": "novox/de-spiegel", "issues": [1126], "state": "merged", "since": "2026-10-28T10:45:28.285947172Z",
"title": "Take the practice's public names from config, not the definition (issue 1126)",
"check": {"at": "2026-10-28T10:44:13.035037566Z", "gate": "pass", "head": "fba6dbe3259e", "status": "mesh/check", "summary": "pass fba6dbe3259e on 927d302714af | unit 0 run 0 kept | int 0 run 0 kept | clone 31s lint 177ms | composition passed", "trunk": "927d302714af"},
"merged_at": "2026-10-28T10:45:00Z",
"transitions": [
{"at": "2026-10-28T08:49:52.467925371Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/de-spiegel#6's head announced"},
{"at": "2026-10-28T08:49:54.046089933Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"},
{"at": "2026-10-28T10:45:28.285947172Z", "event": "merged", "from": "ready", "to": "merged", "why": "on the trunk its modules follow: merged there"}
]},
"machines": "on the machines", "waits": ""}
@@ -1,10 +0,0 @@
{"delivery": {"id": "novox/mesh-catalog@f5d4b2f86ed4", "number": 537, "repository": "novox/mesh-catalog", "issues": [1422], "group": "fix/1422-trunk-unknown", "state": "merged", "since": "2026-10-28T10:34:49.628764012Z",
"title": "Gate seeds a never-merged repository's trunk head and follows a retargeted base; gitea announces a retarget and sets a default branch and a pull request's base (issue 1422; group fix/1422-trunk-unknown)",
"check": {"at": "2026-10-28T10:30:45.793798647Z", "gate": "pass", "head": "f5d4b2f86ed4", "status": "mesh/check", "summary": "pass f5d4b2f86ed4 on 9fd95693db54 | unit 1329 run, 3852 kept, 13 skipped | integration 1330 run, 3968 kept, 21 skipped | over bound: unit, …", "trunk": "9fd95693db54"},
"merged_at": "2026-10-28T10:34:47Z",
"transitions": [
{"at": "2026-10-28T10:20:56.142821047Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/mesh-catalog#537's head announced"},
{"at": "2026-10-28T10:20:58.259805356Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"},
{"at": "2026-10-28T10:34:49.628764012Z", "event": "merged", "from": "ready", "to": "merged", "why": "on the trunk its modules follow: merged there"}
]},
"machines": "on the machines", "waits": ""}
@@ -1,8 +0,0 @@
{"delivery": {"id": "novox/mesh-catalog@470687ea4cce", "number": 539, "repository": "novox/mesh-catalog", "issues": [1422, 1956], "state": "ready", "since": "2026-10-28T11:07:43.555244108Z",
"title": "gitea announces from the forge's events, not a poll: an edited base and a close are announced at once, one pass at start (issue 1422)",
"check_state": "pending", "merged_at": "0001-01-01T00:00:00Z",
"transitions": [
{"at": "2026-10-28T11:07:42.740718441Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/mesh-catalog#539's head announced"},
{"at": "2026-10-28T11:07:43.555244108Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"}
]},
"waits": "its merge"}
@@ -1,10 +0,0 @@
{"delivery": {"id": "novox/mesh-delivery@2b801f5bd49a", "number": 94, "repository": "novox/mesh-delivery", "issues": [1126, 1422], "group": "fix/1422-trunk-unknown", "state": "merged", "since": "2026-10-28T10:34:54.759088217Z",
"title": "Say loudly what cannot be judged, read only a merge's changed directories, and move the taken key only by an applied merge (issues 1422, 1126; group fix/1422-trunk-unknown)",
"check": {"at": "2026-10-28T10:30:45.875693927Z", "gate": "pass", "head": "2b801f5bd49a", "status": "mesh/check", "summary": "pass 2b801f5bd49a on ae7ec702a5e9 | unit 1329 run, 3852 kept, 13 skipped | integration 1330 run, 3968 kept, 21 skipped | over bound: unit, …", "trunk": "ae7ec702a5e9"},
"merged_at": "2026-10-28T10:34:51Z",
"transitions": [
{"at": "2026-10-28T10:20:50.017669046Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/mesh-delivery#94's head announced"},
{"at": "2026-10-28T10:20:51.900542745Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"},
{"at": "2026-10-28T10:34:54.759088217Z", "event": "merged", "from": "ready", "to": "merged", "why": "on the trunk its modules follow: merged there"}
]},
"machines": "on the machines", "waits": ""}
-1
View File
@@ -88,7 +88,6 @@
"seat:issue-tracker.list",
"seat:issue-tracker.get",
"seat:issue-tracker.milestones",
"seat:issue-tracker.changes",
"seat:mesh-delivery.deliveries",
"seat:mesh-delivery.show",
"seat:mesh-delivery.groups"
@@ -4,9 +4,6 @@ import (
"encoding/json"
"fmt"
"strings"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Who may ask `get` for which secret (novox/hq ADR 0349 §11, design 57 V4, issue 647).
@@ -111,9 +108,6 @@ func fieldSet(f json.RawMessage) bool {
// that the module's plan there names.
type Declared struct {
Read AssignmentReader
// Confirm, when set, reads an assignment directly (over the bus): a module Read does not hold is confirmed through it
// before a credential is refused, since a watched mirror can be behind (issue 1126, review round 1).
Confirm AssignmentReader
// Store is the vault's store, whose record of who holds a provision's one credential (pair.go) the rule reads.
Store *Store
}
@@ -213,77 +207,3 @@ func (d Declared) May(caller, name string) (bool, string) {
// bus error rather than an answer. The vault says "holds no secret named ... yet", which the client waits on and asks
// again, and records no refusal.
const notYetPrefix = "not yet: "
// WatchedAssignments is the assignments mirror as the vault's watch of it hands it over (issue 1126, 2026-10-28: novox's
// `own` read one assignment over the bus per own secret and per credential, 203 reads, and answered after 26 s, past
// the node-engine's 15 s). The state is the bus's; the vault watches it, never polls it, and reads it from memory once the
// watch has handed every current value. Until then, and when the watch could not be begun, a read goes to the bus
// (Fallback), as before.
type WatchedAssignments struct {
mu sync.Mutex
held map[string][]byte
caught bool
attempt uint64
Fallback AssignmentReader
}
// WatchAttempt is one attempt at the watch: what it hands over counts only while it is the latest attempt, so a watch
// that failed partway and is begun again keeps nothing it took (a key deleted meanwhile is never handed again), and a
// late delivery to an older attempt changes nothing (issue 1126, review round 1).
type WatchAttempt struct {
w *WatchedAssignments
n uint64
}
// Begin starts a new attempt from nothing held: reads go to the bus until it has caught up.
func (w *WatchedAssignments) Begin() WatchAttempt {
w.mu.Lock()
defer w.mu.Unlock()
w.attempt++
w.held, w.caught = map[string][]byte{}, false
return WatchAttempt{w: w, n: w.attempt}
}
// Take keeps one change the attempt handed over, a value put or a key deleted, while it is the latest attempt.
func (a WatchAttempt) Take(c stdio.StateChange) error {
w := a.w
w.mu.Lock()
defer w.mu.Unlock()
if a.n != w.attempt {
return nil
}
if c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0 {
delete(w.held, c.Key)
return nil
}
w.held[c.Key] = append([]byte(nil), c.Value...)
return nil
}
// Caught says the attempt handed every current value: reads are answered from memory from now on, if it is the latest.
func (a WatchAttempt) Caught() {
w := a.w
w.mu.Lock()
defer w.mu.Unlock()
if a.n == w.attempt {
w.caught = true
}
}
// Read is one value of the mirror, `<node>.<module>`: nil when there is none.
func (w *WatchedAssignments) Read(key string) ([]byte, error) {
w.mu.Lock()
if w.caught {
raw, ok := w.held[key]
w.mu.Unlock()
if !ok {
return nil, nil
}
return append([]byte(nil), raw...), nil
}
w.mu.Unlock()
if w.Fallback == nil {
return nil, fmt.Errorf("the assignments mirror is not watched yet, and nothing reads it from the bus")
}
return w.Fallback(key)
}
+2 -29
View File
@@ -49,37 +49,13 @@ func build() *Env {
}
// Who may ask (askers.go): a node's provisioner client, for what its module's plan there names.
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
// The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up.
assigned = &WatchedAssignments{Fallback: readAssignment}
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: assigned.Read, Confirm: readAssignment, Store: store},
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave,
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }}
return env
}
// assigned is the assignments mirror as the vault's watch keeps it.
var assigned *WatchedAssignments
// watchAssignments begins the watch of the assignments mirror the vault's rules read (issue 1126, 2026-10-28): a
// watch, never a poll, so `own` and `get` read no assignment over the bus once it has caught up. It answers whether the
// watch began; one that did not is begun again at the next minute pass, and reads go to the bus meanwhile.
func watchAssignments() bool {
if assigned == nil {
return true
}
// Each attempt begins from nothing held: one that failed partway leaves nothing a later one would not hand again.
attempt := assigned.Begin()
if err := stdio.State(AssignmentsRead).Watch("", attempt.Take); err != nil {
assigned.Begin() // what the failed attempt took counts for nothing, and reads go to the bus
say("the assignments are not watched, so each is read over the bus; begun again at the next pass: %v", err)
return false
}
attempt.Caught()
say("the assignments are watched: the vault's rules read them from memory")
return true
}
// readHave is one key of the node-engines' have bucket, read through the runtime under the vault's own account (its
// manifest `reads` it), or nil when there is none.
func readHave(key string) ([]byte, error) {
@@ -173,14 +149,11 @@ func main() {
written, failed := env.Vault.HashHeld()
say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed)
}
watched, watching := watchHandings(env), watchAssignments()
watched := watchHandings(env)
for {
if !watched {
watched = watchHandings(env)
}
if !watching {
watching = watchAssignments()
}
env.pass()
if env.Vault != nil {
env.Vault.HashOwed()

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