Compare commits

..
Author SHA1 Message Date
jschoubben 998c773420 mesh-vault: pin the group's mesh-sdk head 3f7957e (issue 774) 2026-10-27 23:02:51 +01:00
jschoubben f7e90cb049 mesh-vault: account_retire refuses while the controller holds the lease or before the wants are read whole, as the passes do (issue 774, review round 1) 2026-10-27 23:00:54 +01:00
jschoubben 98cae719bd mesh-vault: pin the group's mesh-sdk head d688f10 (issue 774) 2026-10-27 22:52:33 +01:00
jschoubben 92cabc6fb2 mesh-vault: prove the vault whole on a real bus mints only from the lease write, and try a failed mint again at the pass; adopt the operator key before the watches open (issue 774, ADR 0561 §1-§4) 2026-10-27 22:48:58 +01:00
jschoubben f1cdabbb0d mesh-vault: tell where modules stand and which node holds the bus from the catalogue's placement first, so the vault does not depend on the controller's assignments for them (issue 774, ADR 0549 §3, §5) 2026-10-27 22:45:18 +01:00
jschoubben 6bc56a4ce3 mesh-vault: serve issue-account, the hand path that mints one wanted account now, and record a rotation at most once per loaded list's revision; a covered account still never rotates in place (issue 774, ADR 0561 §4, §9) 2026-10-27 22:42:57 +01:00
jschoubben 0754c2c3ae mesh-vault: retire an account the grants part no longer wants only within ADR 0230's bounds, keeping its value, and re-enable it when wanted again; account_retire for a person past the bound (issue 774, ADR 0561 §2) 2026-10-27 22:39:13 +01:00
jschoubben d422479180 mesh-vault: mint only while the controller's lease is handed away, read afresh before each mint, and say in vault_status what is held back and why (issue 774, ADR 0561 §3, §9) 2026-10-27 22:35:08 +01:00
jschoubben d0bba2d364 mesh-vault: mint each account the grants part wants and the vault holds no value or adopted hash for, in the controller's shape, hashed before account-issued and secret-changed (issue 774, ADR 0561 §1, §2, §4) 2026-10-27 22:33:44 +01:00
129 changed files with 2232 additions and 4509 deletions
-1
View File
@@ -41,4 +41,3 @@ checks/placement/placement
# Built by their modules' build step (issue 1577), never committed. # Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools modules/forticlient/cmd/forticlient-tools/forticlient-tools
modules/networkmanager/uplink-tools modules/networkmanager/uplink-tools
checks/words/words
+6 -12
View File
@@ -1,4 +1,4 @@
// Package nobinaries refuses a compiled program anywhere in the repository (issue 1577; first a checks/ binary slipped past a modules/-only rule): it is build output, made by the module's build step, // Package nobinaries refuses a compiled program under modules/ (issue 1577): it is build output, made by the module's build step,
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it. // and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
package nobinaries package nobinaries
@@ -27,10 +27,10 @@ func Kind(head []byte) string {
return "" return ""
} }
// Files are the files of the catalogue's root that the change holds, anywhere: the tracked ones when the root is a git // Files are the files of the catalogue's root that the change holds under modules/: the tracked ones when the root is a git
// checkout (an ignored binary a local build left is not the change's), else every file there but .git's. // checkout (an ignored binary a local build left is not the change's), else every file there.
func Files(root string) ([]string, error) { func Files(root string) ([]string, error) {
cmd := exec.Command("git", "-C", root, "ls-files", "-z") cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "modules")
if out, err := cmd.Output(); err == nil && len(out) > 0 { if out, err := cmd.Output(); err == nil && len(out) > 0 {
var files []string var files []string
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") { for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
@@ -41,16 +41,10 @@ func Files(root string) ([]string, error) {
return files, nil return files, nil
} }
var files []string var files []string
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d os.DirEntry, err error) error {
if err != nil { if err != nil || d.IsDir() || !d.Type().IsRegular() {
return err return err
} }
if d.IsDir() && d.Name() == ".git" {
return filepath.SkipDir
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
rel, _ := filepath.Rel(root, p) rel, _ := filepath.Rel(root, p)
files = append(files, rel) files = append(files, rel)
return nil return nil
+2 -37
View File
@@ -2,7 +2,6 @@ package nobinaries
import ( import (
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -17,7 +16,6 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
"modules/e/short": []byte("\x7f"), "modules/e/short": []byte("\x7f"),
"modules/f/empty": {}, "modules/f/empty": {},
"modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O "modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O
"checks/words/words": []byte("\x7fELF\x02\x01\x01\x00rest"), // outside modules/ too
} }
var names []string var names []string
for f, b := range files { for f, b := range files {
@@ -34,8 +32,8 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(got) != 4 { if len(got) != 3 {
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c and checks/words/words only", got) t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c only", got)
} }
} }
@@ -54,36 +52,3 @@ func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f) t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f)
} }
} }
// A compiled program outside modules/ is refused too (a binary under checks/ slipped past the modules/-only rule): Files holds
// every tracked file of a git checkout and every file of a plain folder, and Find names the binary in both.
func TestAProgramUnderChecksIsRefused(t *testing.T) {
for _, git := range []bool{true, false} {
root := t.TempDir()
p := filepath.Join(root, "checks", "words", "words")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("\x7fELF\x02\x01\x01\x00rest"), 0o644); err != nil {
t.Fatal(err)
}
if git {
for _, args := range [][]string{{"init", "-q"}, {"add", "-A"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
}
files, err := Files(root)
if err != nil {
t.Fatal(err)
}
found, err := Find(root, files)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Errorf("git=%v: found %v; wanted checks/words/words (ELF)", git, found)
}
}
}
+5 -1
View File
@@ -1 +1,5 @@
# every tracked file of the repository (git ls-files, no path limit): the whole tree, so this package's verdict is never kept. # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every file under modules/ (git ls-files and a walk).
modules
-8
View File
@@ -206,9 +206,6 @@ func excerpt(s string, from, to int) string {
return strings.Join(strings.Fields(s[a:b]), " ") return strings.Join(strings.Fields(s[a:b]), " ")
} }
// modulePath matches a string that is only a module or import path ("git.example.org/org/repo/go/pkg").
var modulePath = regexp.MustCompile(`^[a-z0-9.-]+\.[a-z]+/[A-Za-z0-9_./-]+$`)
// goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them. // goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them.
func goStrings(path, rel string) ([]text, error) { func goStrings(path, rel string) ([]text, error) {
src, err := os.ReadFile(path) src, err := os.ReadFile(path)
@@ -234,11 +231,6 @@ func goStrings(path, rel string) ([]text, error) {
if err != nil { if err != nil {
v = lit v = lit
} }
if modulePath.MatchString(v) {
// An import path or another module path names code, not words an agent reads.
cur, joining = nil, false
continue
}
if cur != nil && joining { if cur != nil && joining {
cur.s += v cur.s += v
} else { } else {
@@ -23,7 +23,7 @@ import (
// manifest's. // manifest's.
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE" const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
// CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through. // CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
const ( const (
CatalogueModules = "mesh-catalog.catalog_modules" CatalogueModules = "mesh-catalog.catalog_modules"
CatalogueModule = "mesh-catalog.catalog_module" CatalogueModule = "mesh-catalog.catalog_module"
@@ -143,21 +143,21 @@ func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
return out return out
} }
// Definitions is what the catalog's definitions pin in the store, and what of them could not be read. // Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
type Definitions struct { type Definitions struct {
// Pins are by Key(repository, digest). // Pins are by Key(repository, digest).
Pins map[string][]Pin Pins map[string][]Pin
// Read is how many definitions were read; Unread says, per module, why its definition was not. // Read is how many definitions were read; Unread says, per module, why its definition was not.
Read int Read int
Unread map[string]string Unread map[string]string
// NotAsked says why the catalog could not be asked at all, when it could not. // NotAsked says why the catalogue could not be asked at all, when it could not.
NotAsked string NotAsked string
// NamesNotRead says why the store's older names could not be read, when they could not. // NamesNotRead says why the store's older names could not be read, when they could not.
NamesNotRead string NamesNotRead string
Names StoreNames Names StoreNames
} }
// Catalogue reads the definitions through the catalog's tools, and keeps each definition it read by its // Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
// module and commit, which never change together. // module and commit, which never change together.
type Catalogue struct { type Catalogue struct {
Ask Asking Ask Asking
@@ -171,21 +171,21 @@ type catalogueEntry struct {
Commit string `json:"commit"` Commit string `json:"commit"`
} }
// Read asks the catalog for every module and reads each definition, bounded by DefinitionsTimeout. // Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions { func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names} defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
if c == nil || c.Ask == nil { if c == nil || c.Ask == nil {
defs.NotAsked = "this bundle has no way to ask the catalog" defs.NotAsked = "this bundle has no way to ask the catalogue"
return defs return defs
} }
raw, err := c.Ask(CatalogueModules, map[string]any{}) raw, err := c.Ask(CatalogueModules, map[string]any{})
if err != nil { if err != nil {
defs.NotAsked = "the catalog's modules could not be asked: " + err.Error() defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
return defs return defs
} }
answer, output, ok := answerOf(raw) answer, output, ok := answerOf(raw)
if !ok { if !ok {
defs.NotAsked = "the catalog refused its modules: " + lastLine(output) defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
return defs return defs
} }
var list struct { var list struct {
@@ -193,7 +193,7 @@ func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
Error string `json:"error"` Error string `json:"error"`
} }
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil { if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
defs.NotAsked = "the catalog's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output)) defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
return defs return defs
} }
c.mu.Lock() c.mu.Lock()
@@ -54,7 +54,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
Name: "artifact_store_references", Name: "artifact_store_references",
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " + Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " + "names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
"record names it, and a definition in the catalog pins it by digest, under the store's current name or " + "record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
"an older one: the module is said), the holder of a kept archive, " + "an older one: the module is said), the holder of a kept archive, " +
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " + "eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " + "a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
+2 -2
View File
@@ -45,8 +45,8 @@ whenever the node's tool runtime collects the module's tools:
| file | holds | | file | holds |
|---|---| |---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh` (with a `timeout` of 660000 ms, mesh_wait's longest wait and a minute, so the agent's one-minute default does not cut a wait, issue 1871), and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | | `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` (660000) in `env` beside the operator's own, the mesh's key winning | | `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one | | `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in | | `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
@@ -83,20 +83,6 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
for k := range MarketplaceKeys() { for k := range MarketplaceKeys() {
delete(m, k) delete(m, k)
} }
// The wait bound is new since the TypeScript (issue 1871), and is held below.
if env, ok := m["env"].(map[string]any); ok {
delete(env, "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT")
if len(env) == 0 {
delete(m, "env")
}
}
}
if m, ok := a.(map[string]any); ok && file == "managed-mcp.json" {
if servers, ok := m["mcpServers"].(map[string]any); ok {
if entry, ok := servers["mesh"].(map[string]any); ok {
delete(entry, "timeout")
}
}
} }
_ = json.Unmarshal([]byte(want[file]), &b) _ = json.Unmarshal([]byte(want[file]), &b)
if !reflect.DeepEqual(a, b) { if !reflect.DeepEqual(a, b) {
@@ -118,21 +104,6 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil { if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
t.Fatalf("%v", mcp.MCPServers) t.Fatalf("%v", mcp.MCPServers)
} }
// The entry bounds a call by mesh_wait's longest wait and the idle bound is laid over the operator's env
// (issue 1871): a session's wait is not cut at the agent's one-minute default.
if mcp.MCPServers["mesh"]["timeout"] != float64(660000) {
t.Errorf("the mesh entry's timeout is %v", mcp.MCPServers["mesh"]["timeout"])
}
for _, held := range []map[string]any{nil, {"CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "1", "FOO": "bar"}} {
var m struct {
Env map[string]any `json:"env"`
}
got := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"env": held}}, nil, "/h", nil, Config{})
_ = json.Unmarshal([]byte(got["managed-settings.json"]), &m)
if m.Env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] != "660000" || (held != nil && m.Env["FOO"] != "bar") {
t.Errorf("env %v rendered as %v", held, m.Env)
}
}
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) { if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
t.Fatal("rendering is not deterministic") t.Fatal("rendering is not deterministic")
} }
@@ -4,9 +4,9 @@ package main
// the file under the agent's managed directory did not change by a byte when the module moved to Go; // the file under the agent's managed directory did not change by a byte when the module moved to Go;
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json). // a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
func instructionsText(node, role string) string { func instructionsText(node, role string) string {
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" + return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
node + node +
"`\n- **Role:** " + "`\n- **Role:** " +
role + role +
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" "\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
} }
+1 -24
View File
@@ -22,7 +22,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
) )
@@ -99,27 +98,6 @@ func jsonFile(v any) string {
return b.String() return b.String()
} }
// MeshWaitBoundMillis is how long the agent may wait on the mesh's console for one call: mesh_wait's longest
// wait (600 s, mesh-tools node-tools console.WaitLongest) plus a minute's margin. The agent bounds an HTTP
// server's request by the largest of 60 s, the server entry's "timeout" and MCP_TIMEOUT, and a call that
// receives nothing for CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (5 minutes by default) is aborted
// (code.claude.com/docs/en/mcp.md). Without these every mesh_wait ended "The operation timed out." at a
// minute, whatever its timeout (issue 1871).
const MeshWaitBoundMillis = 660000
// meshWaitEnv lays the idle bound into the environment the settings give every session, the mesh's key winning
// over the operator's, like the forge's keys.
func meshWaitEnv(managed map[string]any) {
env := map[string]any{}
if held, ok := managed["env"].(map[string]any); ok {
for k, v := range held {
env[k] = v
}
}
env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] = strconv.Itoa(MeshWaitBoundMillis)
managed["env"] = env
}
// Render composes the three files. registered — what was registered through this module and applies // Render composes the three files. registered — what was registered through this module and applies
// here — is laid over the servers the operator set in its settings; config is the rest of what was // here — is laid over the servers the operator set in its settings; config is the rest of what was
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections // registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
@@ -141,7 +119,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
servers[name] = entry servers[name] = entry
} }
} }
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console, "timeout": MeshWaitBoundMillis} servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered // The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
// through this module's tools is laid over it (novox/hq issue 340). // through this module's tools is laid over it (novox/hq issue 340).
@@ -165,7 +143,6 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
managed[key] = entries managed[key] = entries
} }
forgeEnv(managed, facts.ForgeCredentials) forgeEnv(managed, facts.ForgeCredentials)
meshWaitEnv(managed)
if binding != nil && binding.Kind == "api-key" { if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath managed["apiKeyHelper"] = helperPath
} }
@@ -32,11 +32,11 @@
"withKey": { "withKey": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n", "managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}, },
"plain": { "plain": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n", "managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
} }
} }
@@ -4,9 +4,6 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings"
"testing" "testing"
) )
@@ -65,60 +62,3 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Errorf("the build packs files/ into %d archives, not one", packs) 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. # 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 # 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 # machine's private address is there: it waits up to 80 seconds for it, and a start that found it
# is tried again every ten seconds (Restart=on-failure, no start limit) until the tunnel has brought it. # missing is tried again every ten seconds rather than left failed.
# 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).
[Unit] [Unit]
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0 StartLimitIntervalSec=0
[Service] [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 Restart=on-failure
RestartSec=10s RestartSec=10s
-9
View File
@@ -312,15 +312,6 @@ read-only. With a `user` it adds the teams that user is in and its effective rig
listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The
tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only. tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only.
### Two settings have named tools, and a retargeted pull request is judged again
`gitea_api` refuses a repository's settings and a pull request's base (guard.ts). Issue 1422 note 7 gave each its tool:
`gitea_default_branch_set` (owner, repo, branch, why) and `gitea_pull_base_set` (owner, repo, number, base, why). Both
act as the tools' account, need a reason, refuse a branch the repository does not have, and answer the value before and
after; a default must also be a branch a protection rule guards, and only an open pull request has its base changed.
A pull request's announced head is kept under `owner/repo#number@base`, so a retarget (same head, new base) is
announced again into the new base by the next poll instead of being skipped as already announced.
### The merging account writes the repositories the mesh delivers ### The merging account writes the repositories the mesh delivers
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of `mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
-58
View File
@@ -758,15 +758,6 @@ export class GiteaClient {
return { created: true, rule }; 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( async createPullRequest(
owner: string, owner: string,
repo: string, repo: string,
@@ -843,30 +834,6 @@ export class GiteaClient {
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) })); return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
} }
/** One branch as the forge keeps it: whether a protection rule guards it; null when the repository has no such branch. */
async branchState(owner: string, repo: string, branch: string): Promise<{ protected: boolean } | null> {
try {
const b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
return { protected: Boolean(b?.protected) };
} catch (err) {
if (/: 404 /.test(err instanceof Error ? err.message : "")) return null;
throw err;
}
}
/** Make a branch the repository's default (issue 1422): what the forge shows, clones and opens pull requests against. */
async setDefaultBranch(owner: string, repo: string, branch: string): Promise<GiteaRepo> {
return GiteaClient.mapRepo(await this.request<any>(repoPath(owner, repo), { method: "PATCH", body: JSON.stringify({ default_branch: branch }) }));
}
/** Change the branch an open pull request merges into (issue 1422). The head stays; the forge recomputes the diff. */
async setPullBase(owner: string, repo: string, index: number, base: string): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ base }) }),
);
}
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> { async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" }); await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" });
} }
@@ -1061,31 +1028,6 @@ export class GiteaAdmin {
return Array.isArray(res.body) ? res.body : []; 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 /** 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 * 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: * rule for main gets one. Answers the repositories it touched. With the administrator's login:
-2
View File
@@ -12,8 +12,6 @@
// forge as themselves; // forge as themselves;
// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps // - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5); // the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
// - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that
// exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422);
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed // - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed
// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only // against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
// that head; // that head;
+66 -67
View File
@@ -53,9 +53,8 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js"; import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { keepMainRules, mainRulesStep } from "./mainrules.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained, import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js"; requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
import { actOn, announceNewHeads, startPass } from "./watch.js";
import { GateWriter } from "./gate.js"; import { GateWriter } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js"; import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
@@ -139,7 +138,12 @@ async function pollMerged(client: GiteaClient): Promise<void> {
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops. // Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
const closedKey = `closed:${repo.full_name}#${pull.number}`; const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) { if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) await announceClosed(client, repo, pull); if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
await siblingMoves(client, repo.full_name, pull.number);
}
announced.add(closedKey); announced.add(closedKey);
continue; continue;
} }
@@ -164,23 +168,6 @@ async function pollMerged(client: GiteaClient): Promise<void> {
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string }; type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number]; type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
/** A pull request closed without a merge, announced once (ADR 0239): by its event, or by the start's pass. */
async function announceClosed(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (pull.merged || pull.state !== "closed" || announced.has(closedKey)) return false;
announced.add(closedKey);
try {
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
} catch (err) {
announced.delete(closedKey);
throw err;
}
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
await siblingMoves(client, repo.full_name, pull.number);
return true;
}
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */ /** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> { async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false; if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
@@ -256,7 +243,7 @@ let heads: Announced = {};
let primedPulls = false; let primedPulls = false;
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */ /** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> { async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
const key = headKey(repo.full_name, pull.number, pull.base); const key = `${repo.full_name}#${pull.number}`;
const sha = String(pull.head_sha ?? ""); const sha = String(pull.head_sha ?? "");
const before = heads[key]; const before = heads[key];
// Claimed before the first wait, so the other path finds it taken while this one is still announcing. // Claimed before the first wait, so the other path finds it taken while this one is still announcing.
@@ -294,18 +281,43 @@ async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf):
} }
let lastPullLook = ""; let lastPullLook = "";
/** The pass over every open pull request: run at the start and when the receiver listens, never on a timer (issue 1422). */ // **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
async function passPulls(client: GiteaClient): Promise<void> { // heuristic, and a heuristic that misses is caught by the next sweep rather than never.
const SWEEP_MS = 10 * 60_000;
let lastSweep = 0;
async function pollPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString(); const began = new Date().toISOString();
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString(); const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
for (const repo of await client.listAllRepos()) { // A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
// Every open pull request, paged (novox/hq issue 579). // pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
const open = await client.listOpenPullsSince(repo.owner, repo.name, ""); let sweep = Date.now() - lastSweep >= SWEEP_MS;
await announceNewHeads(repo.full_name, open, heads, primedPulls, dayAgo, { let updated: { owner: string; name: string; updated_at?: string }[] = [];
alreadyChecked: (pull) => alreadyChecked(client, repo, pull), if (floor && !sweep) {
announceHead: (pull) => announceHead(client, repo, pull), try {
missed: (what) => pace.miss(what), updated = await client.searchOpenPulls(floor);
}); } catch (err) {
console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`);
sweep = true;
}
}
if (sweep) lastSweep = Date.now();
for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) {
// Every open pull request updated since the floor, paged (novox/hq issue 579): the newest 20 alone left a head
// beyond them unannounced. A sweep and the first pass read every page.
const open = await client.listOpenPullsSince(repo.owner, repo.name, sweep ? "" : floor);
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
const key = `${repo.full_name}#${pull.number}`;
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
heads[key] = String(pull.head_sha);
continue;
}
if (fresh) {
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
}
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true; if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
} }
if (changedTrunks) keepTrunks(); if (changedTrunks) keepTrunks();
@@ -485,9 +497,6 @@ const checks = new CheckStatuses({
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what /** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what
* gitea_events says of them (webhook.ts). */ * gitea_events says of them (webhook.ts). */
const pace = new Pace(); const pace = new Pace();
/** Asks the pass over every repository again, shortly and once (set where the pass is made): an event that could not be
* acted on is not lost until the next start. */
let catchUp: () => void = () => {};
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false); if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2). // The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
@@ -513,15 +522,22 @@ registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll),
gates, gates,
}), mergeTool(gitea, { gates })] : [])]); }), mergeTool(gitea, { gates })] : [])]);
/** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */ /** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise<void> => actOn<RepoOf, PullOf>(event, { async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
getRepo: (o, r) => client.getRepo(o, r), const b = event.body as { owner?: string; repo?: string; number?: number };
getPull: (o, r, n) => client.getPullRequest(o, r, n) as Promise<PullOf & { state?: string; merged?: boolean }>, if (!b.owner || !b.repo || !b.number) return;
announceHead: (repo, pull) => announceHead(client, repo, pull as OpenOf), if (event.name === "forge.pull-request.closed") {
announceMerge: (repo, pull) => announceMerge(client, repo, pull), // Closed unmerged is a sibling's move; a merge is weighed with its announcement below (ADR 0382 §5).
announceClosed: (repo, pull) => announceClosed(client, repo, pull), if (!(event.body as { merged?: boolean }).merged) await siblingMoves(client, `${b.owner}/${b.repo}`, b.number);
siblingMoves: (full, n) => siblingMoves(client, full, n), return;
}); }
const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name);
const merged = event.name === "forge.pull-request.merged";
if (!head && !merged) return;
const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]);
if (head && pull.state === "open") await announceHead(client, repo, pull);
if (merged) await announceMerge(client, repo, pull);
}
/** /**
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside * **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
@@ -547,12 +563,7 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts). // A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) => await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`)); console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
try { await actOn(client, event);
await actOnEvent(client, event);
} catch (err) {
catchUp();
throw err;
}
} }); } });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server.once("error", reject); server.once("error", reject);
@@ -601,9 +612,9 @@ if (gitea) {
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s. // polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle // **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle // whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below // of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
// runs again once it is listening, so nothing said before is missed; listenForForge says its own failure. // listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
const listenOk = listenForForge(client); void listenForForge(client);
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet // The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant. // is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when // An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
@@ -635,20 +646,8 @@ if (gitea) {
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000); tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000);
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
// **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over tick(() => pollMerged(client), () => pace.every());
// every repository runs at the start and again once the receiver listens, so what happened while the module was away or tick(() => pollPulls(client), () => pace.every());
// not yet listening is announced; passes run one at a time, a failed one is asked again a minute later.
let passes: Promise<void> = Promise.resolve();
const pass = (): Promise<void> => (passes = passes.catch(() => {}).then(() =>
startPass(async () => { await pollMerged(client); await passPulls(client); }, { retryMs: 60_000 })));
void pass();
let catchUpAsked = false;
catchUp = () => {
if (catchUpAsked) return;
catchUpAsked = true;
setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000);
};
void listenOk.then((ok) => (ok ? pass() : undefined));
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its // Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
// handshake in 10 s, so the import must end while a refused bind is still asked again. // handshake in 10 s, so the import must end while a refused bind is still asked again.
bindInBackground([ bindInBackground([
+1 -2
View File
@@ -569,7 +569,6 @@
"mesh-build-node.keys", "mesh-build-node.keys",
"mesh-build-image.keys", "mesh-build-image.keys",
"mesh-delivery-checker.owed", "mesh-delivery-checker.owed",
"mesh-delivery-indexer.modules", "mesh-delivery-indexer.modules"
"mesh-delivery-gate.source"
] ]
} }
-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`;
}
+2 -8
View File
@@ -141,19 +141,13 @@ export function planText(plan: ChangePlan): string {
return lines.join("\n"); return lines.join("\n");
} }
/** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */ /** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
export type Announced = Record<string, string>; export type Announced = Record<string, string>;
/** The key a pull request's announced head is kept under: `owner/repo#number@base`. The base is in it because a pull
* request retargeted keeps its head, and a head judged into one base is not judged into another (issue 1422, de-spiegel #6). */
export function headKey(full: string, number: number, base?: string): string {
return `${full}#${number}@${base ?? ""}`;
}
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not /** Which open pull requests have a head not yet announced. A pull request merged or closed is not
* open and is never asked about. */ * open and is never asked about. */
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] { export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[headKey(full, p.number, p.base)] !== p.head_sha); return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
} }
/** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */ /** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
+4 -67
View File
@@ -34,12 +34,6 @@ const state = {
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch. // refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
admin: false, admin: false,
lastCommit: "2026-01-01T00:00:00Z", lastCommit: "2026-01-01T00:00:00Z",
// The repository's default branch and pull request 7's base, as the forge keeps them (issue 1422 note 7).
defaultBranch: "main",
base: "main",
prState: "open",
// Branches the forge answers 404 for; "scratch" exists but no protection rule guards it.
absent: ["nope"],
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's // The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the // record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
// merge commit's own sha. // merge commit's own sha.
@@ -72,10 +66,7 @@ beforeEach(() => {
state.statuses = judgedAt(TRUNK); state.statuses = judgedAt(TRUNK);
state.trunk = TRUNK; state.trunk = TRUNK;
state.emitted = []; state.emitted = [];
state.rules = [protectedMain(), { rule_name: "release/*", enable_push: false }]; state.rules = [protectedMain()];
state.defaultBranch = "main";
state.base = "main";
state.prState = "open";
state.admin = false; state.admin = false;
state.head = REVIEWED; state.head = REVIEWED;
state.landed = ""; state.landed = "";
@@ -107,22 +98,12 @@ const server = createServer(async (req, res) => {
state.asked.push(`${method} ${path}${url.search}`); state.asked.push(`${method} ${path}${url.search}`);
if (method !== "GET") state.sent.push({ method, path, body }); if (method !== "GET") state.sent.push({ method, path, body });
const repo = "/repos/novox/mesh-catalog"; const repo = "/repos/novox/mesh-catalog";
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch }); if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
if (method === "PATCH" && path === repo) {
if (body?.default_branch) state.defaultBranch = body.default_branch;
return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
}
if (method === "PATCH" && path === `${repo}/pulls/7`) {
if (body?.base) state.base = body.base;
return json(res, 201, { number: 7, title: "x", state: state.prState, merged: false, head: { ref: "feat/x", sha: state.head },
base: { ref: state.base }, html_url: "http://fake/7" });
}
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) { if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
} }
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules); if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null; const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
if (branch !== null && state.absent.includes(branch)) return json(res, 404, { message: "branch does not exist" });
if (branch !== null) { if (branch !== null) {
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it. // As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch)); const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
@@ -144,8 +125,8 @@ const server = createServer(async (req, res) => {
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]); return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
} }
if (method === "GET" && path === `${repo}/pulls/7`) { if (method === "GET" && path === `${repo}/pulls/7`) {
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged, return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : "open", merged: state.merged,
head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7", head: { ref: "feat/x", sha: state.head }, base: { ref: "main" }, html_url: "http://fake/7",
merge_commit_sha: state.merged ? state.mergeCommit : null }); merge_commit_sha: state.merged ? state.mergeCommit : null });
} }
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) { if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
@@ -828,47 +809,3 @@ test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on
state.statuses = [judgedAt(TRUNK)[1]]; state.statuses = [judgedAt(TRUNK)[1]];
assert.equal(((await merge()) as Record<string, unknown>).merged, true); assert.equal(((await merge()) as Record<string, unknown>).merged, true);
}); });
// Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as
// gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards.
test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => {
const set = (args: Record<string, unknown>) =>
tool("gitea_default_branch_set").run({ owner: "novox", repo: "mesh-catalog", why: "the trunk was recorded as unknown", ...args });
await assert.rejects(set({ branch: "nope" }), /no branch nope/);
await assert.rejects(set({ branch: "scratch" }), /no protection rule/);
await assert.rejects(set({ branch: "release/1", why: " " }), /why/);
await assert.rejects(set({ branch: ".." }), /dot segment|not a branch/);
assert.deepEqual(writes(), []);
assert.deepEqual(await set({ branch: "release/1" }), { branch: "release/1", before: "main", after: "release/1", changed: true,
why: "the trunk was recorded as unknown" });
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog"]);
assert.deepEqual(state.sent[0].body, { default_branch: "release/1" });
assert.equal(state.defaultBranch, "release/1");
state.sent = [];
assert.equal(((await set({ branch: "release/1" })) as any).changed, false, "already the default: nothing is sent");
assert.deepEqual(writes(), []);
});
test("gitea_pull_base_set answers the base before and after, and refuses a base that is missing, a closed pull request or no reason", async () => {
const set = (args: Record<string, unknown>) =>
tool("gitea_pull_base_set").run({ owner: "novox", repo: "mesh-catalog", number: 7, why: "it was opened against the wrong trunk", ...args });
await assert.rejects(set({ base: "nope" }), /no branch nope/);
await assert.rejects(set({ base: "release/1", why: "" }), /why/);
await assert.rejects(set({ base: "release/1", number: 0 }), /pull request's number/);
state.prState = "closed";
await assert.rejects(set({ base: "release/1" }), /not open/);
state.prState = "open";
assert.deepEqual(writes(), []);
assert.deepEqual(await set({ base: "release/1" }), { number: 7, before: "main", after: "release/1", changed: true,
why: "it was opened against the wrong trunk" });
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/pulls/7"]);
assert.deepEqual(state.sent[0].body, { base: "release/1" });
assert.equal(state.base, "release/1");
});
test("the two new tools are named for what mesh_search is asked, and gitea_api still refuses both settings", () => {
assert.match(tool("gitea_default_branch_set").description, /default branch/i);
assert.match(tool("gitea_pull_base_set").description, /pull request.s base/i);
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" }));
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" }));
});
+2 -2
View File
@@ -45,7 +45,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
const { headsToAnnounce } = await import("../pulls.ts"); const { headsToAnnounce } = await import("../pulls.ts");
// 25 open, every head announced before; #3 — the 23rd most recently updated — got a new head. // 25 open, every head announced before; #3 — the 23rd most recently updated — got a new head.
const announced: Record<string, string> = {}; const announced: Record<string, string> = {};
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}@main`] = sha(n, "a"); for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}`] = sha(n, "a");
const { client, server, asked } = await serve(25, { 3: sha(3, "b") }); const { client, server, asked } = await serve(25, { 3: sha(3, "b") });
try { try {
// The fault: the newest 20 alone do not hold it. // The fault: the newest 20 alone do not hold it.
@@ -58,7 +58,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
const open = await client.listOpenPullsSince("novox", "hq", ""); const open = await client.listOpenPullsSince("novox", "hq", "");
for (const p of headsToAnnounce("novox/hq", open, announced)) { for (const p of headsToAnnounce("novox/hq", open, announced)) {
said.push(`#${p.number}@${p.head_sha}`); said.push(`#${p.number}@${p.head_sha}`);
announced[`novox/hq#${p.number}@${p.base}`] = String(p.head_sha); announced[`novox/hq#${p.number}`] = String(p.head_sha);
} }
} }
assert.deepEqual(said, [`#3@${sha(3, "b")}`], "the new head past the newest 20, announced once over two passes"); assert.deepEqual(said, [`#3@${sha(3, "b")}`], "the new head past the newest 20, announced once over two passes");
-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);
});
+2 -13
View File
@@ -8,27 +8,16 @@ import { createServer } from "node:http";
test("each open pull request's new head is announced once, and nothing closed or merged", async () => { test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
const { headsToAnnounce } = await import("../pulls.ts"); const { headsToAnnounce } = await import("../pulls.ts");
const pulls = [ const pulls = [
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", base: "main", html_url: "" }, { number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" }, { number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" }, { number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
{ number: 4, title: "d", state: "open", merged: false, html_url: "" }, { number: 4, title: "d", state: "open", merged: false, html_url: "" },
]; ];
const announced = { "novox/mesh-catalog#1@main": "aaa", "novox/mesh-catalog#2@": "old" }; const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2], assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
"only a head not announced, of a pull request that is open"); "only a head not announced, of a pull request that is open");
}); });
test("a pull request retargeted keeps its head and is announced again, into its new base (de-spiegel #6)", async () => {
const { headsToAnnounce, headKey } = await import("../pulls.ts");
const was = { number: 6, title: "a", state: "open", merged: false, head_sha: "aaa", base: "old-trunk", html_url: "" };
const announced: Record<string, string> = { [headKey("novox/de-spiegel", 6, "old-trunk")]: "aaa" };
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [was], announced), [], "the same head into the same base is announced once");
const retargeted = { ...was, base: "main" };
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [retargeted], announced).map((p) => p.number), [6],
"the same head into a new base is judged afresh");
assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk"));
});
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => { test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts"); const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" }; const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
+1 -2
View File
@@ -538,8 +538,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_get_file", "gitea_get_file",
"gitea_list_branches", "gitea_list_branches",
"gitea_delete_branch", "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_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
"gitea_list_labels", "gitea_create_label", "gitea_list_labels", "gitea_create_label",
"gitea_api", "gitea_api",
-73
View File
@@ -1,73 +0,0 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import type { GiteaPull } from "../client.ts";
// **No poll of the forge** (issue 1422, notes 10 and 11): the forge's events announce heads and merges; a pass runs once at
// the start and when the receiver listens.
const edited = (base: string) => ({ name: "forge.pull-request.edited", body: { owner: "novox", repo: "hq", number: 7, base, merged: false } });
function deps() {
const calls: string[] = [];
return { calls, d: {
getRepo: async () => ({ r: 1 }),
getPull: async () => ({ state: "open", base: "trunk" }) as any,
announceHead: async () => { calls.push("head"); },
announceMerge: async () => { calls.push("merge"); },
announceClosed: async () => { calls.push("closed"); },
siblingMoves: async () => { calls.push("siblings"); },
} };
}
test("an edited event announces the head into the base it now has, without a poll", async () => {
const { actOn } = await import("../watch.ts");
const { calls, d } = deps();
await actOn(edited("trunk"), d);
assert.deepEqual(calls, ["head"]);
});
test("a close without a merge is announced by its event, a merged close is left to the merge event", async () => {
const { actOn } = await import("../watch.ts");
const { calls, d } = deps();
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: false } }, d);
assert.deepEqual(calls, ["closed", "siblings"]);
calls.length = 0;
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: true } }, d);
assert.deepEqual(calls, []);
});
test("no timer polls after the start: one pass ends and nothing is scheduled; a failed one is asked again", async () => {
const { startPass } = await import("../watch.ts");
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
try {
let runs = 0;
await startPass(async () => { runs++; }, { retryMs: 60_000, log: () => {} });
mock.timers.tick(24 * 3600_000);
assert.equal(runs, 1, "a day later the forge was not asked again");
let tries = 0;
const retried = startPass(async () => { if (++tries < 2) throw new Error("forge down"); }, { retryMs: 60_000, log: () => {} });
await Promise.resolve(); await Promise.resolve();
mock.timers.tick(60_000);
await retried;
assert.equal(tries, 2);
} finally {
mock.timers.reset();
}
});
test("a start pass announces a head nobody announced, and only remembers one the forge shows a check of", async () => {
const { announceNewHeads } = await import("../watch.ts");
const pull = (n: number, sha: string): GiteaPull => ({ number: n, state: "open", head_sha: sha, base: "main",
updated_at: new Date().toISOString() } as unknown as GiteaPull);
const announced: number[] = [], said: string[] = [];
const heads: Record<string, string> = {};
await announceNewHeads("novox/hq", [pull(1, "a".repeat(40)), pull(2, "b".repeat(40))], heads, false,
new Date(Date.now() - 24 * 3600_000).toISOString(), {
alreadyChecked: async (p) => p.number === 2,
announceHead: async (p) => { announced.push(p.number); return true; },
missed: (w) => said.push(w),
});
assert.deepEqual(announced, [1]);
assert.equal(heads["novox/hq#2@main"], "b".repeat(40), "the checked head is remembered, not announced");
assert.equal(said.length, 1);
});
+11 -7
View File
@@ -251,8 +251,7 @@ test("a forge that never delivers is said loudly once, and unhealthy, when the p
assert.equal(r.healthy, true); assert.equal(r.healthy, true);
assert.equal(r.verified.count, 1); assert.equal(r.verified.count, 1);
assert.equal(r.verified.last.event, "push"); assert.equal(r.verified.last.event, "push");
assert.equal(r.polls.every_seconds, null); assert.equal(r.polls.every_seconds, 300);
assert.equal(r.polls.hearing, true);
assert.match(said.join("\n"), /heard again/); assert.match(said.join("\n"), /heard again/);
}); });
@@ -315,19 +314,24 @@ test("without a receiver by configuration the polls alone watch, healthy; a rece
assert.match(r.why, /403/); assert.match(r.why, /403/);
}); });
test("nothing is paced: the report says no timer asks the forge, and a miss is still counted and said", async () => { test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
const { Pace } = await import("../webhook.ts"); const { Pace } = await import("../webhook.ts");
let now = 1_000_000; let now = 1_000_000;
const said: string[] = []; const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l)); const pace = new Pace(() => now, (l) => said.push(l));
assert.equal((pace as any).every, undefined, "no pace to ask"); assert.equal(pace.every(), 30_000, "no delivery yet");
assert.equal((pace.report() as any).polls.every_seconds, null); pace.heard();
assert.equal(pace.every(), 300_000, "a delivery heard");
now += 11 * 60_000;
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
pace.heard(); pace.heard();
assert.equal((pace.report() as any).polls.hearing, true);
now += 1000; now += 1000;
pace.miss("novox/hq#7 at aaaaaaaa"); pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal((pace.report() as any).polls.hearing, false, "the pass found what no event said"); assert.equal(pace.every(), 30_000, "the poll found what no event said");
assert.match(said.join("\n"), /MISSED novox\/hq#7/); assert.match(said.join("\n"), /MISSED novox\/hq#7/);
now += 1000;
pace.heard();
assert.equal(pace.every(), 300_000, "the next delivery");
}); });
// Issue 1079: a pull request's edit event carries its description, so mesh-delivery reads a Reviewed-by line written after // Issue 1079: a pull request's edit event carries its description, so mesh-delivery reads a Reviewed-by line written after
+6 -157
View File
@@ -12,8 +12,6 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js";
import { readTeamRights } from "../teamrights.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 { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import { import {
@@ -90,13 +88,13 @@ function listedDirs(raw: unknown): string[] | undefined {
return raw as string[]; return raw as string[];
} }
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin, source)); 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 // 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). // 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 [ return [
// ---- Repositories ---- // ---- Repositories ----
{ {
@@ -433,9 +431,8 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
}, },
run: async (args) => { run: async (args) => {
const owner = String(args.owner), repo = String(args.repo); 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 // The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
// administrator's, which the tools' account is not. gitea_team_rights reads, and gitea_branch_protection_set_full // repository administrator's, which the tools' account is not. No other tool uses this account.
// and gitea_repo_settings_set_full write, with the same account.
const rules = await (admin ? admin() : gitea).branchProtections(owner, repo); const rules = await (admin ? admin() : gitea).branchProtections(owner, repo);
if (!args.branch) return { rules: rules.map(summarised) }; if (!args.branch) return { rules: rules.map(summarised) };
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null; const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null;
@@ -491,154 +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",
description: "Set a repository's default branch — the branch the forge shows, clones and opens pull requests against, and the one gitea_api never writes a file to. Refused when the branch does not exist, when no protection rule guards it (a default nothing protects would open it to direct writes), or when no reason (why) is given; acts as the tools' account, so the forge refuses it where that account is no administrator of the repository. Answers the default branch before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch to make the default; it must exist and be guarded by a protection rule" },
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
},
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 to make the default");
if (!why) throw new Error("say why (why): a default branch is changed for a reason, which the call's record keeps");
pathSegments("branch", branch);
const state = await gitea.branchState(owner, repo, branch);
if (!state) throw new Refused(`${owner}/${repo} has no branch ${branch}, so it cannot be the default branch. Nothing was sent`);
if (!state.protected) {
throw new Refused(`no protection rule guards ${branch} of ${owner}/${repo}: a default branch nothing protects takes direct writes ` +
`(gitea_branch_protection_set guards it first). Nothing was sent`);
}
const before = String((await gitea.getRepo(owner, repo)).default_branch ?? "");
if (before === branch) return { branch, before, after: before, changed: false, why };
const after = String((await gitea.setDefaultBranch(owner, repo, branch)).default_branch ?? "");
return { branch, before, after, changed: before !== after, why };
},
},
{
name: "gitea_pull_base_set",
description: "Change an open pull request's base — the branch it merges into — keeping its head. The checks were judged against the base it had, so the pull request is announced again into the new one. Refused when the base branch does not exist, when the pull request is not open, or when no reason (why) is given; acts as the tools' account. Answers the pull request's base before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the pull request's number" },
base: { type: "string", description: "the branch it should merge into; it must exist" },
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
const base = String(args.base ?? "").trim(), why = String(args.why ?? "").trim();
if (!Number.isInteger(number) || number < 1) throw new Error(`${args.number} is not a pull request's number`);
if (!base) throw new Error("name the base branch");
if (!why) throw new Error("say why (why): a pull request's base is changed for a reason, which the call's record keeps");
pathSegments("branch", base);
if (!(await gitea.branchState(owner, repo, base))) throw new Refused(`${owner}/${repo} has no branch ${base}, so a pull request cannot merge into it. Nothing was sent`);
const had = await gitea.getPullRequest(owner, repo, number);
if (had.state !== "open" || had.merged) throw new Refused(`${owner}/${repo}#${number} is not open, so its base is not changed. Nothing was sent`);
const before = String(had.base ?? "");
if (before === base) return { number, before, after: before, changed: false, why };
const after = String((await gitea.setPullBase(owner, repo, number, base)).base ?? "");
return { number, before, after, changed: before !== after, why };
},
},
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ---- // ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
{ {
name: "gitea_note_append", name: "gitea_note_append",
@@ -733,7 +582,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
}, },
{ {
name: "gitea_commit_trees", name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With 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.", 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 catalogue's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
input: { input: {
owner: { type: "string", description: "the repository owner" }, owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" }, repo: { type: "string", description: "the repository name" },
-87
View File
@@ -1,87 +0,0 @@
// What the forge's events ask of the announcer, and the one pass at a start (issue 1422, notes 10 and 11). Nothing polls the
// forge: a head or merge is announced when its event is heard, and a pass over every repository runs once when the module
// starts and again when the receiver is listening, so what happened while it was down is announced too.
//
// Apart from index.ts, which has side effects at import, so these are tested with the announcers injected.
import type { GiteaPull, GiteaRepo } from "./client.js";
import type { Announced } from "./pulls.js";
import { headKey, headsToAnnounce } from "./pulls.js";
import type { ForgeEvent } from "./webhook.js";
export interface ActDeps<R, P> {
getRepo(owner: string, repo: string): Promise<R>;
getPull(owner: string, repo: string, number: number): Promise<P & { state?: string; merged?: boolean }>;
announceHead(repo: R, pull: P): Promise<unknown>;
announceMerge(repo: R, pull: P): Promise<unknown>;
announceClosed(repo: R, pull: P): Promise<unknown>;
siblingMoves(full: string, number: number): Promise<unknown>;
}
const HEAD_EVENTS = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"];
/**
* What the forge's event asks of the announcer at once: a new head is announced, a merge, a close without a merge, and a
* pull request retargeted (an edit: the head is announced into the base it now has — the base is in the head's key, so an
* edit that changed nothing about the base announces nothing).
*/
export async function actOn<R, P>(event: ForgeEvent, d: ActDeps<R, P>): Promise<void> {
const b = event.body as { owner?: string; repo?: string; number?: number; merged?: boolean };
if (!b.owner || !b.repo || !b.number) return;
const edited = event.name === "forge.pull-request.edited";
const closed = event.name === "forge.pull-request.closed";
const head = HEAD_EVENTS.includes(event.name) || edited;
const merged = event.name === "forge.pull-request.merged";
if (!head && !merged && !closed) return;
if (closed && b.merged) return; // a merge is weighed with its own event (ADR 0382 §5)
const [repo, pull] = await Promise.all([d.getRepo(b.owner, b.repo), d.getPull(b.owner, b.repo, b.number)]);
if (closed) {
await d.announceClosed(repo, pull);
await d.siblingMoves(`${b.owner}/${b.repo}`, b.number);
return;
}
if (head && pull.state === "open") await d.announceHead(repo, pull);
if (merged) await d.announceMerge(repo, pull);
}
/**
* The one pass at a start, and its retry: run now; if it fails, say so and run again after `retryMs`, until one pass has
* ended. A retry of a failed pass, never a poll: after a pass has ended nothing is scheduled.
*/
export function startPass(run: () => Promise<void>, opts: { retryMs: number; log?: (l: string) => void }): Promise<void> {
const log = opts.log ?? ((l) => console.error(`[gitea] ${l}`));
return run().catch((err) => {
log(`the pass over every repository failed, asked again in ${opts.retryMs / 1000} s — ${err instanceof Error ? err.message : err}`);
return new Promise<void>((resolve) => setTimeout(() => resolve(startPass(run, opts)), opts.retryMs));
});
}
export interface HeadDeps {
/** Whether the forge shows a merge check of the head: only an announced head has one. */
alreadyChecked(pull: GiteaPull): Promise<boolean>;
/** Announce it; true when it was this call that announced it (not an event before it). */
announceHead(pull: GiteaPull): Promise<boolean>;
/** Told of a head this pass announced that no event had. */
missed(what: string): void;
}
/**
* The heads of one repository's open pull requests a pass announces. A head is announced when it is new and fresh (moved in
* the last day); at the first pass after a start (`primed` false) one the forge shows a merge check of was announced
* already, and is only remembered.
*/
export async function announceNewHeads(full: string, open: GiteaPull[], heads: Announced, primed: boolean, dayAgo: string, d: HeadDeps): Promise<void> {
for (const pull of headsToAnnounce(full, open, heads)) {
const key = headKey(full, pull.number, pull.base);
const fresh = primed || (!!pull.updated_at && pull.updated_at > dayAgo);
if (fresh && !primed && await d.alreadyChecked(pull)) {
heads[key] = String(pull.head_sha);
continue;
}
if (fresh) {
if (await d.announceHead(pull)) d.missed(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
}
}
export type { GiteaRepo };
+22 -19
View File
@@ -3,7 +3,7 @@
// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and // with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and
// publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …), // publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …),
// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships. // on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships.
// No poll is the safety net (issue 1422); the announced-heads and announced-merges records keep anything from // The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
// being announced twice, whichever path saw it first. // being announced twice, whichever path saw it first.
// //
// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is. // The secret is never logged: lines say it was minted or reused and where it is kept, never what it is.
@@ -144,7 +144,7 @@ export interface ReceiverOptions {
publish: (event: ForgeEvent) => Promise<void>; publish: (event: ForgeEvent) => Promise<void>;
/** Acts on it at once, beside publishing it (the announcer). */ /** Acts on it at once, beside publishing it (the announcer). */
act?: (event: ForgeEvent) => Promise<void>; act?: (event: ForgeEvent) => Promise<void>;
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the module knows events are /** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are
* heard, and the module can say when it last heard one. */ * heard, and the module can say when it last heard one. */
heard?: (kind: string, delivery: string) => void; heard?: (kind: string, delivery: string) => void;
/** Told of every refused delivery, with the reason and where it came from. */ /** Told of every refused delivery, with the reason and where it came from. */
@@ -242,7 +242,7 @@ export function receiver(opts: ReceiverOptions): Server & { idle(): Promise<void
await opts.publish(event); await opts.publish(event);
if (opts.act) await opts.act(event); if (opts.act) await opts.act(event);
} catch (err) { } catch (err) {
log(`a forge event ${event.name} could not be published or acted on; the pass over every repository is asked again shortly — ${err instanceof Error ? err.message : err}`); log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`);
} }
}); });
}); });
@@ -276,13 +276,14 @@ export interface HookState {
export const SILENT_AFTER = 10 * 60_000; export const SILENT_AFTER = 10 * 60_000;
/** /**
* **Whether the forge's events are heard** (issues 729, 1422). Nothing polls the forge, so there is no pace: a delivery * **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the
* heard within the last ten minutes, and none missed since, is `hearing`. * forge's events are heard — a verified delivery within the last ten minutes, and none missed since. Until the first
* delivery, after ten minutes without one, or once the poll found something no event said, every thirty seconds.
* *
* It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused * It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused
* deliveries, and the finds of the pass over every repository (at the start and when the receiver listens) that no event said. **A forge whose events stop is said** (issue 729): once the receiver * deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver
* listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery — * listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery —
* and a pass has found at least two heads or merges no event said in that quiet (one could be a pass beating an * and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an
* event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a * event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a
* delivery verifies. Deliveries refused since the last verified one are said unhealthy too. * delivery verifies. Deliveries refused since the last verified one are said unhealthy too.
*/ */
@@ -317,7 +318,7 @@ export class Pace {
this.notHeard = null; this.notHeard = null;
} }
/** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen /** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen
* or the hook could not be ensured (`fails` true, said unhealthy). Only the pass at the start watches. */ * or the hook could not be ensured (`fails` true, said unhealthy). The polls alone watch. */
unheard(why: string, fails = true): void { unheard(why: string, fails = true): void {
this.notHeard = why; this.notHeard = why;
this.notHeardFails = fails; this.notHeardFails = fails;
@@ -336,10 +337,10 @@ export class Pace {
this.refusedSpell++; this.refusedSpell++;
this.lastRefused = { at: this.now(), reason, from }; this.lastRefused = { at: this.now(), reason, from };
} }
/** The pass over every repository found a head or merge no forge event announced. */ /** The poll found a head or merge no forge event announced. */
miss(what: string): void { miss(what: string): void {
const t = this.now(); const t = this.now();
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the pass over every repository found it`); if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`);
this.missed = t; this.missed = t;
this.missCount++; this.missCount++;
this.lastMiss = { at: t, what }; this.lastMiss = { at: t, what };
@@ -354,8 +355,8 @@ export class Pace {
this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` + this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` +
`${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` + `${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` +
`system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` + `system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` +
`${this.lastRefused?.reason}` : ""}; the pass found ${what}; nothing else watches the forge ` + `${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` +
`(novox/hq issue 729, 1422)`); `(novox/hq issue 729)`);
} }
} }
/** Whether a delivery was refused after the last verified one (or with none verified). */ /** Whether a delivery was refused after the last verified one (or with none verified). */
@@ -366,6 +367,9 @@ export class Pace {
const t = this.now(); const t = this.now();
return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed; return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed;
} }
every(): number {
return this.hearing() ? 5 * 60_000 : 30_000;
}
private expecting(): boolean { private expecting(): boolean {
return this.where !== null && this.hook !== null; return this.where !== null && this.hook !== null;
} }
@@ -379,14 +383,14 @@ export class Pace {
if (this.silentSince) { if (this.silentSince) {
state = "unhealthy"; state = "unhealthy";
why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` + why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` +
`event since the start at ${iso(this.started)}`}, while the pass found ${this.missCount} head(s) or merge(s) no ` + `event since the start at ${iso(this.started)}`}, while the poll found ${this.missCount} head(s) or merge(s) no ` +
`event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`; `event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`;
} else if (refusedSince) { } else if (refusedSince) {
state = "unhealthy"; state = "unhealthy";
why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` : why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` :
"and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`; "and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`;
} else if (!this.expecting()) { } else if (!this.expecting()) {
why = this.notHeard ? `no events are listened for, so only the pass at the start watches the forge: ${this.notHeard}` : why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` :
"the receiver is not listening yet"; "the receiver is not listening yet";
if (this.notHeard && this.notHeardFails) state = "unhealthy"; if (this.notHeard && this.notHeardFails) state = "unhealthy";
} else if (this.hearing()) { } else if (this.hearing()) {
@@ -394,7 +398,7 @@ export class Pace {
} else if (this.last === 0) { } else if (this.last === 0) {
why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`; why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`;
} else { } else {
why = `the last delivery was at ${iso(this.last)}; nothing polls the forge, so what happens meanwhile is announced at the next start or when the receiver listens again`; why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`;
} }
return { return {
healthy: state !== "unhealthy", state, why, healthy: state !== "unhealthy", state, why,
@@ -403,8 +407,7 @@ export class Pace {
hook: this.hook, hook: this.hook,
verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } }, verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } },
refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } }, refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } },
polls: { every_seconds: null, note: "no timer asks the forge; one pass at the start and when the receiver listens (issue 1422)", polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount,
hearing: this.hearing(), found_unsaid: this.missCount,
last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } }, last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } },
silent: this.silentSince ? { since: iso(this.silentSince) } : null, silent: this.silentSince ? { since: iso(this.silentSince) } : null,
}; };
@@ -499,8 +502,8 @@ export function eventsTool(pace: Pace): { name: string; description: string; inp
name: "gitea_events", name: "gitea_events",
description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " + description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " +
"system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " + "system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " +
"verified and the last one's time and event, how many were refused and the last reason, whether " + "verified and the last one's time and event, how many were refused and the last reason, how often the polls look " +
"events are heard (nothing polls; one pass runs at the start), and how many heads or merges that pass found that no event said. Unhealthy when the " + "and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " +
"forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " + "forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " +
"poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " + "poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " +
"could not listen or the webhook could not be made.", "could not listen or the webhook could not be made.",
+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 | | 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 | | 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 ## Tools
| tool | | answers | | tool | | answers |
@@ -79,7 +63,7 @@ module says so in its health and takes no image build there meanwhile.
## Tests ## Tests
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the `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, 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 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 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 { for _, r := range m.Resources {
ids = append(ids, r["id"].(string)) 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) t.Fatalf("order: %v", ids)
} }
script := m.resource(t, "huge-pages") 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" { if h := apply["health"].(map[string]any); h["kind"] != "unit" {
t.Fatalf("health: %v", h) 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 // 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", "id": "linux-lts-headers",
"type": "package", "type": "package",
"package": "linux-lts-headers" "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": { "build": {
+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 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 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, without a range for the account, without buildah, or 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.
The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key. The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key.
@@ -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) } 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, 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) { Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil { if err := m.Check(ctx); err != nil {
return nil, err return nil, err
@@ -71,17 +71,4 @@ func TestAPulledImageAskCopiesThenBuilds(t *testing.T) {
t.Errorf("%s.%s is not served", Seat, verb) 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)
}
} }
@@ -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"), SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"),
SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"), SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"),
RegistryFile: write("registry", f.address()+"\n"), RegistryFile: write("registry", f.address()+"\n"),
Tun: write("tun", ""),
RegistryWait: 2 * time.Second, RegistryWait: 2 * time.Second,
RegistryPause: 5 * time.Millisecond, RegistryPause: 5 * time.Millisecond,
} }
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"sync" "sync"
@@ -39,8 +38,6 @@ type Module struct {
Work, Store string Work, Store string
// SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty. // SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty.
SubUIDFile, SubGIDFile string 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 holds the registry's address, host:port, which the manifest binds from the artifact-store provision.
RegistryFile string RegistryFile string
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of // 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 spacesOnce sync.Once
workspaces *buildstep.Workspaces 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. // 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 { if err := m.userNamespace(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah} 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 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 // Check is the guard: nil when the machine can build, else the refusal.
// 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.
func (m *Module) Check(ctx context.Context) error { func (m *Module) Check(ctx context.Context) error {
_, err := m.look(ctx) _, err := m.look(ctx)
m.keep(err)
return 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. // 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) { func (m *Module) Start(ctx context.Context) {
for { 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). // 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 { func (m *Module) Health(ctx context.Context) health.Answer {
l, err := m.look(ctx) l, err := m.look(ctx)
m.keep(err) if err != nil {
if err != nil && !isNotEligible(err) {
return health.Unhealthy(err.Error()) return health.Unhealthy(err.Error())
} }
if err := m.room(); err != nil { if err := m.room(); err != nil {
return health.Unhealthy(err.Error()) return health.Unhealthy(err.Error())
} }
if err != nil { 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))
// 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()))
} }
// Status is what the module reports of itself. // Status is what the module reports of itself.
func (m *Module) Status(ctx context.Context) map[string]any { func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx) 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, 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 { if err != nil {
out["why"] = err.Error() out["why"] = err.Error()
} }
@@ -2,9 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "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 // 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 // map, and the health says which file.
// gone, so tun could not be loaded) buildah's network for a RUN line cannot be made: the health says the node is not func TestHealthIsUnhealthyWithoutARangeInEitherFile(t *testing.T) {
// eligible, and the puller judges by 0 so its asks go to a node that can build them. for file, set := range map[string]func(*Module, string){
func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) { "subuid": func(m *Module, p string) { m.SubUIDFile = p },
for _, c := range []struct { "subgid": func(m *Module, p string) { m.SubGIDFile = p },
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"}},
} { } {
f := newFake(t) f := newFake(t)
m := moduleWith(t, f, newStates()) m := moduleWith(t, f, newStates())
p := t.TempDir() + "/" + c.name p := t.TempDir() + "/" + file
os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644) os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644)
c.set(m, p) set(m, p)
a := m.Health(context.Background()) a := m.Health(context.Background())
for _, want := range c.says { if a.Healthy || !strings.Contains(a.Why, p) || !strings.Contains(a.Why, "mesh-build-image") {
if a.Healthy || !strings.Contains(a.Why, want) { t.Errorf("%s: health = %+v, want unhealthy naming %s and the account", file, a, p)
t.Errorf("%s: health = %+v, want unhealthy saying %q", c.name, a, want)
}
} }
// 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, // 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` // 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 // account, so nothing declares them; this reads them), and a user namespace can be made.
// 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).
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -20,9 +18,6 @@ import (
// ProbeTimeout bounds each probe: they read the machine and fetch nothing. // ProbeTimeout bounds each probe: they read the machine and fetch nothing.
const ProbeTimeout = 20 * time.Second 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. // Default subordinate id files.
const ( const (
SubUIDFile = "/etc/subuid" SubUIDFile = "/etc/subuid"
@@ -138,23 +133,3 @@ func (m *Module) ensureRuntimeDir() error {
} }
return nil 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 }}, Run: func(map[string]any) (any, error) { return m.Status(m.Ctx), nil }},
stdio.Tool{Name: HealthTool, stdio.Tool{Name: HealthTool,
Description: "How the image build module is: unhealthy while buildah cannot be run, while its account holds no " + 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 }}, Run: func(map[string]any) (any, error) { return m.Health(m.Ctx), nil }},
) )
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028115016-1618837ea9fe 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-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028115016-1618837ea9fe h1:QDWi8k7zxaRPnJ+V/izoy6A19hZt1LTLl3uksZSlqOg= 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.20261028115016-1618837ea9fe/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= 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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+3 -3
View File
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS module_version (
PRIMARY KEY (module, commit_sha) PRIMARY KEY (module, commit_sha)
); );
-- What the catalog considers this module to be now. Separate from module_version because a -- What the catalogue considers this module to be now. Separate from module_version because a
-- module has many versions and exactly one current one, and "current" is a decision rather than -- module has many versions and exactly one current one, and "current" is a decision rather than
-- a consequence of being newest. -- a consequence of being newest.
CREATE TABLE IF NOT EXISTS module_current ( CREATE TABLE IF NOT EXISTS module_current (
@@ -144,7 +144,7 @@ export class Graph {
const url = env["DATABASE_URL"] ?? readMaybe(env["DATABASE_URL_FILE"]); const url = env["DATABASE_URL"] ?? readMaybe(env["DATABASE_URL_FILE"]);
if (!url) { if (!url) {
throw new Error( throw new Error(
"no DATABASE_URL: the catalog holds the module graph and cannot hold it in memory, " + "no DATABASE_URL: the catalogue holds the module graph and cannot hold it in memory, " +
"because a graph that disappears on restart is not a record of anything", "because a graph that disappears on restart is not a record of anything",
); );
} }
@@ -246,7 +246,7 @@ export class Graph {
} }
} }
/** Every module the catalog holds, with the commit it considers current. */ /** Every module the catalogue holds, with the commit it considers current. */
async modules(): Promise<{ module: string; commit: string; repository: string; path: string }[]> { async modules(): Promise<{ module: string; commit: string; repository: string; path: string }[]> {
const { rows } = await this.pool.query( const { rows } = await this.pool.query(
`SELECT c.module, c.commit_sha, v.repository, v.path `SELECT c.module, c.commit_sha, v.repository, v.path
+2 -2
View File
@@ -12,7 +12,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
{ {
name: "catalog_modules", name: "catalog_modules",
description: description:
"Every module this mesh holds, with the commit the catalog considers current and where it came from.", "Every module this mesh holds, with the commit the catalogue considers current and where it came from.",
input: {}, input: {},
run: async () => ({ modules: await graph.modules() }), run: async () => ({ modules: await graph.modules() }),
}, },
@@ -28,7 +28,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
const module = String(args.module ?? ""); const module = String(args.module ?? "");
if (!module) return { error: "name a module" }; if (!module) return { error: "name a module" };
const found = await graph.show(module, args.commit ? String(args.commit) : undefined); const found = await graph.show(module, args.commit ? String(args.commit) : undefined);
return found ? { module: found } : { error: `the catalog holds no ${module}` }; return found ? { module: found } : { error: `the catalogue holds no ${module}` };
}, },
}, },
{ {
@@ -184,7 +184,7 @@ func (call Call) level() (Level, error) {
case call.Module != "": case call.Module != "":
return Level{}, fmt.Errorf("a module's own value is set for one node, and no node is named: there is no "+ return Level{}, fmt.Errorf("a module's own value is set for one node, and no node is named: there is no "+
"module value for the whole mesh (novox/hq ADR 0348 §3); name the node, or set mesh config for the "+ "module value for the whole mesh (novox/hq ADR 0348 §3); name the node, or set mesh config for the "+
"predefined key %s's value falls back to, or change its default in the catalog", call.Module) "predefined key %s's value falls back to, or change its default in the catalogue", call.Module)
case call.Node != "": case call.Node != "":
return Level{Kind: LevelNode, Node: call.Node}, nil return Level{Kind: LevelNode, Node: call.Node}, nil
} }
@@ -27,7 +27,7 @@ func (c *Config) sources() (Sources, error) {
}); err != nil { }); err != nil {
return s, fmt.Errorf("the assignments cannot be read: %w", err) return s, fmt.Errorf("the assignments cannot be read: %w", err)
} }
// And every module the catalog's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both // And every module the catalogue'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. // 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 indexer'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{} standing := map[string]bool{}
@@ -57,7 +57,7 @@ func (c *Config) sources() (Sources, error) {
} }
return nil return nil
}); err != nil { }); err != nil {
problems = append(problems, fmt.Sprintf("the catalog's placement cannot be read, so the assignments alone are resolved: %v", err)) problems = append(problems, fmt.Sprintf("the catalogue's placement cannot be read, so the assignments alone are resolved: %v", err))
} }
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error { if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
m, err := ParseModuleDeclarations(raw) m, err := ParseModuleDeclarations(raw)
@@ -411,9 +411,6 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
} }
} }
check := h.checkAt(members) check := h.checkAt(members)
if !h.checkGates() {
check = unansweredWords(check) // asked with no verdict only past its bound (verdictWaits)
}
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now) ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now)
taken := []*Delivery{} taken := []*Delivery{}
var reads []memberRead var reads []memberRead
@@ -1,11 +1,6 @@
package main package main
import ( import "time"
"encoding/json"
"time"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
@@ -32,24 +27,3 @@ func statusOn(w *world, repo, commit string) string {
defer w.forge.mu.Unlock() defer w.forge.mu.Unlock()
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"] return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
} }
// checkerSays puts the checker's verdict at these heads together into its verdicts bucket, as captured on the bus
// (mesh-delivery-checker, key `<group>.<heads hash>`).
func checkerSays(w *world, group, state, failed string, heads ...sdk.Head) {
checkerSaysWith(w, group, state, failed, nil, heads...)
}
// checkerSaysWith is checkerSays with more of the verdict's fields (its composition); the state "gone" deletes the key.
func checkerSaysWith(w *world, group, state, failed string, more map[string]any, heads ...sdk.Head) {
hash, _ := sdk.HeadsHash(heads)
if state == "gone" {
w.h.CheckerVerdictChanged(group+"."+hash, nil)
return
}
v := map[string]any{"v": 1, "group": group, "heads": hash, "state": state, "failed": map[string]string{"words": failed}}
for k, x := range more {
v[k] = x
}
value, _ := json.Marshal(v)
w.h.CheckerVerdictChanged(group+"."+hash, value)
}
@@ -57,8 +57,6 @@ type Holder struct {
// catchUp asks the next look to weigh again the newest merge of each repository against source.main (SourceCatchUp). // catchUp asks the next look to weigh again the newest merge of each repository against source.main (SourceCatchUp).
catchUp bool catchUp bool
// seeded are the repositories source.main was found to name, or was seeded with, since this owner started (seedTrunks).
seeded map[string]bool
mu sync.Mutex mu sync.Mutex
deliveries map[string]*Delivery deliveries map[string]*Delivery
@@ -79,10 +77,7 @@ type Holder struct {
unchecked map[string]unchecked unchecked map[string]unchecked
// checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them // checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them
// (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go). // (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go).
checker map[string]checkerVerdict checker map[string]checkerVerdict
// verdictGone is when the checker's verdict at a heads hash was taken away (its key deleted): the Approve's wait for a
// verdict counts its bound from then, as from a check that starts again (verdictWaits).
verdictGone map[string]time.Time
runningSince map[string]time.Time runningSince map[string]time.Time
// unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275, // unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275,
// group_merge.go): within forgeMergeGrace that is the forge still working out mergeability, asked again shortly. // group_merge.go): within forgeMergeGrace that is the forge still working out mergeability, asked again shortly.
@@ -146,7 +141,7 @@ func (h *Holder) init() {
h.lostRead = map[string]time.Time{} h.lostRead = map[string]time.Time{}
h.unchecked = map[string]unchecked{} h.unchecked = map[string]unchecked{}
h.checker = map[string]checkerVerdict{} h.checker = map[string]checkerVerdict{}
h.runningSince, h.turnsHeld, h.verdictGone = map[string]time.Time{}, map[string]sdk.Turn{}, map[string]time.Time{} h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{}
h.reach = map[string]sdk.Reach{} h.reach = map[string]sdk.Reach{}
h.started = h.Now() h.started = h.Now()
} }
@@ -345,15 +340,13 @@ func (h *Holder) pullUpdated(p PullEvent) (isNew bool) {
// Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its // Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its
// check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound // check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound
// runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it. // runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it.
if p.BaseSHA != "" { if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA {
d.BaseSHA, d.BaseSHAAt = p.BaseSHA, now d.BaseSHA = p.BaseSHA
h.keep(d) h.keep(d)
} }
// A delivery made from its verdict before its head was heard (issue 1726) holds none of what an announcement // A delivery made from its verdict before its head was heard (issue 1726) holds none of what an announcement
// carries: filled from this one, so its base is known and the operator is asked. // carries: filled from this one, so its base is known and the operator is asked.
h.fillFromAnnouncement(d, p) h.fillFromAnnouncement(d, p)
// A pull request retargeted to another base (review of issue 1422): what it merges into is the forge's word now.
h.retarget(d, p)
// The review record, corrected in the description without a push, is read again (ADR 0421 §5). // The review record, corrected in the description without a push, is read again (ADR 0421 §5).
if by, at := ReviewIn(p.Body); p.Body != "" && (by != d.ReviewedBy || at != d.ReviewedAt) { if by, at := ReviewIn(p.Body); p.Body != "" && (by != d.ReviewedBy || at != d.ReviewedAt) {
d.ReviewedBy, d.ReviewedAt = by, at d.ReviewedBy, d.ReviewedAt = by, at
@@ -443,7 +436,7 @@ func (h *Holder) fillFromAnnouncement(d *Delivery, p PullEvent) {
d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body) d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body)
d.PushedBy = PushedIn(p.Body) d.PushedBy = PushedIn(p.Body)
if p.BaseSHA != "" { if p.BaseSHA != "" {
d.BaseSHA, d.BaseSHAAt = p.BaseSHA, h.Now() d.BaseSHA = p.BaseSHA
} }
d.Number = p.Number d.Number = p.Number
h.Logf("[mesh-delivery] %s: its pull request's announcement filled its base (%s), branch and title", d.ID, p.Base) h.Logf("[mesh-delivery] %s: its pull request's announcement filled its base (%s), branch and title", d.ID, p.Base)
@@ -455,27 +448,6 @@ func (h *Holder) fillFromAnnouncement(d *Delivery, p PullEvent) {
h.Poke() h.Poke()
} }
// retarget takes the base a live, unmerged delivery's pull request was announced again with when it differs from the one
// kept: into main it is asked and merged as any other, and source.main may be seeded from it (seedTrunks); out of main an
// ask nobody answered yet is taken back, and the next look says it is not asked (ready.go). A delivery whose base is not yet
// known is fillFromAnnouncement's. Said in the log. Under the lock.
func (h *Holder) retarget(d *Delivery, p PullEvent) {
if p.Base == "" || d.Base == "" || p.Base == d.Base || d.State.Final() || d.MergedAs != "" ||
(d.Ready != nil && d.Ready.Merged != nil) {
return
}
from := d.Base
d.Base = p.Base
if r := d.Ready; r != nil {
r.OtherBase = ""
if r.AskID != "" && r.Approved == nil {
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, fmt.Sprintf("%s was retargeted from %s to %s", d.ID, from, p.Base)))
}
}
h.Logf("[mesh-delivery] %s: its pull request was retargeted from %s to %s", d.ID, from, p.Base)
h.keep(d)
}
// PullEdited takes a pull request's edit (novox/hq issue 1079): its description is read again for the review record, the // PullEdited takes a pull request's edit (novox/hq issue 1079): its description is read again for the review record, the
// `after:` lines and the issues it names, exactly as an announcement reads them. An Approve ask still unanswered whose words // `after:` lines and the issues it names, exactly as an announcement reads them. An Approve ask still unanswered whose words
// the new record changes is withdrawn and asked again with it read, without a word on the pull request: nothing was // the new record changes is withdrawn and asked again with it read, without a word on the pull request: nothing was
@@ -583,9 +555,6 @@ func (h *Holder) freshAnnouncement(p PullEvent, now time.Time, startedOver strin
Title: p.Title, Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths, Title: p.Title, Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths,
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid, PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, BaseSHA: p.BaseSHA} After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, BaseSHA: p.BaseSHA}
if p.BaseSHA != "" {
d.BaseSHAAt = now
}
d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body) d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body)
d.PushedBy = PushedIn(p.Body) d.PushedBy = PushedIn(p.Body)
why := fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number) why := fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)
@@ -1,7 +1,6 @@
package main package main
import ( import (
"strings"
"testing" "testing"
"time" "time"
@@ -49,86 +48,6 @@ func TestStartingWritesTheNewestMergeOfEachRepositoryNotYetInSourceMain(t *testi
t.Fatalf("a second catch-up moved source.main again: %d moves, then %d", n, len(w.source.moves)) t.Fatalf("a second catch-up moved source.main again: %d moves, then %d", n, len(w.source.moves))
} }
// Issue 1422: a repository never merged through the gate gets main's head as the forge announced its open pull request
// with, once, beside the others; a pull request into another base, or one announced with no base head (the forge could
// not read it), seeds nothing; a head source.main holds is never replaced.
const never, trunkN, trunkM = "novox/never-merged", "eeee000000000000000000000000000000000001", "eeee000000000000000000000000000000000002"
s := newWorld(t)
s.source.main = &sdk.Source{V: sdk.Version, Group: "before", Heads: map[string]string{i1726Repo: i1726Merge1}}
other := pr("novox/other-base", 6, "f1f1000000000000000000000000000000000001", "fix/c", "a.go")
other.Base, other.BaseSHA = "nox-mesh", trunkM
s.h.PullUpdated(other)
unread := pr("novox/unread", 2, "f2f2000000000000000000000000000000000002", "fix/d", "a.go")
s.h.PullUpdated(unread)
seed := pr(never, 4, "f3f3000000000000000000000000000000000003", "fix/e", "a.go")
seed.BaseSHA = trunkN
s.h.PullUpdated(seed)
s.settleAll()
if h := s.source.main.Heads; h[never] != trunkN || h[i1726Repo] != i1726Merge1 || h["novox/other-base"] != "" || h["novox/unread"] != "" || len(s.source.moves) != 1 {
t.Fatalf("source.main is %v after %d moves; wanted %s seeded at %s, the catalogue kept and nothing else, in one move", h, len(s.source.moves), never, trunkN)
}
if !strings.Contains(s.logs(), "seeded with "+trunkN[:12]) {
t.Errorf("the seed was not said: %s", s.logs())
}
again := pr(never, 5, "f4f4000000000000000000000000000000000004", "fix/f", "a.go")
again.BaseSHA = trunkM
s.h.PullUpdated(again)
s.h.SourceCatchUp()
s.settleAll()
if got := s.source.main.Heads[never]; got != trunkN || len(s.source.moves) != 1 {
t.Fatalf("a seeded head was replaced by %s (%d moves)", got, len(s.source.moves))
}
// The freshest announcement's base head is the seed, not the newest delivery's: #4, made first, is announced again after #5
// with main's head moved on.
const trunkF = "eeee000000000000000000000000000000000003"
f := newWorld(t)
first := pr(never, 4, "f5f5000000000000000000000000000000000005", "fix/g", "a.go")
first.BaseSHA = trunkN
f.h.PullUpdated(first)
f.later(time.Minute)
second := pr(never, 5, "f6f6000000000000000000000000000000000006", "fix/h", "a.go")
second.BaseSHA = trunkM
f.h.PullUpdated(second)
f.later(time.Minute)
first.BaseSHA = trunkF
f.h.PullUpdated(first)
f.settleAll()
if got := f.source.main.Heads[never]; got != trunkF {
t.Fatalf("seeded with %s; wanted %s, the base head of the latest announcement", got, trunkF)
}
// A green pull request retargeted from nox-mesh to main is seeded and asked; one retargeted from main to nox-mesh has
// its ask taken back and is not merged (review round 2).
retarget := func(from, to string) (*world, int) {
w := newWorld(t)
w.op.auto = false
p := pr(never, 7, readyHead, "feat/x", "src/a.go")
p.Base, p.BaseSHA = from, trunkN
w.h.PullUpdated(p)
w.h.Checked(judgedAt(never, 7, readyHead, "pass", "t1t1t1t1t1t1"))
open := openPull(never, 7, readyHead)
open.Base = from
w.forge.open = []OpenPull{open}
w.h.SeeOpenPulls()
w.settleAll()
before := len(w.op.open())
p.Base, open.Base = to, to
w.forge.open = []OpenPull{open}
w.h.PullUpdated(p)
w.h.SeeOpenPulls()
w.settleAll()
w.later(2 * time.Minute)
w.settleAll()
return w, before
}
if r, before := retarget("nox-mesh", "main"); before != 0 || r.source.main == nil || r.source.main.Heads[never] != trunkN || len(r.op.open()) != 1 {
t.Fatalf("retargeted to main: %d asks before, source.main %+v, open asks %v; wanted none before, then seeded at %s and asked", before, r.source.main, r.op.open(), trunkN)
}
if r, before := retarget("main", "nox-mesh"); before != 1 || len(r.op.open()) != 0 || len(r.forge.merges) != 0 || !strings.Contains(r.logs(), "retargeted") {
t.Fatalf("retargeted to nox-mesh: %d asks before, open asks %v, merges %v; wanted one before, then none and the retarget said", before, r.op.open(), r.forge.merges)
}
o := newWorld(t) o := newWorld(t)
o.source.main = &sdk.Source{V: sdk.Version, Group: "newer", Heads: map[string]string{i1726Repo: i1726Merge2}} o.source.main = &sdk.Source{V: sdk.Version, Group: "newer", Heads: map[string]string{i1726Repo: i1726Merge2}}
o.h.PullUpdated(pr(i1726Repo, 471, head2, "fix/b", "modules/app/b.go")) o.h.PullUpdated(pr(i1726Repo, 471, head2, "fix/b", "modules/app/b.go"))
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"reflect" "reflect"
"regexp"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -525,75 +524,6 @@ func (h *Holder) settleForgeMerges() []func() {
return out return out
} }
// trunkHead is a full commit id as the forge names a branch's head.
var trunkHead = regexp.MustCompile(`^[0-9a-f]{40}$`)
// seedTrunks gives source.main the trunk head of a repository it names none of (issue 1422): one never merged through the
// gate, whose open pull requests the checker could otherwise never judge. The head is the base_sha of the latest announcement of
// any open pull request into main (gitea.pull.updated; ADR 0323; lastAnnounced), the trunk's freshest head the gate heard. It is
// added in one compare-and-set write that never replaces a head source.main holds, so a merge always wins; the first merge
// through the gate moves it as any other. source.main is per repository and names main's head only (ADR 0484 §11, MergeBranch):
// a pull request into another base seeds nothing. A repository is weighed once per start after it was found or seeded.
// Under the lock; the writing happens outside it.
func (h *Holder) seedTrunks() []func() {
newest := map[string]*Delivery{}
for _, d := range h.deliveries {
if h.seeded[d.Repository] || d.Base != MergeBranch || !trunkHead.MatchString(d.BaseSHA) || d.State.Final() || d.MergedAs != "" {
continue
}
if o := newest[d.Repository]; o == nil || lastAnnounced(d).After(lastAnnounced(o)) {
newest[d.Repository] = d
}
}
var out []func()
for repository, d := range newest {
id, head, group := d.ID, d.BaseSHA, firstOf(d.Branch, d.Repository)
out = append(out, func() {
seeded := false
err := h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
seeded = false
next := sdk.Source{Group: group, Heads: map[string]string{}}
if prev != nil {
if prev.Heads[repository] != "" {
return *prev, false // named already: never replaced
}
next.Group = prev.Group
for r, held := range prev.Heads {
next.Heads[r] = held
}
}
next.Heads[repository] = head
seeded = true
return next, true
})
h.mu.Lock()
defer h.mu.Unlock()
if err != nil {
h.Logf("[mesh-delivery] %s: source.main names no trunk head of %s, and it cannot be seeded yet: %v; asked again in %s", id, repository, err, askAgainAfter)
h.again("source.main, seeded with the trunk head of a repository it names none of")
return
}
if h.seeded == nil {
h.seeded = map[string]bool{}
}
h.seeded[repository] = true
if seeded {
h.Logf("[mesh-delivery] source.main named no trunk head of %s: seeded with %s, main's head as the forge announced %s with, so its pull requests are judged (issue 1422)", repository, shortOf(head, 12), id)
}
})
}
return out
}
// lastAnnounced is when the forge last announced a delivery's head with its base head: BaseSHAAt, or when the gate made it
// for one kept before that was written.
func lastAnnounced(d *Delivery) time.Time {
if !d.BaseSHAAt.IsZero() {
return d.BaseSHAAt
}
return d.Created
}
// replayLate is how long after its merge a merge heard only from the forge's word is a replay: the forge announces a merge // replayLate is how long after its merge a merge heard only from the forge's word is a replay: the forge announces a merge
// within seconds, so one heard this late was replayed from the bus's history, and the source already holds what came after. // within seconds, so one heard this late was replayed from the bus's history, and the source already holds what came after.
const replayLate = 15 * time.Minute const replayLate = 15 * time.Minute
@@ -66,9 +66,6 @@ type Delivery struct {
// BaseSHA is the base branch's head the forge last announced its head with (ADR 0323): sent with a check this // BaseSHA is the base branch's head the forge last announced its head with (ADR 0323): sent with a check this
// owner asks, for a verdict no build judges to name its trunk commit. // owner asks, for a verdict no build judges to name its trunk commit.
BaseSHA string `json:"base_sha,omitempty"` BaseSHA string `json:"base_sha,omitempty"`
// BaseSHAAt is when the forge last announced its head with BaseSHA: the freshest of a repository's announcements gives
// source.main its trunk head when it names none (seedTrunks, issue 1422). Zero for one kept before it was written.
BaseSHAAt time.Time `json:"base_sha_at,omitzero"`
// AskedAgain is when its head was announced again while its verdict was out of date — the merge tool's ask of // AskedAgain is when its head was announced again while its verdict was out of date — the merge tool's ask of
// its check against the trunk's head (ADR 0323 §5) — and no verdict has come since: its one-hour bound runs from // its check against the trunk's head (ADR 0323 §5) — and no verdict has come since: its one-hour bound runs from
// then. Nil while nothing is asked, and cleared by a verdict or another move of its trunk. // then. Nil while nothing is asked, and cleared by a verdict or another move of its trunk.
@@ -8,7 +8,6 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
sdk "git.novox.be/novox/mesh-sdk/go/delivery" sdk "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -58,12 +57,6 @@ type checkerVerdict struct {
Machine string `json:"machine,omitempty"` Machine string `json:"machine,omitempty"`
Words string `json:"words,omitempty"` Words string `json:"words,omitempty"`
} `json:"failed,omitzero"` } `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, 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"`
} `json:"composition,omitempty"`
} }
// CheckerVerdictChanged takes a key of the checker's verdicts bucket (`<group>.<heads hash>`) put or deleted (an empty // CheckerVerdictChanged takes a key of the checker's verdicts bucket (`<group>.<heads hash>`) put or deleted (an empty
@@ -82,13 +75,9 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
if h.runningSince == nil { if h.runningSince == nil {
h.runningSince = map[string]time.Time{} h.runningSince = map[string]time.Time{}
} }
if h.verdictGone == nil {
h.verdictGone = map[string]time.Time{}
}
if len(value) == 0 || string(value) == "null" { if len(value) == 0 || string(value) == "null" {
delete(h.checker, hash) delete(h.checker, hash)
delete(h.runningSince, hash) delete(h.runningSince, hash)
h.verdictGone[hash] = h.Now()
h.Poke() h.Poke()
return return
} }
@@ -104,7 +93,6 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
h.runningSince[v.Heads] = h.Now() // a check starts: a merge it holds says since when (ADR 0548 §2) h.runningSince[v.Heads] = h.Now() // a check starts: a merge it holds says since when (ADR 0548 §2)
} else if v.State != checkerRunning { } else if v.State != checkerRunning {
delete(h.runningSince, v.Heads) delete(h.runningSince, v.Heads)
delete(h.verdictGone, v.Heads)
} }
h.checker[v.Heads] = v h.checker[v.Heads] = v
h.Poke() h.Poke()
@@ -341,11 +329,11 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
if ok { if ok {
switch v.State { switch v.State {
case checkerPass: case checkerPass:
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v, members)} return checkSaid{Key: checkSaidPass, Words: "passed its check"}
case checkerRunning: case checkerRunning:
words := "its check has no verdict yet: it is running" words := "its check has no verdict yet: it is running"
if since, held := h.runningSince[hash]; held && !since.IsZero() { if since, held := h.runningSince[hash]; held && !since.IsZero() {
words += " (since " + clockSaid(since) + ")" words += " (since " + since.UTC().Format("15:04") + " UTC)"
} }
return checkSaid{Key: checkSaidRunning, Words: words} return checkSaid{Key: checkSaidRunning, Words: words}
default: default:
@@ -383,31 +371,3 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
} }
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"} 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): 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 + ")"
}
// operatorZone is the zone the operator reads times in, as the messenger says them (its time-zone setting).
var operatorZone = func() *time.Location {
if loc, err := time.LoadLocation("Europe/Brussels"); err == nil {
return loc
}
return time.UTC
}()
// clockSaid is a time of day as the operator reads it: in their zone, named ("00:35 CET").
func clockSaid(t time.Time) string { return t.In(operatorZone).Format("15:04 MST") }
@@ -294,7 +294,6 @@ func (h *Holder) readyAsks(now time.Time) []func() {
asks = append(asks, h.turnWrites()...) // the merge turns, after the holds of this look (ADR 0548 §3) asks = append(asks, h.turnWrites()...) // the merge turns, after the holds of this look (ADR 0548 §3)
asks = append(asks, h.settleMerges()...) asks = append(asks, h.settleMerges()...)
asks = append(asks, h.settleForgeMerges()...) asks = append(asks, h.settleForgeMerges()...)
asks = append(asks, h.seedTrunks()...)
asks = append(asks, h.withdrawnKeys()...) asks = append(asks, h.withdrawnKeys()...)
asks = append(h.cancels, asks...) asks = append(h.cancels, asks...)
h.cancels = nil h.cancels = nil
@@ -447,75 +446,9 @@ func (h *Holder) ungatedWaits(d *Delivery) string {
} }
} }
} }
if h.covering(d) == nil {
return h.verdictWaits(d)
}
return "" return ""
} }
// askCheckBound is how long an Approve waits for the check's verdict at its heads while the check gates no merge: past it,
// the operator is asked anyway, and the ask says the check has not answered. The checker's own step bound is 30 minutes
// and a check runs several steps; an hour covers a whole check.
const askCheckBound = time.Hour
// waitsCheckBeforeAsking is how the wait for the check's verdict begins, as `show` and the delivery's wait say it.
const waitsCheckBeforeAsking = "waits its check before asking the operator's Approve: "
// verdictWaits is why an Approve is not asked yet while the check gates no merge: the check at the heads the ask would cover
// 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; 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)
if c.Key == checkSaidPass || c.Key == checkSaidFail {
return ""
}
until := h.waitSince(d, members).Add(askCheckBound)
if !h.Now().Before(until) {
return ""
}
h.lookAt(until, "an Approve that waits its check, asked anyway at its bound")
return waitsCheckBeforeAsking + c.Words + "; asked anyway at " + clockSaid(until) + " if it has not answered"
}
// waitSince is when the wait for a verdict began, the moment the bound of verdictWaits counts from: the latest of the
// heads becoming ready (a member with no ready run yet, such as one re-pushed or settled ready by an event this look has
// not reached, became ready now), the check at those heads starting again, and its verdict being taken away. Under the lock.
func (h *Holder) waitSince(d *Delivery, members []*Delivery) time.Time {
now := h.Now()
since := time.Time{}
later := func(t time.Time) {
if t.After(since) {
since = t
}
}
for _, m := range append([]*Delivery{d}, members...) {
if m.Ready == nil {
return now
}
later(m.Ready.Seen)
}
if hash, err := sdk.HeadsHash(headsOf(members)); err == nil {
later(h.runningSince[hash])
later(h.verdictGone[hash])
}
if since.IsZero() || since.After(now) {
return now
}
return since
}
// unansweredWords is the check's words in an ask made past askCheckBound with no verdict: it says the check has not answered.
func unansweredWords(c checkSaid) checkSaid {
if c.Key == checkSaidPass || c.Key == checkSaidFail {
return c
}
c.Words += ", and has given none in " + strconv.Itoa(int(askCheckBound.Minutes())) + " minutes, so the operator is asked anyway"
return c
}
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it. // waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
const waitsForReview = "waits for a review at " const waitsForReview = "waits for a review at "
@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"errors" "errors"
"sort" "sort"
"strings" "strings"
@@ -69,42 +70,27 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
} }
// 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed // 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed
// its check" only when it passed. While the check gates no merge, the ask waits for the check's verdict (the operator, // its check" only when it passed. While the check gates no merge, the ask is made before any verdict, says that Approve
// 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, // merges before the check ends, and is asked again in new words once the check passes or fails; a running check is no
// the failure in its words; with no verdict nothing is asked and the delivery says it waits its check, until the bound, // new word.
// 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 { for _, row := range []struct {
name, state, failed, then, path string name, state, failed string
gated bool gated bool
notComposed bool asks int
after time.Duration want, not []string
asks, open int
want, not []string
}{ }{
{name: "gated, passed", gated: true, state: "pass", asks: 1, {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] + ";"}, want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
not: []string{"before its check ends", "no verdict yet"}}, not: []string{"before its check ends", "no verdict yet"}},
{name: "ungated, no verdict", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 15:00 CEST if"}}, {name: "ungated, no verdict", asks: 1,
{name: "ungated, running", state: "running", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running"}}, want: []string{"its check has no verdict yet", "Approve merges it into main at " + readyHead[:12] + " now, before its check ends"},
{name: "ungated, running past its bound", state: "running", after: askCheckBound, asks: 1, not: []string{"passed"}},
want: []string{"its check has no verdict yet: it is running", "has given none in 60 minutes, so the operator is asked anyway", {name: "ungated, running", state: "running", asks: 1,
"Approve merges it into main at " + readyHead[:12] + " now, before its check ends"}, not: []string{"passed"}}, want: []string{"its check has no verdict yet", "before its check ends"}, not: []string{"passed"}},
{name: "ungated, past its bound, then passed", state: "running", after: askCheckBound, then: "pass", asks: 2, {name: "ungated, then passed", state: "pass", asks: 2,
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends", "anyway"}},
{name: "ungated, passed", state: "pass", asks: 1,
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}}, want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}},
// A pass that turns back into a check with no verdict (a check started again, its verdict taken away) past the bound {name: "ungated, then failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 2,
// withdraws the ask, and the bound counts again from that moment: nothing is asked at once as unanswered. want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed"}},
{name: "ungated, passed, running again past its bound", state: "pass", after: askCheckBound, then: "running", asks: 1, open: -1,
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"}},
} { } {
t.Run(row.name, func(t *testing.T) { t.Run(row.name, func(t *testing.T) {
v := newWorld(t) v := newWorld(t)
@@ -112,46 +98,26 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
ungated(v) ungated(v)
} }
v.op.auto = false v.op.auto = false
p := pr("novox/app", 7, readyHead, "feat/x", firstOf(row.path, "src/a.go")) p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Title = "Fix it" p.Title = "Fix it"
v.h.PullUpdated(p) v.h.PullUpdated(p)
head := headOf("novox/app", 7, readyHead) put := func() {
hash, _ := sdk.HeadsHash([]sdk.Head{headOf("novox/app", 7, readyHead)})
value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "state": row.state,
"failed": map[string]string{"words": row.failed}})
v.h.CheckerVerdictChanged("g."+hash, value)
}
if row.gated { if row.gated {
v.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) v.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
} }
v.settleAll() v.settleAll()
if !row.gated && row.state != "" { if !row.gated && row.state != "" {
var more map[string]any put()
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 {
v.later(row.after)
v.settleAll()
}
if row.then != "" {
checkerSays(v, "g", row.then, row.failed, head)
v.settleAll() v.settleAll()
} }
open := v.op.open() open := v.op.open()
wantOpen := min(row.asks, 1) if len(open) != 1 || v.op.count() != row.asks {
if row.open < 0 { t.Fatalf("open asks %v, asked %d times; wanted one open, asked %d times", open, v.op.count(), row.asks)
wantOpen = 0
}
if v.op.count() != row.asks || len(open) != wantOpen {
t.Fatalf("open asks %v, asked %d times; wanted asked %d times, %d open", open, v.op.count(), row.asks, wantOpen)
}
if wantOpen == 0 {
d := v.delivery(IDOf("novox/app", readyHead))
for _, w := range row.want {
if d.Ready == nil || !strings.Contains(d.Ready.Waits, w) {
t.Fatalf("the delivery's wait does not say %q: %+v", w, d.Ready)
}
}
return
} }
e := v.op.ask(open[0]).Explanation e := v.op.ask(open[0]).Explanation
for _, w := range row.want { for _, w := range row.want {
@@ -180,8 +146,8 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go")) u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go"))
u.settleAll() u.settleAll()
if u.op.count() != 0 || u.state(IDOf("novox/app", readyHead)) != Ready { if u.op.count() != 2 || u.state(IDOf("novox/app", readyHead)) != Ready {
t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s; wanted ready and not asked", u.op.count(), u.state(IDOf("novox/app", readyHead))) t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s", u.op.count(), u.state(IDOf("novox/app", readyHead)))
} }
if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates { if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates {
t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening")) t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening"))
@@ -191,12 +157,8 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
t.Fatalf("show does not say the check gates no merge: %v", shown) t.Fatalf("show does not say the check gates no merge: %v", shown)
} }
u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1")) u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1"))
checkerSays(u, "feat/y", "fail", "TestB", headOf("novox/app", 8, other))
u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7")) u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
u.settleAll() u.settleAll()
if u.op.count() != 1 {
t.Fatalf("ungated, a failed verdict is asked once (the check gates no merge): %d asks", u.op.count())
}
d = u.delivery(IDOf("novox/app", other)) d = u.delivery(IDOf("novox/app", other))
if d.State != Ready || d.Check == nil || d.Check.Passes() { if d.State != Ready || d.Check == nil || d.Check.Passes() {
t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check) t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check)
@@ -361,44 +323,14 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go")) u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")) u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
u.settleAll() u.settleAll()
group := []sdk.Head{headOf("novox/mesh-controller", 1, ctlHead), headOf("novox/mesh-catalog", 2, catHead)}
checkerSays(u, "feat/x", "running", "", group...)
u.settleAll()
// The group's check is the one of its heads together: while it runs, nothing is asked and each member says it waits.
if d := u.delivery(IDOf("novox/mesh-catalog", catHead)); u.op.count() != 0 || d.Ready == nil ||
!strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking+"its check has no verdict yet: it is running") {
t.Fatalf("ungated, the group is asked %d times while its check runs; wanted none, and the wait said: %+v", u.op.count(), d.Ready)
}
checkerSays(u, "feat/x", "pass", "", group...)
u.settleAll()
if u.op.count() != 1 { if u.op.count() != 1 {
t.Fatalf("ungated, the group is asked %d times once its check passed; wanted once", u.op.count()) t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count())
} }
if e := u.op.ask(u.op.open()[0]).Explanation; !strings.Contains(e, "2 pull requests, passed its check. Approve merges all of them into main in one pass, at the heads named;") { // 1733: the group's ask claims no pass it has not had.
t.Fatalf("ungated, the group's ask does not say its check passed:\n%s", e) if e := u.op.ask(u.op.open()[0]).Explanation; strings.Contains(e, "passed") ||
!strings.Contains(e, "2 pull requests: its check has no verdict yet. Approve merges all of them into main in one pass, at the heads named, now, before its check ends;") {
t.Fatalf("ungated, the group's ask does not say its check has no verdict yet:\n%s", e)
} }
// A member re-pushed past the bound after its sibling became ready: the bound counts from the new head, so nothing is
// asked as unanswered at once (review round 1).
r := newWorld(t)
ungated(r)
r.op.auto = false
const ctlHead2 = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
r.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
r.settleAll()
r.later(askCheckBound + time.Minute)
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead2, "feat/x", "internal/a.go"))
// Made ready by a handler, before the look gives it a run: no verdict and no status at the new heads, so only the rule
// that a member with no run counts as ready now keeps the older sibling's time from asking it as unanswered.
r.h.mu.Lock()
r.h.ungate(r.h.deliveries[IDOf("novox/mesh-controller", ctlHead2)], r.h.Now())
r.h.mu.Unlock()
r.settleAll()
if d := r.delivery(IDOf("novox/mesh-catalog", catHead)); r.op.count() != 0 || d.Ready == nil ||
!strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking) {
t.Fatalf("a member re-pushed past the bound: asked %d times, wait %+v; wanted none asked and the wait said", r.op.count(), d.Ready)
}
u.op.answer(u.op.open()[0], OptionApprove) u.op.answer(u.op.open()[0], OptionApprove)
u.settleAll() u.settleAll()
if len(u.forge.merges) != 2 { if len(u.forge.merges) != 2 {
+1 -1
View File
@@ -99,7 +99,7 @@
}, },
"check-gates-merge": { "check-gates-merge": {
"type": "boolean", "type": "boolean",
"description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready, and its Approve is asked once the check at its heads (a group: of all its heads together) has a verdict, a pass or a failure said in the ask, or after 60 minutes with none, said so (the operator, 2026-10-27 23:54: \"if you don't want me to approve it, don't send the approval request\"); an approved merge waits for no verdict; no merge is refused for a check judged on an older trunk; a later verdict is shown on the pull request and the delivery, and asks a pending Approve again in its words; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.", "description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready and its Approve asked at once, with no verdict waited on; no merge is refused for a check judged on an older trunk; a verdict that comes is shown on the pull request and the delivery, never waited on; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.",
"trusted": false, "trusted": false,
"default": true "default": true
} }
-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 | | `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* | | `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 | | `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 **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`, 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. // 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", 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...) } 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) return t.LearnFixes(n)
}}, }},
changesTool(t),
} }
} }
@@ -72,13 +72,6 @@ func TestAnIssueRaceIntoTwoMilestonesOverTheBusEndsInOne(t *testing.T) {
if len(hist.History) != 1 { if len(hist.History) != 1 {
t.Fatalf("history %d values", len(hist.History)) 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() evMu.Lock()
defer evMu.Unlock() defer evMu.Unlock()
var kinds []string 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)
}
}
}
@@ -559,7 +559,7 @@ func cycleRules(is *Issue, from, to string) error {
} }
if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" { if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" {
return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+ return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+
"runner, the SDK, the delivery parts, or the catalog's bus, forge or build modules): it resolves with replay, the replay's id "+ "runner, the SDK, the delivery parts, or the catalogue's bus, forge or build modules): it resolves with replay, the replay's id "+
"in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number) "in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number)
} }
case StatusWontfix: case StatusWontfix:
+2 -4
View File
@@ -22,8 +22,7 @@
"tracking", "tracking",
"reserve", "reserve",
"fix-times", "fix-times",
"learn-fixes", "learn-fixes"
"changes"
] ]
} }
], ],
@@ -47,8 +46,7 @@
"tracking", "tracking",
"reserve", "reserve",
"fix-times", "fix-times",
"learn-fixes", "learn-fixes"
"changes"
] ]
} }
], ],
+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 | | `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 | | `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 | | `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 `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). 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 is open or closed, with its Reason; a closed milestone holding no issue is done.
Status string Status string
Reason string Reason string
// Moved is who last moved it and when, as the tracker says it; nil before its first move.
Moved *stamp
} }
var ( 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, 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 " + "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 " + "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. " + "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.",
Input: schema([]string{"of"}, map[string]any{ 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"), "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}, "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"}, "description": "minimal (the default): one line per child; detailed: the tree with issues, pull requests and groups"},
"bound": boundArg(), "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")}),
Run: func(m map[string]any) (any, error) { 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 { if err != nil {
return nil, err 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 { if p.Bound, err = a.num("bound", false); err != nil {
return nil, err return nil, err
} }
if p.Since, err = a.str("since"); err != nil {
return nil, err
}
return k.Progress(p) 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", 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", "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) t.Fatalf("invokes %v", m.Invokes)
} }
src := "" 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) src += readFile(t, f)
} }
for _, inv := range m.Invokes { for _, inv := range m.Invokes {
@@ -7,7 +7,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
) )
// MilestonesVerb is the tracker's verb that answers the milestones. // MilestonesVerb is the tracker's verb that answers the milestones.
@@ -21,13 +20,6 @@ type trackerMilestone struct {
Status string `json:"status"` Status string `json:"status"`
Reason string `json:"reason"` Reason string `json:"reason"`
Issues []int `json:"issues"` 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 // 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{} by := map[int][]Milestone{}
for _, m := range l.Milestones { 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)) out := make([]*Design, 0, len(ds))
for _, d := range ds { for _, d := range ds {
@@ -56,8 +56,6 @@ type ProgressAsk struct {
Of string Of string
Detail string Detail string
Bound int 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. // 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"` Source string `json:"source"`
ReadAt time.Time `json:"read-at"` ReadAt time.Time `json:"read-at"`
Cut string `json:"cut,omitempty"` 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`. // trackerRow is a row of `issue-tracker.list`.
@@ -144,15 +137,9 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
if k.Seats == nil { if k.Seats == nil {
return nil, errors.New("the knowledge base reaches no other seat here") 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), " ") of := strings.Join(strings.Fields(a.Of), " ")
p := &Progress{Of: of, Detail: detail, ReadAt: k.now()} p := &Progress{Of: of, Detail: detail, ReadAt: k.now()}
if window > 0 { var err error
p.scope = &sinceScope{rows: map[int]trackerRow{}}
}
switch { switch {
case strings.EqualFold(of, "mesh"): case strings.EqualFold(of, "mesh"):
err = k.progressMesh(p) err = k.progressMesh(p)
@@ -181,9 +168,6 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
if err != nil { if err != nil {
return nil, err 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. // A cut takes the warnings from the last: the calls that failed are said before the answers that did not parse.
callsFirst(p.Warnings) callsFirst(p.Warnings)
fitProgress(p, boundOf(a.Bound)) 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) items, warn := milestoneItems(ms, rows, missing)
p.Warnings = append(p.Warnings, warn...) p.Warnings = append(p.Warnings, warn...)
p.scope.hold(rows, ms)
var extra *extras var extra *extras
if p.Detail == DetailDetailed && p.scope == nil { if p.Detail == DetailDetailed {
if extra, err = k.readExtras(items); err != nil { if extra, err = k.readExtras(items); err != nil {
return err return err
} }
@@ -411,10 +394,6 @@ func (k *KB) progressMesh(p *Progress) error {
for _, n := range missingList { for _, n := range missingList {
missing[n] = true 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"} top := Item{ID: "mesh", Title: "the designs being built"}
var kids []Item var kids []Item
for _, d := range with { for _, d := range with {
@@ -462,11 +441,6 @@ func (k *KB) progressIssue(p *Progress, n int) error {
} }
it.ID = fmt.Sprintf("issue %d", n) it.ID = fmt.Sprintf("issue %d", n)
p.Source = "the issue tracker and mesh-delivery" 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}}}}) extra, err := k.readExtras([]Item{{Children: []Item{{ID: strconv.Itoa(n), State: StateMoving}}}})
if err != nil { if err != nil {
return err 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))) 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 { for _, r := range l.Rows {
if p.scope != nil {
p.scope.rows[r.Number] = r
}
if it, counts := issueItem(r); counts { if it, counts := issueItem(r); counts {
kids = append(kids, it) kids = append(kids, it)
} }
@@ -58,24 +58,12 @@ func TestProgressOverTheBus(t *testing.T) {
t.Errorf("detailed: no %q in\n%s", want, p.Tree) 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 { 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") t.Error("an argument naming the caller was taken")
} }
seats.mu.Lock() seats.mu.Lock()
defer seats.mu.Unlock() 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) 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 ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -395,150 +393,3 @@ func TestProgressOfAnIssueAndARecord(t *testing.T) {
t.Error("an issue the tracker does not hold was answered") 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 - `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 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. 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.list",
"seat:issue-tracker.get", "seat:issue-tracker.get",
"seat:issue-tracker.milestones", "seat:issue-tracker.milestones",
"seat:issue-tracker.changes",
"seat:mesh-delivery.deliveries", "seat:mesh-delivery.deliveries",
"seat:mesh-delivery.show", "seat:mesh-delivery.show",
"seat:mesh-delivery.groups" "seat:mesh-delivery.groups"
-19
View File
@@ -206,29 +206,10 @@ account (and, until R1, the controller) and no other caller, and only sealed to
answers the controller alone today; it must also answer `<node>.mesh-vault` when it moves with the media catalogue, answers the controller alone today; it must also answer `<node>.mesh-vault` when it moves with the media catalogue,
after release 57. after release 57.
## Taking what runs: `move-in` with `replace_running` (issue 1126, cause C4)
`move-in` never replaces a value the vault holds, so where the controller made an own secret anew after the move (a
rotation at start, a re-issue), the vault kept the first value while the new one runs. The node-engine of the machine
the secret is for hands the vault what runs, through `move-in` with `replace_running: true`, in an intake's two steps
(`owntake.go`): module, name and why, then the value sealed to the call's key. It is `move-in`'s and no verb of its
own, so the node-engine asks it on the subject it may publish already and the seat promises nothing new. Its own machine's secrets alone (the machine is the caller's, as the bus proved it); a
value the vault holds, never a person's (`accept`) or a provider's software's (`issued`, but for a covered bus
account); never over a value the vault changed in the last 15 minutes. The same value changes nothing and says so.
The replaced value is kept sealed as the record's `previous` (dropped by any later change), the generation is raised,
`secret-changed` and `own-replaced` are said, and the act is in `secret_handed` (`own-replaced`, `own-same`,
`own-take-refused`). A covered bus account so replaced has its hash written again. `own_drift` lists, from every
node's latest shadow answer (`<node>.node` in `node-engine.have`), each own secret whose node says another value,
with what the vault holds and whether the node's ask would be taken now. **The switch** is the vault's: a person
switches every machine's asks off or on with `own_take_switch` (`on`, `why`), kept in the vault's store over a
restart; it is on until switched off, and `own_drift` shows it.
## Events ## Events
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value - `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
or a sealed copy. or a sealed copy.
- `own-replaced` — `secret`, `node`, `module`, `name`, `from_generation`, `to_generation`, `by`, `why`, `at`:
an own secret replaced by what runs on its machine. Never a value or a fingerprint.
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before. - `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`, - `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
`reenabled`, `deleted`, naming holders, never a value. `reenabled`, `deleted`, naming holders, never a value.
+11 -1
View File
@@ -53,6 +53,8 @@ type AccountHash struct {
Adopted bool `json:"adopted,omitempty"` Adopted bool `json:"adopted,omitempty"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
Node string `json:"node,omitempty"` Node string `json:"node,omitempty"`
// Rotating is a rotation under way over a second login (rotation.go, ADR 0561 §4): absent when none is.
Rotating *Rotating `json:"rotating,omitempty"`
} }
// AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone. // AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone.
@@ -61,6 +63,8 @@ type AccountStore interface {
Put(a AccountHash) error Put(a AccountHash) error
// Users is every user with a key. // Users is every user with a key.
Users() ([]string, error) Users() ([]string, error)
// Delete takes a user's key out: an account retired (retire.go).
Delete(user string) error
} }
// busAccounts is AccountStore over the vault's own state through its runtime. // busAccounts is AccountStore over the vault's own state through its runtime.
@@ -80,6 +84,8 @@ func (busAccounts) Get(user string) (*AccountHash, error) {
func (busAccounts) Users() ([]string, error) { return stdio.State(BusAccountsState).Keys() } func (busAccounts) Users() ([]string, error) { return stdio.State(BusAccountsState).Keys() }
func (busAccounts) Delete(user string) error { return stdio.State(BusAccountsState).Delete(user) }
func (busAccounts) Put(a AccountHash) error { func (busAccounts) Put(a AccountHash) error {
_, err := stdio.State(BusAccountsState).Put(a.User, a) _, err := stdio.State(BusAccountsState).Put(a.User, a)
return err return err
@@ -181,6 +187,10 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
if err != nil { if err != nil {
return Hashed{}, err return Hashed{}, err
} }
if rec.Retired != nil {
v.hashes.paid(secret)
return Hashed{}, fmt.Errorf("%s is retired (%s): its hash is written again only when it is wanted again", secret, rec.Retired.Why)
}
user, password, err := accountOf(secret, value) user, password, err := accountOf(secret, value)
if err != nil { if err != nil {
return Hashed{}, err return Hashed{}, err
@@ -259,7 +269,7 @@ func (v *Vault) HashHeld() (written []Hashed, failed map[string]string) {
return nil, failed return nil, failed
} }
for _, r := range records { for _, r := range records {
if !Covered(r.Name) { if !Covered(r.Name) || r.Retired != nil {
continue continue
} }
h, err := v.HashAccount(r.Name, false) h, err := v.HashAccount(r.Name, false)
+3 -26
View File
@@ -58,32 +58,6 @@ type adoptionKept struct {
failed string // why the last look found nothing to adopt, or "" failed string // why the last look found nothing to adopt, or ""
} }
// busNode is the node the assignments name as holding the bus module: exactly one.
func (v *Vault) busNode() (string, error) {
if v.Assignments.Keys == nil {
return "", errors.New("the vault cannot read the assignments, so it cannot tell which node holds the bus")
}
keys, err := v.Assignments.Keys()
if err != nil {
return "", fmt.Errorf("the assignments' keys could not be read: %w", err)
}
var nodes []string
for _, k := range keys {
if node, module, ok := strings.Cut(k, "."); ok && module == BusModule {
nodes = append(nodes, node)
}
}
sort.Strings(nodes)
switch len(nodes) {
case 1:
return nodes[0], nil
case 0:
return "", fmt.Errorf("no node is assigned the bus module %s", BusModule)
}
return "", fmt.Errorf("%d nodes are assigned the bus module %s (%s): the vault adopts from one alone", len(nodes),
BusModule, strings.Join(nodes, ", "))
}
// AdoptNow is the tool account_adopt: the bus node's have read now and adopted, though it says the same as before. // AdoptNow is the tool account_adopt: the bus node's have read now and adopted, though it says the same as before.
func (v *Vault) AdoptNow(caller string) (Adoption, error) { func (v *Vault) AdoptNow(caller string) (Adoption, error) {
if v.Haves == nil { if v.Haves == nil {
@@ -143,6 +117,9 @@ func (v *Vault) adopt(node string, raw []byte, by string, force bool) (Adoption,
if err != nil { if err != nil {
return Adoption{}, fmt.Errorf("the have of %s on %s is not a have: %w", BusModule, node, err) return Adoption{}, fmt.Errorf("the have of %s on %s is not a have: %w", BusModule, node, err)
} }
if have.UserList != nil {
v.LoadedListHeard(have.UserList.Revision)
}
if have.UserList == nil || have.UserList.Handed == nil { if have.UserList == nil || have.UserList.Handed == nil {
return Adoption{}, fmt.Errorf("the have of %s on %s hands no hashes yet (its node-engine hands them once it "+ return Adoption{}, fmt.Errorf("the have of %s on %s hands no hashes yet (its node-engine hands them once it "+
"heard the vault's key in its answer to node-key, and writes the list)", BusModule, node) "heard the vault's key in its answer to node-key, and writes the list)", BusModule, node)
@@ -4,9 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "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). // 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. // that the module's plan there names.
type Declared struct { type Declared struct {
Read AssignmentReader 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 is the vault's store, whose record of who holds a provision's one credential (pair.go) the rule reads.
Store *Store 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 // 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. // again, and records no refusal.
const notYetPrefix = "not yet: " 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)
}
+1 -13
View File
@@ -91,9 +91,7 @@ type callKey struct {
// ask is the ask's record for `secret-ask`, which the call's id is. // ask is the ask's record for `secret-ask`, which the call's id is.
ask *AskRecord ask *AskRecord
// move is what `move-in` takes: the row of the controller's store (move.go). // move is what `move-in` takes: the row of the controller's store (move.go).
move *MoveOf move *MoveOf
// why is what `move-in` with replace_running was asked for, in the caller's words (owntake.go).
why string
public [32]byte public [32]byte
private [32]byte private [32]byte
until time.Time until time.Time
@@ -151,16 +149,6 @@ func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, strin
return id, base64.StdEncoding.EncodeToString(pub[:]), nil return id, base64.StdEncoding.EncodeToString(pub[:]), nil
} }
// callKind is the kind of the open call id, or "" when none is open: what its second step is for.
func (v *Vault) callKind(id string) string {
v.callsMu.Lock()
defer v.callsMu.Unlock()
if c, ok := v.pending[id]; ok {
return c.verb
}
return ""
}
// takeCall ends a call: its key is forgotten whatever follows. // takeCall ends a call: its key is forgotten whatever follows.
func (v *Vault) takeCall(verb, id string) (*callKey, error) { func (v *Vault) takeCall(verb, id string) (*callKey, error) {
v.callsMu.Lock() v.callsMu.Lock()
@@ -48,6 +48,13 @@ func (f *fakeAccounts) Put(a AccountHash) error {
return nil return nil
} }
func (f *fakeAccounts) Delete(user string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.keys, user)
return nil
}
func (f *fakeAccounts) Users() ([]string, error) { func (f *fakeAccounts) Users() ([]string, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -0,0 +1,49 @@
package main
// The switch on the controller's lease (novox/hq ADR 0561 §3): minting is on only while the lease is handed to a holder
// that is no controller, and off for a controller's value, a key deleted or expired, or one that cannot be read. The
// write that hands it away turns it on and mints every wanted account the vault holds no value for; a lease handed back
// turns it off. No clock and no setting turns it. A key's age passing is heard by no watch, so the lease is read afresh
// before every mint (LeaseRead): a lease that expired since mints nothing.
import "git.novox.be/novox/mesh-sdk/go/buscompose"
// LeaseHeard is the watch of the controller's lease. When minting turns on or off, every wanted account is judged again:
// minted when on, held back with the new why when off.
func (v *Vault) LeaseHeard(raw []byte, found bool, readErr error) {
if !v.setLease(raw, found, readErr) {
return
}
on, why := v.mintingOn()
v.log("minting is %s: %s", map[bool]string{true: "ON", false: "off"}[on], why)
for _, user := range v.wantedUsers() {
if w, ok := v.wanted(user); ok {
if _, err := v.mintWanted(w, "the lease write"); err != nil {
v.log("%s is not minted: %v", user, err)
}
}
}
}
// setLease records what the lease says, and answers whether minting turned on or off by it.
func (v *Vault) setLease(raw []byte, found bool, readErr error) bool {
on, why := buscompose.LeaseHandedAway(raw, found, readErr)
m := &v.minting
m.mu.Lock()
defer m.mu.Unlock()
flipped := on != m.on || m.why == ""
if flipped {
m.since = v.Now().UTC()
}
m.on, m.why = on, why
return flipped
}
// leaseAfresh reads the lease again before a mint, where the vault can: a key that expired since is heard by no watch.
func (v *Vault) leaseAfresh() {
if v.LeaseRead == nil {
return
}
raw, found, err := v.LeaseRead()
v.setLease(raw, found, err)
}
+83 -30
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. // 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. // 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. env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
assigned = &WatchedAssignments{Fallback: readAssignment}
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: assigned.Read, Confirm: readAssignment, Store: store},
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask, Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, LeaseRead: readLease, WantRead: readWant,
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }} Placement: placement}
return env 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 // 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. // manifest `reads` it), or nil when there is none.
func readHave(key string) ([]byte, error) { func readHave(key string) ([]byte, error) {
@@ -120,6 +96,74 @@ func watchHandings(env *Env) bool {
return true return true
} }
// watchWants mints from the grants part's wanted accounts as they change (mint.go, ADR 0561 §2): a watch, never a poll.
// It answers whether the watch began; one that did not is begun again at the next minute pass.
func watchWants(env *Env) bool {
if env.Vault == nil {
return true
}
err := stdio.State(WantedRead).Watch("", func(c stdio.StateChange) error {
env.Vault.WantHeard(c.Key, c.Value, c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0)
return nil
})
if err != nil {
say("the grants part's wanted accounts are not watched, so nothing is minted; begun again at the next pass: %v", err)
return false
}
// The watch returns once every current key was handed: what has no key now is no longer wanted.
env.Vault.WantsRead()
return true
}
// placement reads the indexer's placement through the runtime under the vault's own account (its manifest reads it).
var placement = PlacementReader{
Keys: func() ([]string, error) { return stdio.State(PlacementRead).Keys() },
Read: func(key string) ([]byte, error) {
e, err := stdio.State(PlacementRead).Get(key)
if err != nil || e == nil {
return nil, err
}
return e.Value, nil
},
}
// readWant is one wanted account of the grants part, read afresh (issue-account).
func readWant(user string) ([]byte, bool, error) {
e, err := stdio.State(WantedRead).Get(user)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// readLease is the controller's lease key, read through the runtime under the vault's own account (its manifest reads it).
func readLease() ([]byte, bool, error) {
e, err := stdio.State(LeaseRead).Get(LeaseKey)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// watchLease turns minting on and off as the controller's lease is written (lease.go, ADR 0561 §3): a watch, never a
// poll; and the lease is read afresh before every mint. It answers whether the watch began.
func watchLease(env *Env) bool {
if env.Vault == nil {
return true
}
env.Vault.LeaseHeard(readLease())
err := stdio.State(LeaseRead).Watch(LeaseKey, func(c stdio.StateChange) error {
deleted := c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0
env.Vault.LeaseHeard(c.Value, !deleted, nil)
return nil
})
if err != nil {
say("the controller's lease is not watched, so minting stays as last read; begun again at the next pass: %v", err)
return false
}
return true
}
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what // adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
// `operator key set` recorded; the controller keeps only a public half there), and seals again any // `operator key set` recorded; the controller keeps only a public half there), and seals again any
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. It // operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. It
@@ -173,17 +217,26 @@ func main() {
written, failed := env.Vault.HashHeld() 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) 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() // The operator key first: nothing is kept without it, so a want heard before it would not be minted.
env.pass()
watched := watchHandings(env)
lease := watchLease(env)
wants := watchWants(env)
for { for {
if !watched { if !watched {
watched = watchHandings(env) watched = watchHandings(env)
} }
if !watching { if !lease {
watching = watchAssignments() lease = watchLease(env)
}
if !wants {
wants = watchWants(env)
} }
env.pass() env.pass()
if env.Vault != nil { if env.Vault != nil {
env.Vault.HashOwed() env.Vault.HashOwed()
env.Vault.MintOwed()
env.Vault.RetirePass()
} }
env.pairPass() env.pairPass()
if env.Custody != nil { if env.Custody != nil {
+376
View File
@@ -0,0 +1,376 @@
package main
// The vault mints every new bus account (novox/hq ADR 0561 §1 to §4, issue 774).
//
// The grants part says which accounts the mesh wants, in its state `accounts` that the vault alone reads (§2): one key
// per bus user, holding what the controller's credential carries beside the password. The vault watches it, and for a
// user it holds no value for and whose hash it did not adopt it makes a password, keeps the account whole (built by
// mesh-sdk's HeldCredential, byte for byte the controller's shape) as the module's own secret on that node
// (`own/<node>/<module>/<broker|broker-<process>|provisioner-broker>`), sealed, with the operator's copy; writes the
// bcrypt hash into `bus-accounts`; and says `account-issued`, then `secret-changed`. The node's engine asks `own` again
// on `secret-changed` and composes.
//
// **One issuer at every moment** (§3): minting is on only while the controller's lease is handed to a holder that is no
// controller (mesh-sdk LeaseHandedAway). Until then the controller mints, the vault keeps hashing what it is handed, and
// vault_status names every wanted account it holds back and why. **Nothing is minted anew** (§4): an account whose value
// the vault holds, or whose hash it adopted, is never minted; the node accounts and the controller's own are never wanted.
// **Never two values for one user** (§7): a user whose hash the vault already wrote from another of its secrets is held
// back, said, and never replaced but by a rotation.
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buscompose"
)
// WantedRead is the grants part's state of wanted accounts, as the vault's manifest reads it.
const WantedRead = buscompose.GrantsAccounts
// LeaseRead is the controller's lease, as the vault's manifest reads it, and LeaseKey its one key.
const (
LeaseRead = buscompose.ControllerLease
LeaseKey = buscompose.ControllerLeaseKey
)
// AccountIssuedEvent is what the vault says when it minted an account (mesh-sdk buscompose.AccountIssued).
const AccountIssuedEvent = buscompose.AccountIssuedEvent
// minting is the switch and what the vault knows of the wanted accounts, kept in memory and read again from the bus at
// every start: the grants part's state and the lease are the record, never this.
type minting struct {
mu sync.Mutex
on bool
why string
since time.Time
// wants are the wanted accounts, by user, as last heard.
wants map[string]buscompose.WantedAccount
// heldBack are the wanted users the vault did not mint, with why (vault_status).
heldBack map[string]string
// owed are the wanted users whose mint failed, tried again at the pass (MintOwed).
owed map[string]bool
// minted are the accounts this vault minted since it started, by user (vault_status).
minted map[string]Minted
// loadedRevision is the highest revision of the user list the bus node said it loaded (rotation.go).
loadedRevision uint64
// retiring is what the passes keep of the accounts no longer wanted (retire.go).
retiring retiring
// mintMu keeps one mint at a time: a want heard, the switch turning on, and issue-account.
mintMu sync.Mutex
}
// Minted is what one mint did: the account and its secret, never its value or its hash.
type Minted struct {
User string `json:"user"`
Secret string `json:"secret"`
Generation int `json:"generation"`
At time.Time `json:"at"`
// Hashed says its hash is written; false leaves it owed, written at the next pass.
Hashed bool `json:"hashed"`
}
// MintState is what vault_status says of minting.
type MintState struct {
On bool `json:"on"`
Why string `json:"why"`
Since time.Time `json:"since,omitzero"`
Wanted int `json:"wanted"`
HeldBack map[string]string `json:"held_back"`
Minted []Minted `json:"minted"`
}
// MintState answers how minting stands.
func (v *Vault) MintState() MintState {
m := &v.minting
m.mu.Lock()
defer m.mu.Unlock()
out := MintState{On: m.on, Why: m.why, Since: m.since, Wanted: len(m.wants), HeldBack: map[string]string{}, Minted: []Minted{}}
if out.Why == "" {
out.Why = "the controller's lease has not been read yet, so nothing is minted"
}
for u, why := range m.heldBack {
out.HeldBack[u] = why
}
for _, x := range m.minted {
out.Minted = append(out.Minted, x)
}
sort.Slice(out.Minted, func(i, j int) bool { return out.Minted[i].User < out.Minted[j].User })
return out
}
// mintingOn says whether the vault mints now, and why.
func (v *Vault) mintingOn() (bool, string) {
v.minting.mu.Lock()
defer v.minting.mu.Unlock()
if v.minting.why == "" {
return false, "the controller's lease has not been read yet, so nothing is minted"
}
return v.minting.on, v.minting.why
}
func (v *Vault) holdBack(user, why string) {
m := &v.minting
m.mu.Lock()
defer m.mu.Unlock()
if m.heldBack == nil {
m.heldBack = map[string]string{}
}
m.heldBack[user] = why
}
func (v *Vault) noLongerHeld(user string) {
m := &v.minting
m.mu.Lock()
defer m.mu.Unlock()
delete(m.heldBack, user)
}
// AccountSecret is the vault's name of the own secret a wanted account is held in.
func AccountSecret(w buscompose.WantedAccount) (string, error) {
return OwnSecret(w.Node, w.Module, w.OwnSecret())
}
// WantHeard is the watch of the grants part's wanted accounts: a key put is a want, minted when minting is on; a key
// deleted is a want gone (retire.go).
func (v *Vault) WantHeard(user string, raw []byte, deleted bool) {
if deleted {
v.wantGone(user)
return
}
w, err := buscompose.ReadWantedAccount(user, raw)
if err != nil {
v.log("the wanted account %s is not read, so nothing is minted for it: %v", user, err)
v.holdBack(user, "its wanted key is not readable: "+err.Error())
return
}
v.minting.mu.Lock()
if v.minting.wants == nil {
v.minting.wants = map[string]buscompose.WantedAccount{}
}
v.minting.wants[user] = w
v.minting.mu.Unlock()
v.wantBack(user)
if _, err := v.mintWanted(w, "the grants part's want of "+user); err != nil {
v.log("%s is not minted: %v", user, err)
}
}
// errHeldBack is a want the vault does not mint now, with why: not an error of the vault.
type errHeldBack struct{ why string }
func (e errHeldBack) Error() string { return e.why }
// mintWanted mints w when minting is on, the vault holds no value for it and adopted no hash of it; otherwise it says
// why not and, where that is a hold, keeps it for vault_status.
func (v *Vault) mintWanted(w buscompose.WantedAccount, by string) (Minted, error) {
m, err := v.mintOnce(w, by)
var held errHeldBack
v.owe(w.User, err != nil && !errors.As(err, &held) && w.Valid() == nil)
return m, err
}
// mintOnce is one try of mintWanted.
func (v *Vault) mintOnce(w buscompose.WantedAccount, by string) (Minted, error) {
v.minting.mintMu.Lock()
defer v.minting.mintMu.Unlock()
if err := w.Valid(); err != nil {
v.holdBack(w.User, err.Error())
return Minted{}, err
}
secret, err := AccountSecret(w)
if err != nil {
v.holdBack(w.User, err.Error())
return Minted{}, err
}
if _, held, err := v.Store.Read(secret); err != nil {
return Minted{}, err
} else if held {
// Its value is held (the controller's, handed in, or one the vault minted): never minted anew (§4).
v.noLongerHeld(w.User)
return Minted{}, errHeldBack{secret + " is held already: an account the vault holds a value for is never minted anew (ADR 0561 §4)"}
}
if v.Accounts == nil {
v.holdBack(w.User, "the vault has no state of bus accounts to write the hash to")
return Minted{}, errors.New("the vault has no state of bus accounts to write the hash to")
}
was, err := v.Accounts.Get(w.User)
if err != nil {
v.holdBack(w.User, "its key in "+BusAccountsState+" cannot be read: "+err.Error())
return Minted{}, err
}
if was != nil && was.Adopted {
// The hash the bus loaded is kept (§4): not minted, and not held back either.
v.noLongerHeld(w.User)
return Minted{}, errHeldBack{w.User + "'s hash was adopted from the list the bus loaded: it keeps it, and is never minted (ADR 0561 §4)"}
}
if was != nil && was.Of != "" && was.Of != secret {
why := fmt.Sprintf("%s holds the hash of %s for %s, not of %s: a user has one value, so nothing is minted until "+
"a person sees why (ADR 0561 §7)", BusAccountsState, was.Of, w.User, secret)
v.holdBack(w.User, why)
return Minted{}, errHeldBack{why}
}
v.leaseAfresh()
if on, why := v.mintingOn(); !on {
v.holdBack(w.User, "held back: "+why)
return Minted{}, errHeldBack{why}
}
raw := make([]byte, 30)
if _, err := rand.Read(raw); err != nil {
return Minted{}, err
}
value, err := buscompose.HeldCredential(w, base64.RawURLEncoding.EncodeToString(raw))
if err != nil {
v.holdBack(w.User, err.Error())
return Minted{}, err
}
c, err := v.keepLocked(secret, string(value), "the vault", "", OriginMade, keepOptions{neverReplace: true, quiet: true})
if err != nil {
v.holdBack(w.User, "its value could not be kept: "+err.Error())
return Minted{}, err
}
out := Minted{User: w.User, Secret: secret, Generation: c.Generation, At: c.At}
// The hash first, then account-issued, then secret-changed: the grants part composes the user before the node asks.
if _, err := v.HashAccount(secret, false); err != nil {
v.hashes.owe(secret, err.Error())
v.log("%s is minted and kept, and ITS HASH IS NOT WRITTEN, written at the next pass: %v", w.User, err)
} else {
out.Hashed = true
if v.Emit != nil {
e := buscompose.AccountIssued{User: w.User, Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process,
Generation: c.Generation, At: c.At}
body := map[string]any{}
b, _ := json.Marshal(e)
_ = json.Unmarshal(b, &body)
if err := v.Emit(AccountIssuedEvent, body); err != nil {
v.log("%s is minted and hashed, but %s could not be said: %v", w.User, AccountIssuedEvent, err)
}
}
}
v.announce(c)
v.minting.mu.Lock()
if v.minting.minted == nil {
v.minting.minted = map[string]Minted{}
}
v.minting.minted[w.User] = out
v.minting.mu.Unlock()
v.noLongerHeld(w.User)
v.log("minted the bus account %s as %s (generation %d), for %s", w.User, secret, c.Generation, by)
return out, nil
}
// MintOwed tries again, at the vault's minute pass, every wanted account whose mint failed (its value could not be kept,
// its key read, its password made): a want heard is never lost to one refusal. A want held back is not owed: the switch
// or the want changing judges it again.
func (v *Vault) MintOwed() {
v.minting.mu.Lock()
var owed []string
for u := range v.minting.owed {
owed = append(owed, u)
}
v.minting.mu.Unlock()
sort.Strings(owed)
for _, u := range owed {
if w, ok := v.wanted(u); ok {
if _, err := v.mintWanted(w, "the pass, after a mint that failed"); err != nil {
v.log("%s is still not minted: %v", u, err)
}
} else {
v.owe(u, false)
}
}
}
// owe marks user's mint as failed (tried again at the pass) or not.
func (v *Vault) owe(user string, owed bool) {
v.minting.mu.Lock()
defer v.minting.mu.Unlock()
if !owed {
delete(v.minting.owed, user)
return
}
if v.minting.owed == nil {
v.minting.owed = map[string]bool{}
}
v.minting.owed[user] = true
}
// heldBackOrMinted is a mint's answer as a tool gives it.
func heldBackOrMinted(m Minted, err error) (any, error) {
var held errHeldBack
if errors.As(err, &held) {
return map[string]any{"minted": false, "why": held.why}, nil
}
if err != nil {
return nil, err
}
return map[string]any{"minted": true, "account": m}, nil
}
// IssueAccount is the seat's verb issue-account (ADR 0561 §9): one wanted user minted now, its key read afresh from the
// grants part's state. The recovery when a want was not heard: it mints nothing a want would not, and is refused while
// minting is off, for a node's account and the controller's (never minted here, §4), and for a user nobody wants.
func (v *Vault) IssueAccount(caller, user string) (any, error) {
user = strings.TrimSpace(user)
if user == "" {
return nil, errors.New("issue-account names a bus user (`user`), e.g. ace.mesh-build-go")
}
if nodeOrController(user) {
return nil, fmt.Errorf("%s is never minted by the vault: a node's account and the controller's keep the hash they "+
"have (ADR 0561 §4)", user)
}
v.leaseAfresh()
if on, why := v.mintingOn(); !on {
return nil, fmt.Errorf("not minted: %s", why)
}
raw, found, err := []byte(nil), false, error(nil)
if v.WantRead != nil {
raw, found, err = v.WantRead(user)
} else if w, ok := v.wanted(user); ok {
raw, err = json.Marshal(w)
found = err == nil
}
if err != nil {
return nil, fmt.Errorf("the wanted account %s could not be read: %w", user, err)
}
if !found {
return nil, fmt.Errorf("the grants part holds no wanted account %s: the vault mints only what placement composes "+
"(grants_accounts lists them)", user)
}
w, err := buscompose.ReadWantedAccount(user, raw)
if err != nil {
return nil, err
}
v.log("issue-account %s, asked by %s", user, someoneOr("", caller))
return heldBackOrMinted(v.mintWanted(w, "issue-account by "+someoneOr("", caller)))
}
// wantedUsers is every wanted user heard, sorted.
func (v *Vault) wantedUsers() []string {
v.minting.mu.Lock()
defer v.minting.mu.Unlock()
out := make([]string, 0, len(v.minting.wants))
for u := range v.minting.wants {
out = append(out, u)
}
sort.Strings(out)
return out
}
func (v *Vault) wanted(user string) (buscompose.WantedAccount, bool) {
v.minting.mu.Lock()
defer v.minting.mu.Unlock()
w, ok := v.minting.wants[user]
return w, ok
}
// nodeOrController says user is a node's account or the controller's: never minted here (ADR 0561 §4, §9).
func nodeOrController(user string) bool {
return user == ControllerCaller || strings.HasPrefix(user, "node.")
}
@@ -0,0 +1,422 @@
//go:build integration
package main
// The vault whole against a real bus (novox/hq ADR 0561 §1 to §4, issue 774's acceptance 12): the built binary, played
// over its real stdin and stdout as the node's tool runtime plays it (tools/call with the caller in _meta; mesh/state.*
// and mesh/publish answered from a real nats-server's key-value buckets and subjects), its store in a directory of its
// own. A want written to the grants part's state is minted only once the controller's lease is handed away: the hash is
// in bus-accounts, own answers the value sealed to the node, the value is the controller's shape and its hash's
// password, account-issued is said; an adopted user is never minted, and a lease handed back mints nothing.
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/nacl/box"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/seal"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
// wire is one line between the runtime and the bundle (the SDK's stdio message).
type wire struct {
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
// player is the node's tool runtime as far as the vault needs it, over a real bus.
type player struct {
t *testing.T
ctx context.Context
nc *nats.Conn
js jetstream.JetStream
mu sync.Mutex
in io.Writer
next int
pending map[string]chan wire
said []string
}
// bucketOf is the bucket a state names: the vault's own by its local name, another's as `<owner>.<name>`.
func bucketOf(state string) string {
if owner, name, ok := strings.Cut(state, "."); ok {
return owner + "_" + name
}
return "mesh-vault_" + state
}
func (p *player) send(m map[string]any) {
raw, _ := json.Marshal(m)
p.mu.Lock()
defer p.mu.Unlock()
_, _ = p.in.Write(append(raw, '\n'))
}
func (p *player) answer(id json.RawMessage, result any, err error) {
if err != nil {
p.send(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
return
}
p.send(map[string]any{"id": id, "result": result})
}
func (p *player) kv(state string) (jetstream.KeyValue, error) {
return p.js.KeyValue(p.ctx, bucketOf(state))
}
// serve answers what the bundle asks, and hands the answers to what the test asked it.
func (p *player) serve(out io.Reader) {
scanner := bufio.NewScanner(out)
scanner.Buffer(make([]byte, 1<<20), 1<<24)
for scanner.Scan() {
var m wire
if json.Unmarshal(scanner.Bytes(), &m) != nil {
continue
}
if m.Method == "" {
var key string
_ = json.Unmarshal(m.ID, &key)
p.mu.Lock()
ch := p.pending[key]
delete(p.pending, key)
p.mu.Unlock()
if ch != nil {
ch <- m
}
continue
}
go p.handle(m)
}
}
func (p *player) handle(m wire) {
var a struct {
State string `json:"state"`
Key string `json:"key"`
Value json.RawMessage `json:"value"`
Body json.RawMessage `json:"body"`
}
_ = json.Unmarshal(m.Params, &a)
switch m.Method {
case "mesh/state.get":
kv, err := p.kv(a.State)
if err != nil {
p.answer(m.ID, nil, err)
return
}
e, err := kv.Get(p.ctx, a.Key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
p.answer(m.ID, nil, nil)
return
}
if err != nil {
p.answer(m.ID, nil, err)
return
}
p.answer(m.ID, map[string]any{"key": e.Key(), "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil)
case "mesh/state.put":
kv, err := p.kv(a.State)
if err == nil {
var rev uint64
rev, err = kv.Put(p.ctx, a.Key, a.Value)
if err == nil {
p.answer(m.ID, map[string]any{"revision": rev}, nil)
return
}
}
p.answer(m.ID, nil, err)
case "mesh/state.keys":
kv, err := p.kv(a.State)
if err != nil {
p.answer(m.ID, nil, err)
return
}
keys, err := kv.Keys(p.ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
keys, err = []string{}, nil
}
p.answer(m.ID, keys, err)
case "mesh/state.delete":
kv, err := p.kv(a.State)
if err == nil {
err = kv.Delete(p.ctx, a.Key)
}
p.answer(m.ID, map[string]any{}, err)
case "mesh/state.watch":
kv, err := p.kv(a.State)
if err != nil {
p.answer(m.ID, nil, err)
return
}
key := strings.ReplaceAll(a.Key, "**", ">")
if key == "" {
key = ">"
}
w, err := kv.Watch(p.ctx, key)
if err != nil {
p.answer(m.ID, nil, err)
return
}
var watch string
_ = json.Unmarshal(m.ID, &watch)
current := true
for e := range w.Updates() {
if e == nil {
current = false
p.answer(m.ID, map[string]any{}, nil)
continue
}
op := "put"
if e.Operation() != jetstream.KeyValuePut {
op = "delete"
}
change := map[string]any{"state": a.State, "key": e.Key(), "op": op, "revision": e.Revision(), "current": current}
if op == "put" {
change["value"] = json.RawMessage(e.Value())
}
p.mu.Lock()
p.next++
id := fmt.Sprintf("w-%d", p.next)
p.mu.Unlock()
p.send(map[string]any{"id": id, "method": "mesh/state", "params": map[string]any{"watch": watch, "change": change}})
}
case "mesh/publish":
var e struct {
Key string `json:"key"`
Body json.RawMessage `json:"body"`
}
_ = json.Unmarshal(m.Params, &e)
p.mu.Lock()
p.said = append(p.said, e.Key+" "+string(e.Body))
p.mu.Unlock()
p.answer(m.ID, map[string]any{}, p.nc.Publish("mesh.mod.mesh-vault.event."+e.Key, e.Body))
default:
// mesh/ask and the rest: nothing answers on this bus, as a call with no responder.
p.answer(m.ID, nil, errors.New("nats: no responders available for request"))
}
}
// call is a tools/call of the vault as caller, answered as the tool's JSON.
func (p *player) call(name, caller string, args map[string]any) (json.RawMessage, error) {
p.mu.Lock()
p.next++
id := fmt.Sprintf("c-%d", p.next)
ch := make(chan wire, 1)
p.pending[id] = ch
p.mu.Unlock()
p.send(map[string]any{"id": id, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args,
"_meta": map[string]any{"mesh/caller": caller}}})
select {
case m := <-ch:
var r struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if m.Error != nil {
return nil, errors.New(m.Error.Message)
}
if err := json.Unmarshal(m.Result, &r); err != nil || len(r.Content) != 1 {
return nil, fmt.Errorf("%s answered %s", name, m.Result)
}
if r.IsError {
return nil, errors.New(r.Content[0].Text)
}
return json.RawMessage(r.Content[0].Text), nil
case <-time.After(30 * time.Second):
return nil, fmt.Errorf("%s did not answer in 30s", name)
}
}
func (p *player) put(t *testing.T, state, key string, v any) {
t.Helper()
kv, err := p.kv(state)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(v)
if _, err := kv.Put(p.ctx, key, raw); err != nil {
t.Fatal(err)
}
}
func (p *player) hashOf(t *testing.T, user string) *AccountHash {
t.Helper()
kv, err := p.kv(BusAccountsState)
if err != nil {
t.Fatal(err)
}
e, err := kv.Get(p.ctx, user)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
}
if err != nil {
t.Fatal(err)
}
var a AccountHash
if err := json.Unmarshal(e.Value(), &a); err != nil {
t.Fatal(err)
}
return &a
}
func eventually(t *testing.T, what string, ok func() bool) {
t.Helper()
for end := time.Now().Add(30 * time.Second); time.Now().Before(end); time.Sleep(50 * time.Millisecond) {
if ok() {
return
}
}
t.Fatalf("never: %s", what)
}
// 774/AC12.
func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
dir := t.TempDir()
bin := filepath.Join(dir, "mesh-vault")
build := exec.Command("go", "build", "-o", bin, ".")
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
t.Fatalf("the vault does not build: %v", err)
}
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
for _, b := range []string{"mesh-vault_bus-accounts", "mesh-grants_accounts", "mesh-controller_lease",
"mesh-controller_assignments", "node-engine_have", "mesh-delivery-indexer_placement"} {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 2}); err != nil {
t.Fatal(err)
}
}
// The operator key, as the mesh exports it to the vault's root.
opPub, _, err := box.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
root := filepath.Join(dir, "root")
_ = os.MkdirAll(root, 0o700)
export, _ := json.Marshal(map[string]string{"operator-key": base64.StdEncoding.EncodeToString(opPub[:])})
if err := os.WriteFile(filepath.Join(root, "export.json"), export, 0o600); err != nil {
t.Fatal(err)
}
cmd := exec.CommandContext(ctx, bin)
cmd.Env = append(os.Environ(), "MESH_VAULT_STORE="+filepath.Join(dir, "store"), "MESH_VAULT_STATE="+filepath.Join(dir, "state"),
"MESH_VAULT_ROOT="+root, "MESH_VAULT_LEDGER="+filepath.Join(dir, "ledger"), "MESH_RECEIVES="+filepath.Join(dir, "mesh.json"),
"MESH_NODE=novox", "MESH_MODULE=mesh-vault")
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = stdin.Close(); cancel(); _ = cmd.Wait() })
p := &player{t: t, ctx: ctx, nc: nc, js: js, in: stdin, pending: map[string]chan wire{}}
go p.serve(stdout)
status := func() MintState {
raw, err := p.call("vault_status", "person.jochen", nil)
if err != nil {
return MintState{}
}
var s struct {
Minting MintState `json:"minting"`
}
_ = json.Unmarshal(raw, &s)
return s.Minting
}
// The controller holds the lease: a want is held back, nothing is minted.
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57})
x, y, z := aWant("mod-x"), aWant("mod-y"), aWant("mod-z")
p.put(t, "mesh-vault.bus-accounts", y.User, AccountHash{User: y.User, Hash: "$2a$04$adoptedadoptedadoptedadoptedadoptedadoptedadopte", Adopted: true})
p.put(t, "mesh-grants.accounts", x.User, x)
p.put(t, "mesh-grants.accounts", y.User, y)
eventually(t, "the want held back under the controller's lease", func() bool {
return strings.Contains(status().HeldBack[x.User], "controller holds the lease")
})
if p.hashOf(t, x.User) != nil {
t.Fatal("minted under the controller's lease")
}
// The write that hands the lease away mints it.
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
eventually(t, "the account minted at the lease write", func() bool { return p.hashOf(t, x.User) != nil })
nodePub, nodePriv := aSealingKey(t)
if _, err := p.call(Seat+".node-key", "node.ace", map[string]any{"key": nodePub}); err != nil {
t.Fatal(err)
}
raw, err := p.call(Seat+".own", "node.ace", map[string]any{})
if err != nil {
t.Fatal(err)
}
var own OwnAnswer
if err := json.Unmarshal(raw, &own); err != nil {
t.Fatal(err)
}
if len(own.Own) != 1 || own.Own[0].Module != "mod-x" || own.Own[0].Name != "broker" || own.Own[0].Origin != OriginMade {
t.Fatalf("own answers %+v", own.Own)
}
value, err := seal.Open(own.Own[0].Sealed, nodePriv)
if err != nil {
t.Fatal(err)
}
var held struct{ Password string }
_ = json.Unmarshal([]byte(value), &held)
shape, _ := buscompose.HeldCredential(x, held.Password)
if value != string(shape) {
t.Errorf("the minted value %q is not the controller's shape %q", value, shape)
}
if h := p.hashOf(t, x.User); bcrypt.CompareHashAndPassword([]byte(h.Hash), []byte(held.Password)) != nil || h.Adopted || h.Generation != 1 {
t.Errorf("bus-accounts %s holds %+v, not the minted password's hash", x.User, h)
}
if h := p.hashOf(t, y.User); h == nil || !h.Adopted {
t.Errorf("the adopted %s was replaced: %+v", y.User, h)
}
p.mu.Lock()
said := strings.Join(p.said, "\n")
p.mu.Unlock()
if !strings.Contains(said, AccountIssuedEvent+` {"`) || !strings.Contains(said, `"user":"ace.mod-x"`) || strings.Contains(said, held.Password) {
t.Errorf("the vault said: %s", said)
}
// Handed back to a controller: a new want mints nothing.
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"instance": "novox/2/2026-10-28", "epoch": 58})
p.put(t, "mesh-grants.accounts", z.User, z)
eventually(t, "the new want held back once the lease is the controller's again", func() bool {
return strings.Contains(status().HeldBack[z.User], "controller holds the lease")
})
if p.hashOf(t, z.User) != nil {
t.Error("minted after the lease was handed back")
}
}
@@ -0,0 +1,244 @@
package main
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// aWant is a wanted account of a module on ace, as the grants part writes it.
func aWant(module string) buscompose.WantedAccount {
return buscompose.WantedAccount{User: "ace." + module, Kind: buscompose.KindModule, Node: "ace", Module: module,
URL: "nats://novox.mesh:4222", Fingerprint: "ab:cd", Claims: []buscompose.ClaimedSeat{{Seat: "s", Scope: "node"}}}
}
func wantRaw(t *testing.T, w buscompose.WantedAccount) []byte {
t.Helper()
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
return raw
}
// mintingVault is a vault with a state of bus accounts, ace's sealing key stated, ace's modules assigned, and minting
// turned on or off as on says.
func mintingVault(t *testing.T, on bool) (testVault, *fakeAccounts, string) {
t.Helper()
plans := assignments{"ace.mod-x": `{"node":"ace","module":"mod-x"}`, "ace.mod-y": `{"node":"ace","module":"mod-y"}`}
v := newVault(t, nil)
v.Askers = Declared{Read: plans.read, Store: v.Store}
accounts := &fakeAccounts{}
v.Accounts = accounts
pub, priv := aSealingKey(t)
if err := v.recordNodeKey("ace", pub, "node.ace"); err != nil {
t.Fatal(err)
}
if on {
v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil)
} else {
v.LeaseHeard([]byte(`{"instance":"novox/1/2026-10-27","epoch":57}`), true, nil)
}
return v, accounts, priv
}
// 774/AC7 (novox/hq ADR 0561 §1, §2, §4): a want with no value, minting on, is minted once: kept whole in the controller's
// shape as the module's broker, its hash in bus-accounts, said account-issued before secret-changed, and answered by own
// sealed to the node; a value held or a hash adopted is never minted.
func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) {
v, accounts, nodePriv := mintingVault(t, true)
w := aWant("mod-x")
before := len(v.bus.events)
v.WantHeard(w.User, wantRaw(t, w), false)
rec, held, err := v.Store.Read("own/ace/mod-x/broker")
if err != nil || !held || rec.Generation != 1 || rec.Origin != OriginMade {
t.Fatalf("the minted account is kept as %+v (held %v, %v)", rec, held, err)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
t.Fatal(err)
}
var got struct{ Password string }
_ = json.Unmarshal([]byte(value), &got)
shape, _ := buscompose.HeldCredential(w, got.Password)
if len(got.Password) != 40 || value != string(shape) {
t.Errorf("the minted value is %q, not the controller's shape %q", value, shape)
}
if !accounts.hashOf(w.User, got.Password, 1) {
t.Errorf("%s's hash is not the minted password's: %+v", w.User, accounts.keys[w.User])
}
var order []string
for _, e := range v.bus.events[before:] {
order = append(order, e["event"].(string))
}
if strings.Join(order, ",") != AccountHashedEvent+","+AccountIssuedEvent+","+ChangedEvent {
t.Errorf("said %v, want the hash, account-issued, then secret-changed", order)
}
for _, raw := range v.bus.raw[before:] {
if strings.Contains(raw, got.Password) || strings.Contains(raw, "$2a$") {
t.Errorf("a value or a hash was said: %s", raw)
}
}
own, err := v.OwnOfNode("node.ace")
if err != nil || len(own.Own) != 1 {
t.Fatalf("own answered %+v, %v", own, err)
}
if opened, err := seal.Open(own.Own[0].Sealed, nodePriv); err != nil || opened != value {
t.Errorf("own's sealed value opens to %q, %v", opened, err)
}
// Heard again, or for a value held, or for a hash adopted: nothing is minted, nothing is held back.
v.WantHeard(w.User, wantRaw(t, w), false)
if rec, _, _ := v.Store.Read("own/ace/mod-x/broker"); rec.Generation != 1 {
t.Errorf("a want heard again minted generation %d", rec.Generation)
}
accounts.keys["ace.mod-y"] = AccountHash{User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}
y := aWant("mod-y")
v.WantHeard(y.User, wantRaw(t, y), false)
if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held || accounts.keys["ace.mod-y"].Hash != "$2a$04$adopted" {
t.Errorf("an adopted user was minted")
}
if st := v.MintState(); len(st.HeldBack) != 0 || len(st.Minted) != 1 {
t.Errorf("vault_status says %+v: an adopted user is not held back, one account minted", st)
}
}
// 774/AC8 (novox/hq ADR 0561 §3): while the controller holds the lease nothing is minted and vault_status says why and
// names each wanted account held back (never an adopted one); the write that hands the lease away mints them; a lease
// handed back, expired or unreadable turns minting off again, read afresh before every mint.
func TestNothingIsMintedUntilTheLeaseIsHandedAway(t *testing.T) {
v, accounts, _ := mintingVault(t, false)
accounts.keys = map[string]AccountHash{"ace.mod-y": {User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}}
for _, m := range []string{"mod-x", "mod-y"} {
w := aWant(m)
v.WantHeard(w.User, wantRaw(t, w), false)
}
st := v.MintState()
if st.On || !strings.Contains(st.Why, "controller holds the lease") || len(st.HeldBack) != 1 ||
!strings.Contains(st.HeldBack["ace.mod-x"], "controller holds the lease") {
t.Fatalf("under the controller's lease vault_status says %+v", st)
}
if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); held {
t.Fatal("minted while the controller holds the lease")
}
// Handed away: the wanted account is minted at that write, the adopted one is not.
lease := []byte(`{"handed-to":"the operator's hand-over"}`)
v.LeaseRead = func() ([]byte, bool, error) { return lease, true, nil }
v.LeaseHeard(lease, true, nil)
if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); !held {
t.Fatalf("not minted at the lease write: %+v", v.MintState())
}
if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held {
t.Error("an adopted account was minted at the lease write")
}
if st := v.MintState(); !st.On || len(st.HeldBack) != 0 {
t.Errorf("after the lease write vault_status says %+v", st)
}
// The key expired (no watch hears a key's age pass): read afresh before the next mint, nothing is minted.
v.LeaseRead = func() ([]byte, bool, error) { return nil, false, nil }
z := aWant("mod-z")
v.WantHeard(z.User, wantRaw(t, z), false)
if _, held, _ := v.Store.Read("own/ace/mod-z/broker"); held {
t.Error("minted after the lease's key expired")
}
if st := v.MintState(); st.On || !strings.Contains(st.HeldBack["ace.mod-z"], "no lease is written") {
t.Errorf("after the key expired vault_status says %+v", st)
}
}
// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh; it is refused while
// minting is off, for a node's account and the controller's, and for a user nobody wants; a user held already is said so.
func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) {
v, _, _ := mintingVault(t, false)
w := aWant("mod-x")
raw := wantRaw(t, w)
v.WantRead = func(user string) ([]byte, bool, error) {
if user == w.User {
return raw, true, nil
}
return nil, false, nil
}
for user, says := range map[string]string{
"ace.mod-x": "controller holds the lease",
"node.ace": "never minted",
"controller": "never minted",
} {
if _, err := v.IssueAccount("person.jochen", user); err == nil || !strings.Contains(err.Error(), says) {
t.Errorf("%s: %v, want a refusal saying %q", user, err, says)
}
}
v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil)
if _, err := v.IssueAccount("person.jochen", "ace.nobody"); err == nil || !strings.Contains(err.Error(), "no wanted account") {
t.Errorf("a user nobody wants: %v", err)
}
got, err := v.IssueAccount("person.jochen", w.User)
if err != nil || got.(map[string]any)["minted"] != true {
t.Fatalf("issue-account: %v, %v", got, err)
}
if again, err := v.IssueAccount("person.jochen", w.User); err != nil || again.(map[string]any)["minted"] != false ||
!strings.Contains(again.(map[string]any)["why"].(string), "held already") {
t.Errorf("issue-account again: %v, %v", again, err)
}
}
// 774/AC10 the rotation's rule (novox/hq ADR 0561 §4): at most one rotation begins per revision of the loaded user list,
// over the second login `<user>--b`, never of a node's account, the controller's or an adopted hash, and `rotate` of a
// covered account stays refused, so nothing rotates in place.
func TestOneRotationBeginsPerRevisionOfTheLoadedList(t *testing.T) {
v, accounts, _ := mintingVault(t, true)
for _, m := range []string{"mod-x", "mod-y"} {
w := aWant(m)
v.WantHeard(w.User, wantRaw(t, w), false)
}
if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "not known yet") {
t.Errorf("began with no loaded revision known: %v", err)
}
v.LoadedListHeard(570)
r, err := v.BeginRotation("ace.mod-x")
if err != nil || r.Login != "ace.mod-x--b" || r.Revision != 570 || r.Generation != 2 || accounts.keys["ace.mod-x"].Rotating == nil {
t.Fatalf("the first rotation: %+v, %v", r, err)
}
if _, err := v.BeginRotation("ace.mod-y"); err == nil || !strings.Contains(err.Error(), "one rotation begins per revision") {
t.Errorf("a second rotation in one revision: %v", err)
}
v.LoadedListHeard(571)
if _, err := v.BeginRotation("ace.mod-y"); err != nil {
t.Errorf("the next revision's rotation: %v", err)
}
if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "rotating already") {
t.Errorf("an account rotating began again: %v", err)
}
accounts.keys["node.ace"] = AccountHash{User: "node.ace", Hash: "$2a$04$n", Adopted: true}
for _, u := range []string{"node.ace", "controller"} {
if _, err := v.BeginRotation(u); err == nil {
t.Errorf("%s began a rotation", u)
}
}
if _, err := v.RotateFor(ControllerCaller, "own/ace/mod-x/broker", Narrow{}, "the test", "a rotation in place"); err == nil {
t.Error("a covered account rotated in place")
}
}
// A mint that failed (here: no operator key yet, so nothing can be kept) is tried again at the pass, never lost.
func TestAFailedMintIsTriedAgainAtThePass(t *testing.T) {
v, accounts, _ := mintingVault(t, true)
key, _ := v.Store.OperatorKey()
if err := v.Store.setOperatorKey("", "the test", v.Now()); err != nil {
t.Fatal(err)
}
w := aWant("mod-x")
v.WantHeard(w.User, wantRaw(t, w), false)
if _, ok := accounts.keys[w.User]; ok {
t.Fatal("minted with no operator key")
}
if err := v.Store.setOperatorKey(key, "the test", v.Now()); err != nil {
t.Fatal(err)
}
v.MintOwed()
if _, ok := accounts.keys[w.User]; !ok {
t.Errorf("not minted at the pass after the failure: %+v", v.MintState())
}
}
@@ -14,8 +14,6 @@ import (
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
) )
@@ -321,139 +319,3 @@ func TestMoveInThroughTheTool(t *testing.T) {
t.Fatalf("asks through the tool: %+v, %v", moved, err) t.Fatalf("asks through the tool: %+v, %v", moved, err)
} }
} }
// Issue 1126, cause C4: a machine's node-engine hands the vault what runs of its own machine's own secret, where the
// vault holds another. Refused for another machine's secret, one that is not an own secret or not held, a person's or a
// provider's software's value, an empty value, and one the vault changed in the last 15 minutes; the same value changes
// nothing. A replacement keeps the old value as the previous generation, raises the generation, and says own-replaced
// with both generations and never a value; own_drift lists it from the node's shadow answer.
func TestANodeEngineHandsTheVaultWhatRunsOnItsOwnMachine(t *testing.T) {
v := newVault(t, nil)
tick, later := v.Now, time.Duration(0)
v.Now = func() time.Time { return tick().Add(later) }
old, running := "the-value-moved-in-first", "the-value-the-controller-made-at-start"
if _, err := moveIn(t, v, "node.anchor", superuser(), old); err != nil {
t.Fatal(err)
}
for _, origin := range []string{"accepted", "issued"} {
m := superuser()
m.Name, m.Origin = "from-"+origin, origin
if _, err := moveIn(t, v, "node.anchor", m, "value-"+origin); err != nil {
t.Fatal(err)
}
}
later = time.Hour
// Through move-in's tool as the node-engine asks it: replace_running in the first step, the second known by its call.
moveInTool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".move-in")
take := func(caller string, of OwnTakeOf, value string) (OwnReplaced, error) {
got, err := moveInTool.runAs(caller, map[string]any{ReplaceRunningArg: true, "node": of.Node, "module": of.Module,
"name": of.Name, "secret": of.Secret, "why": of.Why})
if err != nil {
return OwnReplaced{}, err
}
opened := got.(Opened)
got, err = moveInTool.runAs(caller, map[string]any{"call": opened.Call, "sealed": sealToCall(t, opened.SealTo, value)})
if err != nil {
return OwnReplaced{}, err
}
return got.(OwnReplaced), nil
}
pg := OwnTakeOf{Module: "postgres", Name: "superuser-password", Why: "the shadow answer says another value"}
for name, row := range map[string]struct {
caller string
of OwnTakeOf
value string
says string
}{
"another machine's, named": {"node.shanks", OwnTakeOf{Node: "anchor", Module: "postgres", Name: "superuser-password"}, running, "anchor's"},
"another machine's, whole name": {"node.shanks", OwnTakeOf{Secret: "own/anchor/postgres/superuser-password"}, running, "anchor's"},
"not a node-engine": {ControllerCaller, pg, running, "not a node-engine"},
"a module's tools": {"anchor.node-tools", pg, running, "not a node-engine"},
"not an own secret": {"node.anchor", OwnTakeOf{Secret: "pair/anchor/database/shanks/gitea"}, running, "not a module's own secret"},
"not held": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "never-made"}, running, "nothing to replace"},
"a person's value": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "from-accepted"}, running, "by a person"},
"a provider's software's value": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "from-issued"}, running, "issued by its provider"},
"an empty value": {"node.anchor", pg, " ", "empty"},
} {
if _, err := take(row.caller, row.of, row.value); err == nil || !strings.Contains(err.Error(), row.says) {
t.Errorf("%s: %v, want refused saying %q", name, err, row.says)
}
}
if rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password"); rec.Generation != 1 || rec.Previous != nil {
t.Fatalf("a refusal changed the secret: generation %d, previous %+v", rec.Generation, rec.Previous)
}
// The value the vault holds already changes nothing, and says so.
if same, err := take("node.anchor", pg, old); err != nil || !same.Unchanged || same.To != 1 {
t.Errorf("the same value: %+v %v", same, err)
}
// What runs replaces it: one generation more, the old kept as the previous, origin kept, its fingerprint answered.
got, err := take("node.anchor", pg, running)
if err != nil {
t.Fatal(err)
}
rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password")
if got.From != 1 || got.To != 2 || got.Unchanged || got.Fingerprint != Fingerprint(running) || rec.Generation != 2 ||
rec.Origin != OriginMade || rec.ChangedBy != "node.anchor" || rec.Previous == nil || rec.Previous.Generation != 1 {
t.Fatalf("replaced %+v, the record at generation %d origin %q by %s, previous %+v", got, rec.Generation, rec.Origin,
rec.ChangedBy, rec.Previous)
}
if now, _ := seal.Open(rec.AtRest, v.Own.PrivateKey); now != running {
t.Error("the vault does not hold what runs")
}
if was, _ := seal.Open(rec.Previous.AtRest, v.Own.PrivateKey); was != old ||
openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Previous.Operator.Sealed) != old {
t.Error("the previous value is not kept, for the vault and the operator")
}
// Said once, with both generations and why, never a value.
var replaced []map[string]any
for _, e := range v.bus.events {
if e["event"] == OwnReplacedEvent {
replaced = append(replaced, e["body"].(map[string]any))
}
}
if len(replaced) != 1 || replaced[0]["secret"] != "own/anchor/postgres/superuser-password" ||
replaced[0]["from_generation"] != 1 || replaced[0]["to_generation"] != 2 || replaced[0]["node"] != "anchor" ||
replaced[0]["why"] != pg.Why {
t.Errorf("own-replaced said %v", replaced)
}
if all := everything(t, v, got); strings.Contains(all, running) || strings.Contains(all, old) {
t.Fatal("a value is in something the vault said, answered or wrote in the clear")
}
// Taken again at once, over the value the vault just took: refused.
if _, err := take("node.anchor", pg, "a-value-older-than-the-vaults"); err == nil || !strings.Contains(err.Error(), "less than") {
t.Errorf("a running value over one the vault just took: %v", err)
}
// Switched off by a person, every ask is refused, saying who and why; on again, taken again.
later += time.Hour
if _, err := v.SwitchOwnTake("jochen", false, "the test"); err != nil {
t.Fatal(err)
}
if _, err := take("node.anchor", pg, "after-the-switch"); err == nil || !strings.Contains(err.Error(), "switched off (by jochen") {
t.Errorf("an ask while switched off: %v", err)
}
if _, err := v.SwitchOwnTake("jochen", true, "the test"); err != nil {
t.Fatal(err)
}
later -= time.Hour
// own_drift reads it from the node's latest shadow answer, mesh-wide; the id as a node runs it, its module's name
// first (captured from novox's shadow answer, 2026-10-28: only-office.needs-jwt).
answer := delivery.CandidateAnswer{Head: "h", Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{
Module: "postgres", Why: "the resource differs", Bodies: []delivery.ResourceBodies{{ID: "postgres.needs-superuser-password",
Composed: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`, Running: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`}}}}}
have, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": answer},
Composes: delivery.ComposesController})
if err != nil {
t.Fatal(err)
}
v.HaveKeys = func() ([]string, error) { return []string{"anchor.node", "anchor.postgres"}, nil }
v.Haves = func(key string) ([]byte, error) { return have, nil }
drift, err := v.OwnDrift()
if err != nil {
t.Fatal(err)
}
if len(drift.Secrets) != 1 || drift.Secrets[0].Secret != "own/anchor/postgres/superuser-password" ||
drift.Secrets[0].Generation != 2 || drift.Secrets[0].Previous != 1 || drift.Secrets[0].Takeable ||
len(drift.Nodes) != 1 || drift.Nodes[0].ValuesDiffer["g"] != 1 || drift.Nodes[0].Composes != delivery.ComposesController {
t.Errorf("own_drift: %+v", drift)
}
}
+19 -81
View File
@@ -70,7 +70,7 @@ func credentialNames(c CredentialAsked) []string {
// credentialRefusal says why node may not have c, or "": a consumer's credential to the machine its consuming module // credentialRefusal says why node may not have c, or "": a consumer's credential to the machine its consuming module
// stands on, a provider's to the providing machine, and nothing to a machine that is neither. // stands on, a provider's to the providing machine, and nothing to a machine that is neither.
func (v *Vault) credentialRefusal(node string, c CredentialAsked, plan *planReader) string { func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
for _, part := range []string{c.Provision, c.Consumer, c.ConsumerModule, c.Provider} { for _, part := range []string{c.Provision, c.Consumer, c.ConsumerModule, c.Provider} {
if !namePart.MatchString(part) { if !namePart.MatchString(part) {
return fmt.Sprintf("%q is not a name the mesh writes", part) return fmt.Sprintf("%q is not a name the mesh writes", part)
@@ -84,22 +84,14 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked, plan *planRead
if c.Consumer != node { if c.Consumer != node {
return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer) return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer)
} }
// The consuming module stands on the machine: read, never assumed (an assignment that cannot be read refuses). // The consuming module stands on the machine: read from the placement and the assignments, never assumed (one
raw, why, err := plan.read(c.ConsumerModule) // that cannot be read refuses).
if why != "" { stands, err := v.standsOn(node, c.ConsumerModule)
return why
}
if err != nil { if err != nil {
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err) return fmt.Sprintf(notYetPrefix+"where %s stands could not be read: %v", c.ConsumerModule, err)
} }
if raw == nil { if !stands {
// Confirmed directly before refusing: the mirror may not hold a module assigned a moment ago. return fmt.Sprintf("%s is not on %s: neither placed nor assigned there, nor required by a module there", c.ConsumerModule, node)
if raw, err = plan.confirmed(c.ConsumerModule); err != nil {
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
}
}
if raw == nil {
return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node)
} }
return "" return ""
case "provider": case "provider":
@@ -111,56 +103,6 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked, plan *planRead
return fmt.Sprintf("the role %q is not consumer or provider", c.Role) return fmt.Sprintf("the role %q is not consumer or provider", c.Role)
} }
// planReader reads the assignments of one machine's modules for one answer of `own`, each module's once (issue 1126,
// 2026-10-28: one read per own secret and per credential, 203 on novox, held the answer past the node-engine's bound).
type planReader struct {
node string
reader AssignmentReader
confirm AssignmentReader
why string
got map[string]planRead
}
type planRead struct {
raw []byte
err error
}
func (v *Vault) plansOf(node string) *planReader {
read, why := v.assignmentReader(node)
var confirm AssignmentReader
if d, ok := v.Askers.(Declared); ok && d.Confirm != nil {
confirm = standing(d.Confirm)
}
return &planReader{node: node, confirm: confirm, reader: read, why: why, got: map[string]planRead{}}
}
// read is module's assignment on the machine, nil when there is none, or why the vault reads none.
func (p *planReader) read(module string) ([]byte, string, error) {
if p.why != "" {
return nil, p.why, nil
}
r, ok := p.got[module]
if !ok {
r.raw, r.err = p.reader(p.node + "." + module)
p.got[module] = r
}
return r.raw, "", r.err
}
// confirmed is module's assignment read directly, for one the reader does not hold (a watched mirror can be behind),
// nil when there is none or nothing reads directly.
func (p *planReader) confirmed(module string) ([]byte, error) {
if p.confirm == nil {
return nil, nil
}
raw, err := p.confirm(p.node + "." + module)
if err == nil && raw != nil {
p.got[module] = planRead{raw: raw}
}
return raw, err
}
// assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them, // assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them,
// and without them it refuses rather than hands (a rule that cannot be read is no rule). // and without them it refuses rather than hands (a rule that cannot be read is no rule).
func (v *Vault) assignmentReader(node string) (AssignmentReader, string) { func (v *Vault) assignmentReader(node string) (AssignmentReader, string) {
@@ -232,21 +174,17 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if err != nil { if err != nil {
return OwnAnswer{}, fmt.Errorf("%s's sealing key as recorded is not usable: %w", node, err) return OwnAnswer{}, fmt.Errorf("%s's sealing key as recorded is not usable: %w", node, err)
} }
// Only this machine's own records are read, each module's assignment once, and every hand-out recorded in one synced all, err := v.Store.List()
// write after all are sealed: none is handed when that record cannot be written (issue 1126, 2026-10-28).
all, err := v.Store.ListPrefix("own/" + node + "/")
if err != nil { if err != nil {
return OwnAnswer{}, err return OwnAnswer{}, err
} }
plan := v.plansOf(node)
var handings []Handing
out := OwnAnswer{Node: node, Key: key} out := OwnAnswer{Node: node, Key: key}
for _, r := range all { for _, r := range all {
parts := strings.Split(r.Name, "/") parts := strings.Split(r.Name, "/")
if len(parts) != 4 || parts[0] != "own" || parts[1] != node { if len(parts) != 4 || parts[0] != "own" || parts[1] != node {
continue continue
} }
handed, err := plan.handed(parts[2]) handed, err := v.handed(node, parts[2])
if err != nil { if err != nil {
return OwnAnswer{}, err return OwnAnswer{}, err
} }
@@ -261,7 +199,9 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if err != nil { if err != nil {
return OwnAnswer{}, err return OwnAnswer{}, err
} }
handings = append(handings, Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"}) if err := v.recordHanding(Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"}); err != nil {
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", r.Name, err)
}
out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin), out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin),
Generation: r.Generation, Sealed: sealed}) Generation: r.Generation, Sealed: sealed})
} }
@@ -272,7 +212,7 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
return out.Own[i].Name < out.Own[j].Name return out.Own[i].Name < out.Own[j].Name
}) })
for _, c := range asked { for _, c := range asked {
if why := v.credentialRefusal(node, c, plan); why != "" { if why := v.credentialRefusal(node, c); why != "" {
return OwnAnswer{}, fmt.Errorf("%s may not ask for the credential of %s from %s to %s on %s: %s", caller, return OwnAnswer{}, fmt.Errorf("%s may not ask for the credential of %s from %s to %s on %s: %s", caller,
c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why) c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why)
} }
@@ -305,7 +245,9 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if err != nil { if err != nil {
return OwnAnswer{}, err return OwnAnswer{}, err
} }
handings = append(handings, Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"}) if err := v.recordHanding(Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"}); err != nil {
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
}
out.Credentials = append(out.Credentials, NodeCredential{CredentialAsked: c, Name: name, Generation: r.Generation, Sealed: sealed}) out.Credentials = append(out.Credentials, NodeCredential{CredentialAsked: c, Name: name, Generation: r.Generation, Sealed: sealed})
found = true found = true
break break
@@ -314,10 +256,6 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
out.Missing = append(out.Missing, c) out.Missing = append(out.Missing, c)
} }
} }
if err := v.recordHandings(handings); err != nil {
return OwnAnswer{}, fmt.Errorf("nothing is handed out to %s: who was handed its %d values could not be recorded: %w",
caller, len(handings), err)
}
v.log("handed %s's node-engine the %d own secrets of its modules and %d credentials (%d not held), sealed to its key", v.log("handed %s's node-engine the %d own secrets of its modules and %d credentials (%d not held), sealed to its key",
node, len(out.Own), len(out.Credentials), len(out.Missing)) node, len(out.Own), len(out.Credentials), len(out.Missing))
return out, nil return out, nil
@@ -326,12 +264,12 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
// handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed. // handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed.
// A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner // A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner
// holds. // holds.
func (p *planReader) handed(module string) (bool, error) { func (v *Vault) handed(node, module string) (bool, error) {
node := p.node read, why := v.assignmentReader(node)
raw, why, err := p.read(module)
if why != "" { if why != "" {
return false, errors.New(why) return false, errors.New(why)
} }
raw, err := read(node + "." + module)
if err != nil { if err != nil {
return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+ return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+
"provisioner is not known: %w", node, module, err) "provisioner is not known: %w", node, module, err)

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