Compare commits

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

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

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 16:32:28 +01:00
817 changed files with 8007 additions and 48142 deletions
-3
View File
@@ -7,8 +7,6 @@ modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
modules/messenger/cmd/messenger/messenger
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
modules/mesh-conditions/mesh-conditions
modules/minio/object-store
modules/claude-code/cmd/claude-code/claude-code
modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
@@ -36,7 +34,6 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
# binaries a local go build leaves in a module root
/checks/paths/paths
/checks/environment/environment
/modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate
modules/mesh-config/cmd/mesh-config/mesh-config
-26
View File
@@ -10,9 +10,6 @@
// (`mesh-controller.<name>`, `seat:mesh-controller.<verb>`, `mesh-controller_<bucket>`), anywhere in the manifest;
// - a manifest string that is the bare module name `mesh-controller` outside the module's own `config`, where a value
// is the module's data (an issue owner naming the controller's repository is no dependency), not an address;
// - a go.mod of a module or of a check requiring the controller's repository (its module path, github.com/novox/mesh-controller,
// or the forge's git.novox.be/novox/mesh-controller): the repository is archived once its last module is delivered
// elsewhere (novox/hq ADR 0593 §11), so nothing here may be built from it;
// - a line of any module's file naming one of the controller's buckets (`mesh-controller_`): its buckets are removed
// after the controller's end (ADR 0590 §6), so a reader of one reads nothing. A test (`_test.go`, `.test.ts`) reads
// no live bucket, its names are its own data, and a `testdata` directory is a copy of text from elsewhere (a design,
@@ -43,9 +40,6 @@ const (
// address is a string naming the controller's seat, one of its states, verbs or events, or one of its buckets.
var address = regexp.MustCompile(`(^|[^A-Za-z0-9_-])(seat:)?mesh-controller([._][A-Za-z0-9])`)
// repository is a go.mod line naming the controller's repository by either of its module paths.
var repository = regexp.MustCompile(`(github\.com|git\.novox\.be)/novox/mesh-controller(/|\s|$)`)
// Refusals is each reason the check gives over the tree at root, sorted; none when it passes.
func Refusals(root string) ([]string, error) {
var out []string
@@ -71,26 +65,6 @@ func Refusals(root string) ([]string, error) {
"consumes, waits on or uses it; name the seat that holds the job now (novox/hq ADR 0590 §5)", module, s))
}
}
mods, err := filepath.Glob(filepath.Join(root, "*", "*", "go.mod"))
if err != nil {
return nil, err
}
sort.Strings(mods)
for _, f := range mods {
if rel, _ := filepath.Rel(root, f); !strings.HasPrefix(rel, "modules"+string(filepath.Separator)) && !strings.HasPrefix(rel, "checks"+string(filepath.Separator)) {
continue
}
raw, err := os.ReadFile(f)
if err != nil {
return nil, err
}
for n, l := range strings.Split(string(raw), "\n") {
if repository.MatchString(l) {
rel, _ := filepath.Rel(root, f)
out = append(out, fmt.Sprintf("%s:%d: requires the controller's repository: it is archived once its last module is built elsewhere, so nothing here is built from it (novox/hq ADR 0593 §11)", filepath.ToSlash(rel), n+1))
}
}
}
buckets, err := bucketLines(root)
if err != nil {
return nil, err
@@ -33,9 +33,6 @@ func TestRefusalsJudgeATree(t *testing.T) {
{"a requirement", map[string]string{"modules/d/module.json": `{"module":"d","requires":[{"name":"x","from":"mesh-controller"}]}`}, `d: names "mesh-controller"`},
{"the controller's own module", map[string]string{"modules/mesh-controller/module.json": `{"module":"mesh-controller"}`}, "mesh-controller: the controller is no module of the catalogue"},
{"a bucket in code", map[string]string{"modules/e/cmd/e/main.go": "package main\nconst b = \"mesh-controller_assignments\"\n"}, "modules/e/cmd/e/main.go:2: names the controller's bucket"},
{"a requirement of the controller's repository", map[string]string{"modules/h/go.mod": "module h\n\nrequire github.com/novox/mesh-controller v0.0.0\n"}, "modules/h/go.mod:3: requires the controller's repository"},
{"its forge path in a check", map[string]string{"checks/i/go.mod": "module i\n\nrequire (\n\tgit.novox.be/novox/mesh-controller/modules/route-proxy v0.1.0\n)\n"}, "checks/i/go.mod:4"},
{"another repository of the forge passes", map[string]string{"modules/j/go.mod": "module j\n\nrequire git.novox.be/novox/mesh-sdk/go v0.1.0\n"}, ""},
{"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""},
{"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""},
{"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""},
+1 -3
View File
@@ -2,6 +2,4 @@ module checks/credentialform
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -4
View File
@@ -1,4 +1,2 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+2 -13
View File
@@ -111,24 +111,13 @@ func forgeRequirements(dir string) ([]requirement, error) {
if err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
}
var mod struct {
Require []requirement
Replace []struct{ Old, New requirement }
}
var mod struct{ Require []requirement }
if err := json.Unmarshal(out, &mod); err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
}
// A requirement the go.mod replaces to a directory is a package of the module's own repository (novox/hq ADR 0593 §1): it
// is in the tree under check, so it is fetched from nowhere.
local := map[string]bool{}
for _, r := range mod.Replace {
if r.New.Version == "" {
local[r.Old.Path] = true
}
}
var on []requirement
for _, r := range mod.Require {
if strings.HasPrefix(r.Path, forge) && !local[r.Path] {
if strings.HasPrefix(r.Path, forge) {
on = append(on, r)
}
}
-25
View File
@@ -175,28 +175,3 @@ func TestTheRefOfAVersion(t *testing.T) {
}
}
}
// ADR 0593 §1: a module requires a sibling package of its own repository and replaces it to the directory beside it; that
// package is no fetch from the forge (it is in the tree under check), so it is never looked for in a clone beside the check,
// and the module builds with the SDK taken from there.
func TestASiblingPackageReplacedToItsDirectoryIsNotFetched(t *testing.T) {
offline(t)
beside, commit := sdkBeside(t)
version := "v0.1.14-0.20261012082113-" + commit[:12]
root := t.TempDir()
sibling := "git.novox.be/novox/mesh-catalog/modules/builder/runner"
write(t, filepath.Join(root, "builder", "runner", "go.mod"), "module "+sibling+"\n\ngo 1.22\n\nrequire git.novox.be/novox/mesh-sdk/go "+version+"\n")
write(t, filepath.Join(root, "builder", "runner", "run.go"), "package runner\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\nfunc Run() string { return sdk.Hello() }\n")
dir := filepath.Join(root, "user")
write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/user\n\ngo 1.22\n\nrequire (\n\t"+sibling+" v0.0.0\n"+
"\tgit.novox.be/novox/mesh-sdk/go "+version+"\n)\n\nreplace "+sibling+" => ../builder/runner\n")
write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \""+sibling+"\"\n\nfunc main() { println(runner.Run()) }\n")
modfile, err := resolve(dir, beside, t.TempDir())
if err != nil {
t.Fatalf("resolve: %v", err)
}
if modfile == "" {
t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch the SDK from")
}
builds(t, dir, modfile)
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/environment
go 1.22
-252
View File
@@ -1,252 +0,0 @@
// Command environment refuses a module's code reading a MESH_* word of its environment that nothing gives it: not its
// manifest, and not the node-engine or the tool runner for every process (the lead's ruling on A3n, issue 1966).
//
// A MESH_* word is the mesh's contract with a module's process: the manifest sets it (an `env` of a build artifact or a
// resource, an `environment`'s `variables`, a `NAME=` line of a file the module ships for an env-file), or the mesh gives
// it to every process (Given). A word read and set by nothing is a contract nobody keeps: the program runs on its
// default or on nothing, and says so to nobody (MESH_REGISTRY_REACH answered every published reference without its
// registry; MESH_CONFIG_CALLERS held every window shut). Found here, it is refused: the code reads what the mesh gives
// (a manifest word, a config value through its tools, a seat's name), or the manifest sets it.
//
// A read is `os.Getenv` or `os.LookupEnv` of a literal or of a constant of the same directory (Go), and `env.MESH_*` or
// `env["MESH_*"]` (TypeScript, `process.env` included); tests, test directories, node_modules and dist are not read. The
// modules still reading such a word are listed in the file `unset` beside this check, `<module> <WORD>` a line, until
// each is fixed: a line no longer found is refused, so the list only shrinks.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Given is every MESH_* word the mesh gives a module's processes whatever their manifest says, and who gives it.
var Given = map[string]string{
// The tool runner, to every bundle and to a module's own runtime (mesh-tools node-tools runtime.BundleEnv).
"MESH_MODULE": "the tool runner",
"MESH_SERVED_MODULE": "the tool runner",
"MESH_NODE": "the tool runner",
// The node-engine, to the tool runner it runs as the operator's account; its bundles inherit them (mesh-host
// compose/resolution runtime.go).
"MESH_OPERATOR_ACCOUNT": "the node-engine",
"MESH_OPERATOR_HOME": "the node-engine",
// The node-engine, to every module that yields to the node's builds (ADR 0553 §3; catalogue.BuildCPUShareWord).
"MESH_BUILD_CPU_SHARE": "the node-engine",
}
const listFile = "unset"
var (
goRead = regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\s*(?:"(MESH_[A-Z0-9_]+)"|([A-Za-z_][A-Za-z0-9_]*))\s*\)`)
goConst = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:string\s*)?=\s*"(MESH_[A-Z0-9_]+)"`)
tsRead = regexp.MustCompile(`\benv(?:\.|\[\s*["'])(MESH_[A-Z0-9_]+)`)
envLine = regexp.MustCompile(`(?m)^\s*(?:export\s+)?(MESH_[A-Z0-9_]+)=`)
)
// skipped are the directories whose code no process of the module runs.
var skipped = map[string]bool{"node_modules": true, "dist": true, "test": true, "testdata": true, ".git": true}
// Check answers, per module, each MESH_* word its code reads that its manifest does not set and the mesh does not
// give, with the files reading it.
func Check(root string) (map[string]map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
out := map[string]map[string][]string{}
for _, path := range manifests {
dir := filepath.Dir(path)
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var manifest any
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
set := map[string]bool{}
setBy(manifest, set)
reads := map[string]map[string]bool{}
consts := map[string]map[string]string{} // directory -> constant -> word
var goFiles []string
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if p != dir && skipped[d.Name()] {
return filepath.SkipDir
}
return nil
}
name := d.Name()
text, err := os.ReadFile(p)
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, p)
rel = filepath.ToSlash(rel)
switch {
case strings.HasSuffix(name, "_test.go"), strings.HasSuffix(name, ".test.ts"), strings.HasSuffix(name, ".d.ts"):
case strings.HasSuffix(name, ".go"):
goFiles = append(goFiles, p)
c := consts[filepath.Dir(p)]
if c == nil {
c = map[string]string{}
consts[filepath.Dir(p)] = c
}
for _, m := range goConst.FindAllStringSubmatch(string(text), -1) {
c[m[1]] = m[2]
}
case strings.HasSuffix(name, ".ts"):
for _, m := range tsRead.FindAllStringSubmatch(string(text), -1) {
read(reads, m[1], rel)
}
case name != "module.json":
// A file the module ships: a NAME= line is a word an env-file gives.
for _, m := range envLine.FindAllStringSubmatch(string(text), -1) {
set[m[1]] = true
}
}
return nil
})
if err != nil {
return nil, err
}
for _, p := range goFiles {
text, _ := os.ReadFile(p)
rel, _ := filepath.Rel(dir, p)
for _, m := range goRead.FindAllStringSubmatch(string(text), -1) {
word := m[1]
if word == "" {
word = consts[filepath.Dir(p)][m[2]]
}
if word != "" {
read(reads, word, filepath.ToSlash(rel))
}
}
}
module := filepath.Base(dir)
for word, files := range reads {
if set[word] || Given[word] != "" {
continue
}
if out[module] == nil {
out[module] = map[string][]string{}
}
for f := range files {
out[module][word] = append(out[module][word], f)
}
sort.Strings(out[module][word])
}
}
return out, nil
}
func read(reads map[string]map[string]bool, word, file string) {
if reads[word] == nil {
reads[word] = map[string]bool{}
}
reads[word][file] = true
}
// setBy collects the words a manifest sets: every key of an `env` object and of an `environment`'s `variables`.
func setBy(v any, set map[string]bool) {
switch v := v.(type) {
case map[string]any:
for k, e := range v {
if m, ok := e.(map[string]any); ok && (k == "env" || k == "variables") {
for word := range m {
set[word] = true
}
}
setBy(e, set)
}
case []any:
for _, e := range v {
setBy(e, set)
}
}
}
// Listed is the list's `<module> <WORD>` lines; a line starting with # and a blank line are not entries.
func Listed(path string) (map[string]bool, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
out := map[string]bool{}
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.Join(strings.Fields(s.Text()), " ")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out, s.Err()
}
// Judge answers the reads not on the list (refused) and the list's lines no read stands for (stale).
func Judge(found map[string]map[string][]string, listed map[string]bool) (refused, stale []string) {
seen := map[string]bool{}
for module, words := range found {
for word := range words {
line := module + " " + word
seen[line] = true
if !listed[line] {
refused = append(refused, line)
}
}
}
for line := range listed {
if !seen[line] {
stale = append(stale, line)
}
}
sort.Strings(refused)
sort.Strings(stale)
return refused, stale
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
listed, err := Listed(listFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
refused, stale := Judge(found, listed)
for _, line := range refused {
module, word, _ := strings.Cut(line, " ")
fmt.Printf("%s reads %s (%s), which its manifest does not set and the mesh gives no process: set it in the "+
"manifest, or read what the mesh gives\n", module, word, strings.Join(found[module][word], ", "))
}
for _, line := range stale {
fmt.Printf("checks/environment/%s names %q, which no code reads unset any more: take it off the list\n", listFile, line)
}
if len(refused) > 0 || len(stale) > 0 {
os.Exit(1)
}
fmt.Printf("environment: every MESH_* word a module reads is set by its manifest or given by the mesh (%d still listed)\n", len(listed))
}
-88
View File
@@ -1,88 +0,0 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// firstCount is how many lines the list held when it was first written: it only shrinks.
const firstCount = 63
func module(t *testing.T, root, name, manifest string, files map[string]string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
for path, text := range map[string]string{"module.json": manifest} {
files[path] = text
}
for path, text := range files {
full := filepath.Join(dir, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
}
// A word read and set by nothing is found, in Go (a literal, a constant of the same directory) and in TypeScript; one
// the manifest sets (an env, an environment's variables, an env-file line it ships) or the mesh gives every process is
// not; tests, node_modules and dist are not read.
func TestAWordReadThatNothingSetsIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "reader", `{"module":"reader","build":{"artifacts":[{"name":"tools","env":{"MESH_SET":"x"}}]},`+
`"resources":[{"id":"p","type":"process","environment":{"variables":{"MESH_VARIABLE":"y"}}}]}`, map[string]string{
"cmd/a/main.go": `package main; const reach = "MESH_REACH"; var _ = os.Getenv(reach); var _ = os.Getenv("MESH_SET")`,
"cmd/a/other.go": `package main; var _, _ = os.LookupEnv("MESH_LITERAL"); var _ = os.Getenv("MESH_MODULE")`,
"cmd/a/write.go": `package main; var _ = []string{"MESH_WRITTEN_FOR_A_CHILD=1"}`,
"cmd/a/a_test.go": `package main; var _ = os.Getenv("MESH_IN_A_TEST")`,
"client.ts": `const a = process.env.MESH_TS ?? "d"; const b = env["MESH_BRACKET"]; const c = env.MESH_VARIABLE;`,
"files/env": "MESH_FROM_FILE=${config:x}\n",
"index.ts": `const f = env.MESH_FROM_FILE;`,
"test/x.test.ts": `env.MESH_IN_A_TEST_DIR`,
"node_modules/x.ts": `env.MESH_VENDORED`,
})
module(t, root, "quiet", `{"module":"quiet"}`, map[string]string{"main.go": `package main; var _ = os.Getenv("MESH_NODE")`})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string]map[string][]string{"reader": {
"MESH_REACH": {"cmd/a/main.go"},
"MESH_LITERAL": {"cmd/a/other.go"},
"MESH_TS": {"client.ts"},
"MESH_BRACKET": {"client.ts"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
refused, stale := Judge(got, map[string]bool{"reader MESH_REACH": true, "reader MESH_GONE": true})
if !reflect.DeepEqual(refused, []string{"reader MESH_BRACKET", "reader MESH_LITERAL", "reader MESH_TS"}) ||
!reflect.DeepEqual(stale, []string{"reader MESH_GONE"}) {
t.Errorf("refused %v, stale %v", refused, stale)
}
}
// The catalogue itself: every word a module reads is set or given, but the list's, and the list only shrinks.
func TestTheCatalogueReadsOnlyWordsTheMeshSets(t *testing.T) {
found, err := Check(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
listed, err := Listed(listFile)
if err != nil {
t.Fatal(err)
}
refused, stale := Judge(found, listed)
for _, line := range refused {
t.Errorf("%s: read, set by nothing, and not on checks/environment/unset", line)
}
for _, line := range stale {
t.Errorf("checks/environment/unset names %q, which no code reads unset any more", line)
}
if len(listed) > firstCount {
t.Errorf("checks/environment/unset holds %d lines, more than its first %d: it only shrinks", len(listed), firstCount)
}
}
-5
View File
@@ -1,5 +0,0 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and the code and files of its module.
modules
-67
View File
@@ -1,67 +0,0 @@
# The MESH_* words a module's code still reads that its manifest does not set and the mesh gives no process,
# `<module> <WORD>` a line, as checks/environment first found them (issue 1966). Each is a contract nobody keeps: fix
# it (the manifest sets it, or the code reads what the mesh gives) and take its line off. A line no code reads unset any
# more is refused, so this list only shrinks; a new read set by nothing is refused, never added here.
baserow MESH_BASEROW_EMAIL
baserow MESH_BASEROW_HOST
baserow MESH_BASEROW_PASSWORD
claude-code MESH_AGENT_ACCOUNT
claude-code MESH_AGENT_HOME
claude-licence-manager MESH_ANTHROPIC_TOKEN_ENDPOINT
claude-licence-manager MESH_ANTHROPIC_USAGE_ENDPOINT
confluence MESH_CONFLUENCE_EMAIL
confluence MESH_CONFLUENCE_TOKEN
confluence MESH_CONFLUENCE_URL
disk-load MESH_DISK_MEASURE_EVERY
gitlab MESH_GITLAB_TOKEN
gitlab MESH_GITLAB_URL
grafana MESH_GRAFANA_PASSWORD
grafana MESH_GRAFANA_TOKEN
grafana MESH_GRAFANA_USER
home-assistant MESH_HOMEASSISTANT_TOKEN
home-assistant MESH_HOMEASSISTANT_WAIT_SECONDS
home-assistant MESH_HOMEASSISTANT_WATCH
icecast MESH_ICECAST_ADMIN_PASSWORD
icecast MESH_ICECAST_ADMIN_USER
influxdb MESH_INFLUXDB_ORG
influxdb MESH_INFLUXDB_TOKEN
jira MESH_JIRA_EMAIL
jira MESH_JIRA_TOKEN
jira MESH_JIRA_URL
letta MESH_LETTA_PASSWORD
mailu MESH_MAILU_API_KEY
mailu MESH_MAILU_DOMAIN
mesh-cli MESH_CONSOLE
mesh-cli MESH_VERB
mesh-files MESH_OWNED_PATHS
mesh-files MESH_SECRET_PLACES
mesh-reads MESH_SECRET_PLACES
minio MESH_MINIO_ROOT_PASSWORD
mongodb MESH_MONGODB_AUTHSOURCE
mongodb MESH_MONGODB_HOST
mongodb MESH_MONGODB_PASSWORD
mongodb MESH_MONGODB_PORT
mongodb MESH_MONGODB_USER
mosquitto MESH_MQTT_ADMIN_USER
mosquitto MESH_MQTT_BROKER_GID
mosquitto MESH_MQTT_BROKER_UID
mosquitto MESH_MQTT_HOST
mosquitto MESH_MQTT_PASSWORD
mosquitto MESH_MQTT_PORT
mssql MESH_MSSQL_HOST
mssql MESH_MSSQL_PASSWORD
mssql MESH_MSSQL_PORT
mssql MESH_MSSQL_READER_PASSWORD
mssql MESH_MSSQL_USER
nextcloud MESH_NEXTCLOUD_ADMIN_PASSWORD
nextcloud MESH_NEXTCLOUD_CONTAINER
nodered MESH_NODERED_TOKEN
nodered MESH_NODERED_WAIT_SECONDS
redis MESH_REDIS_HOST
redis MESH_REDIS_PASSWORD
redis MESH_REDIS_PORT
redis MESH_REDIS_USERNAME
restic MESH_BACKUP_HOUR
unifi MESH_UNIFI_PASSWORD
unifi MESH_UNIFI_SITE
unifi MESH_UNIFI_USERNAME
+4 -4
View File
@@ -1,4 +1,4 @@
// Package failurekit refuses a build module whose tests do not run the build runner's conformance kit (issue 1901, its thorough
// Package failurekit refuses a build module whose tests do not run mesh-sdk's conformance kit (issue 1901, its thorough
// fix): a module that claims a node-build-* seat and serves a test step must hold a test that calls conformance.Run, so
// its parse of the test runner's output is held to the failure contract (a failing test named with its cause, never a
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
@@ -71,7 +71,7 @@ func Judge(root string) ([]Judged, error) {
}
if !runs {
refused = append(refused, fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+
"(mesh-build-go buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test))
"(mesh-sdk go/buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test))
}
}
// Every build module keeps steps, so every one runs the left-running case, test step or not (issue 1890).
@@ -81,7 +81,7 @@ func Judge(root string) ([]Judged, error) {
}
if !sweeps {
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
"(mesh-sdk go/buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
}
j.Refused = strings.Join(refused, "; ")
out = append(out, j)
@@ -121,7 +121,7 @@ func runsTheKit(dir, call string) (bool, error) {
return err
}
found = strings.Contains(string(raw), call) &&
strings.Contains(string(raw), `"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance"`)
strings.Contains(string(raw), `"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"`)
return nil
})
return found, err
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// cases is refused, and a module that claims no node-build-* seat is not judged.
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
root := t.TempDir()
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/buildstep/conformance\"\n\n"
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
for path, body := range map[string]string{
-124
View File
@@ -1,124 +0,0 @@
// Package gitignored refuses an ignore pattern that hides a module's source (catalogue review F1, and its kind): the
// bare pattern `mesh-build-node` in a module's .gitignore, meant for the binary `go build` leaves, also matched the
// directory cmd/mesh-build-node, so git ignored every new file there and publish.go was never committed. A path under
// a module's cmd/ or internal/ is source: no .gitignore, the module's or the catalogue's, may ignore one, tracked or
// new. A binary is ignored by an anchored pattern (`/mesh-build-node`, `/cmd/mesh-build-node/mesh-build-node`).
package gitignored
import (
"bytes"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// Probe is the file name asked of each source directory: a Go file a change might add there.
const Probe = "added_by_a_change.go"
// Hidden is one source path an ignore pattern hides, and the pattern (its file, line and text, as git names it).
type Hidden struct {
Path, Pattern string
}
// Sources are the source directories of root's modules, relative to root: every directory under modules/*/cmd and
// modules/*/internal (node_modules left out).
func Sources(root string) ([]string, error) {
mods, err := filepath.Glob(filepath.Join(root, "modules", "*"))
if err != nil {
return nil, err
}
var out []string
for _, m := range mods {
for _, top := range []string{"cmd", "internal"} {
base := filepath.Join(m, top)
if st, err := os.Stat(base); err != nil || !st.IsDir() {
continue
}
err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if d.Name() == "node_modules" {
return filepath.SkipDir
}
rel, _ := filepath.Rel(root, p)
out = append(out, filepath.ToSlash(rel))
return nil
})
if err != nil {
return nil, err
}
}
}
sort.Strings(out)
return out, nil
}
// isSource is whether a file is source a module's build reads: Go and TypeScript files and a Go module's own files.
// What a build or a test script leaves beside them (a binary, compiled JavaScript) may be ignored.
func isSource(name string) bool {
switch filepath.Ext(name) {
case ".go", ".ts", ".tsx":
return true
}
return name == "go.mod" || name == "go.sum"
}
// Find asks git which of the source paths root's ignore files hide: each source directory, a Go file added in it, and
// every source file there (isSource). It reads the .gitignore files of root's tree through a repository of its own, so it judges a git
// checkout and a plain folder alike, and no one's global ignores count.
func Find(root string, dirs []string) ([]Hidden, error) {
gitDir, err := os.MkdirTemp("", "gitignored-")
if err != nil {
return nil, err
}
defer os.RemoveAll(gitDir)
if out, err := exec.Command("git", "init", "-q", "--bare", gitDir).CombinedOutput(); err != nil {
return nil, fmt.Errorf("git init: %v: %s", err, out)
}
var ask bytes.Buffer
for _, d := range dirs {
ask.WriteString(d + "/\x00" + d + "/" + Probe + "\x00")
entries, err := os.ReadDir(filepath.Join(root, d))
if err != nil {
return nil, err
}
for _, e := range entries {
if !e.IsDir() && isSource(e.Name()) {
ask.WriteString(d + "/" + e.Name() + "\x00")
}
}
}
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
cmd := exec.Command("git", "--git-dir="+gitDir, "--work-tree="+abs, "-c", "core.excludesFile=/dev/null",
"check-ignore", "--no-index", "-v", "-z", "--stdin")
cmd.Dir = abs
cmd.Stdin = &ask
out, err := cmd.Output()
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
return nil, nil // nothing is ignored
}
if err != nil {
return nil, fmt.Errorf("git check-ignore: %w", err)
}
// -v -z answers source, line, pattern and path, each ended by NUL.
f := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00")
var hidden []Hidden
for i := 0; i+3 < len(f); i += 4 {
if f[i+2] == "" || strings.HasPrefix(f[i+2], "!") {
continue // a negation keeps the path
}
hidden = append(hidden, Hidden{Path: f[i+3], Pattern: fmt.Sprintf("%s:%s: %s", f[i], f[i+1], f[i+2])})
}
return hidden, nil
}
-80
View File
@@ -1,80 +0,0 @@
package gitignored
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The kind of catalogue review F1: a bare pattern hides the command directory it shares a name with; an anchored one
// hides the binary alone; a glob that ignores source is refused too; a negation keeps a path.
func TestFindNamesEachPatternThatHidesSource(t *testing.T) {
for name, c := range map[string]struct {
ignore string
hidden []string
}{
"a bare name hides cmd/<name>": {"nfs-server\n", []string{"modules/m/cmd/nfs-server/", "modules/m/cmd/nfs-server/" + Probe, "modules/m/cmd/nfs-server/main.go"}},
"anchored to the binary": {"/nfs-server\n/cmd/nfs-server/nfs-server\n", nil},
"a glob ignoring Go source": {"*.go\n!main.go\n", []string{"modules/m/cmd/" + Probe, "modules/m/cmd/nfs-server/" + Probe,
"modules/m/internal/" + Probe, "modules/m/internal/x/" + Probe, "modules/m/internal/x/x.go"}},
"a pattern over internal/": {"internal/\n", []string{"modules/m/internal/", "modules/m/internal/" + Probe, "modules/m/internal/x/",
"modules/m/internal/x/" + Probe, "modules/m/internal/x/x.go"}},
"nothing but generated JavaScript": {"*.js\n", nil},
} {
root := t.TempDir()
for f, b := range map[string]string{
"modules/m/.gitignore": c.ignore,
"modules/m/cmd/nfs-server/main.go": "package main\n",
"modules/m/cmd/nfs-server/run.js": "",
"modules/m/internal/x/x.go": "package x\n",
"modules/m/node_modules/y/index.go": "",
"modules/other/cmd/other/other.go": "package main\n",
} {
p := filepath.Join(root, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(b), 0o644); err != nil {
t.Fatal(err)
}
}
dirs, err := Sources(root)
if err != nil {
t.Fatal(err)
}
got, err := Find(root, dirs)
if err != nil {
t.Fatal(err)
}
var paths []string
for _, h := range got {
paths = append(paths, h.Path)
if !strings.HasPrefix(h.Pattern, "modules/m/.gitignore:") {
t.Errorf("%s: %s is named hidden by %q, want the module's .gitignore", name, h.Path, h.Pattern)
}
}
if strings.Join(paths, ",") != strings.Join(c.hidden, ",") {
t.Errorf("%s: hidden %v, want %v", name, paths, c.hidden)
}
}
}
// The catalogue's own tree: no ignore pattern hides a path under a module's cmd/ or internal/.
func TestNoIgnorePatternHidesAModulesSource(t *testing.T) {
root := filepath.Join("..", "..")
dirs, err := Sources(root)
if err != nil {
t.Fatal(err)
}
if len(dirs) == 0 {
t.Fatal("no module source directory found: the check reads nothing")
}
hidden, err := Find(root, dirs)
if err != nil {
t.Fatal(err)
}
for _, h := range hidden {
t.Errorf("%s is hidden by %s: a module's source is never ignored; anchor the pattern to the binary (/<name>, /cmd/<name>/<name>)", h.Path, h.Pattern)
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/gitignored
go 1.22
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/gofmt
go 1.22
-58
View File
@@ -1,58 +0,0 @@
// Package gofmt refuses a Go file of the catalogue that gofmt would change (the second of a kind in one milestone:
// buildstep's generated.go and claude-code's config_test.go reached a join unformatted). A check here holds every
// module to it, where a module's own tests hold only themselves.
package gofmt
import (
"bytes"
"go/format"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// Files are the Go files under root, relative to it: every .go file but those in .git, node_modules and a testdata
// directory (a test's fixture may be anything).
func Files(root string) ([]string, error) {
var out []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case ".git", "node_modules", "testdata":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(d.Name(), ".go") && d.Type().IsRegular() {
rel, _ := filepath.Rel(root, p)
out = append(out, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(out)
return out, err
}
// Unformatted are the files gofmt would change, and why for one that does not parse.
func Unformatted(root string, files []string) (map[string]string, error) {
out := map[string]string{}
for _, f := range files {
src, err := os.ReadFile(filepath.Join(root, f))
if err != nil {
return nil, err
}
got, err := format.Source(src)
switch {
case err != nil:
out[f] = "it does not parse: " + err.Error()
case !bytes.Equal(got, src):
out[f] = "gofmt changes it: run gofmt -w " + f
}
}
return out, nil
}
-53
View File
@@ -1,53 +0,0 @@
package gofmt
import (
"os"
"path/filepath"
"testing"
)
func TestUnformattedNamesWhatGofmtChanges(t *testing.T) {
root := t.TempDir()
for f, src := range map[string]string{
"modules/a/a.go": "package a\n",
"modules/b/b.go": "package b\nimport (\n\t\"os\"\n\t\"fmt\"\n)\nvar _, _ = fmt.Sprint, os.Exit\n",
"modules/c/testdata/c.go": "package c",
"modules/d/node_modules/d.go": "package d",
} {
p := filepath.Join(root, f)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
}
files, err := Files(root)
if err != nil {
t.Fatal(err)
}
got, err := Unformatted(root, files)
if err != nil {
t.Fatal(err)
}
if len(files) != 2 || len(got) != 1 || got["modules/b/b.go"] == "" {
t.Errorf("files %v, unformatted %v; want a.go and b.go read, b.go named", files, got)
}
}
// The catalogue's own Go files are as gofmt writes them.
func TestTheCataloguesGoIsFormatted(t *testing.T) {
root := filepath.Join("..", "..")
files, err := Files(root)
if err != nil {
t.Fatal(err)
}
if len(files) == 0 {
t.Fatal("no Go file found: the check reads nothing")
}
got, err := Unformatted(root, files)
if err != nil {
t.Fatal(err)
}
for f, why := range got {
t.Errorf("%s: %s", f, why)
}
}
+1 -3
View File
@@ -2,6 +2,4 @@ module checks/health
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -4
View File
@@ -1,4 +1,2 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+1 -3
View File
@@ -2,6 +2,4 @@ module checks/manifests
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -4
View File
@@ -1,4 +1,2 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+2 -54
View File
@@ -1,5 +1,5 @@
// Package manifests reads every module's manifest as the catalogue's tree holds it and refuses what no judge could
// read: a module.json that does not decode, a module.json giving one field twice, and two manifests that name one module.
// read: a module.json that does not decode, and two manifests that name one module.
//
// It judges no manifest (novox/hq ADR 0578 §2): the checker judges every change's manifests with the indexer's judge, the
// one judge, so the field, seat, bus and catalogue rules are said once, there. Until 2026-11 this check parsed each
@@ -9,8 +9,6 @@
package manifests
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -20,9 +18,7 @@ import (
)
// Problems is what is wrong with the tree's manifests under root/modules, one line each: a module.json whose bytes are
// no manifest (catalogue.Decode: not JSON, or a field of the wrong shape), one giving a field twice (a decoder keeps the
// last and drops the first without a word: a merge of two branches that each added the field writes it so), then a module
// named by two manifests.
// no manifest (catalogue.Decode: not JSON, or a field of the wrong shape), then a module named by two manifests.
func Problems(root string) ([]string, error) {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
@@ -42,10 +38,6 @@ func Problems(root string) ([]string, error) {
out = append(out, fmt.Sprintf("%s: %v", rel, err))
continue
}
if twice := givenTwice(raw); twice != "" {
out = append(out, fmt.Sprintf("%s: the field %s is given twice; a judge reads only the last", rel, twice))
continue
}
if named[m.Module] {
out = append(out, fmt.Sprintf("%s: %s was already given; two manifests name one module", rel, m.Module))
continue
@@ -54,47 +46,3 @@ func Problems(root string) ([]string, error) {
}
return out, nil
}
// givenTwice is the first field an object of raw gives twice, as its path (`invokes`, `build.artifacts[0].env`), or empty.
func givenTwice(raw []byte) string {
d := json.NewDecoder(bytes.NewReader(raw))
var walk func(path string) string
walk = func(path string) string {
t, err := d.Token()
if err != nil {
return ""
}
switch t {
case json.Delim('{'):
seen := map[string]bool{}
for d.More() {
k, err := d.Token()
if err != nil {
return ""
}
key, _ := k.(string)
at := key
if path != "" {
at = path + "." + key
}
if seen[key] {
return at
}
seen[key] = true
if twice := walk(at); twice != "" {
return twice
}
}
_, _ = d.Token()
case json.Delim('['):
for i := 0; d.More(); i++ {
if twice := walk(fmt.Sprintf("%s[%d]", path, i)); twice != "" {
return twice
}
}
_, _ = d.Token()
}
return ""
}
return walk("")
}
+1 -49
View File
@@ -5,12 +5,10 @@ import (
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The tree's manifests are ones a judge can read, and no module is named twice. A row is a catalogue of its own: a
// manifest that is no JSON, one giving a field twice and two manifests of one module are refused, naming the file; a manifest with a field this
// manifest that is no JSON and two manifests of one module are refused, naming the file; a manifest with a field this
// check does not know is not (it judges nothing: the checker does, ADR 0578 §2). The catalogue itself passes.
func TestEveryManifestDecodesAndNamesOneModule(t *testing.T) {
for _, row := range []struct {
@@ -21,10 +19,6 @@ func TestEveryManifestDecodesAndNamesOneModule(t *testing.T) {
{name: "a manifest the judge can read", files: map[string]string{"a": `{"module":"a","version":"1"}`}},
{name: "a field no part knows is the judge's to refuse", files: map[string]string{"a": `{"module":"a","version":"1","unheard":true}`}},
{name: "bytes that are no manifest", files: map[string]string{"a": `{"module":`}, refused: "modules/a/module.json"},
{name: "a field given twice", files: map[string]string{"a": `{"module":"a","version":"1","invokes":["x.y"],"build":{"artifacts":[{}]},"invokes":["z.w"]}`},
refused: "modules/a/module.json: the field invokes is given twice"},
{name: "a field given twice deeper", files: map[string]string{"a": `{"module":"a","version":"1","build":{"artifacts":[{"name":"t","name":"u"}]}}`},
refused: "the field build.artifacts[0].name is given twice"},
{name: "two manifests of one module", files: map[string]string{"a": `{"module":"x","version":"1"}`, "b": `{"module":"x","version":"1"}`},
refused: "two manifests name one module"},
} {
@@ -54,45 +48,3 @@ func TestEveryManifestDecodesAndNamesOneModule(t *testing.T) {
t.Fatalf("the catalogue holds manifests no judge can read:\n%s", strings.Join(real, "\n"))
}
}
// No manifest of this catalogue claims the seat mesh-catalog: the indexer of the delivery holds it, and the module that
// held it before has dropped its claim (ADR 0604 §6). A row is a tree of its own: a manifest claiming it is named.
func TestNoManifestClaimsTheSeatMeshCatalog(t *testing.T) {
claimants := func(root string) []string {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
t.Fatal(err)
}
var out []string
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
m, err := catalogue.Decode(raw)
if err != nil {
continue // Problems names it
}
for _, c := range m.Claims {
if c.Name == "mesh-catalog" {
out = append(out, m.Module)
}
}
}
return out
}
row := t.TempDir()
if err := os.MkdirAll(filepath.Join(row, "modules", "a"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(row, "modules", "a", "module.json"),
[]byte(`{"module":"a","version":"1","claims":[{"name":"mesh-catalog","scope":"mesh"}]}`), 0o644); err != nil {
t.Fatal(err)
}
if got := claimants(row); len(got) != 1 || got[0] != "a" {
t.Fatalf("a manifest claiming the seat is named, got %v", got)
}
if got := claimants(filepath.Join("..", "..")); len(got) != 0 {
t.Errorf("the catalogue's manifests %v claim the seat mesh-catalog: the indexer of the delivery holds it", got)
}
}
+1 -3
View File
@@ -2,6 +2,4 @@ module checks/readable
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+2 -4
View File
@@ -1,4 +1,2 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/removedpackages
go 1.22
-166
View File
@@ -1,166 +0,0 @@
// Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that
// narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left
// mesh-sdk's buildstep for mesh-build-go's package.
//
// A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the
// pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin.
// This check refuses it at once, from the source alone:
//
// - an import of a package in Removed (or one under it);
// - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is
// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep).
//
// It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out).
//
// go run . <catalogue root>
//
// Exits 1 on a finding. The lists are the release's: a package that goes later is added here with the change.
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const sdk = "git.novox.be/novox/mesh-sdk/go"
// Removed are the packages of mesh-sdk that left it, each with where its logic is now.
var Removed = map[string]string{
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu",
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance",
"delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules",
"compose": "the node-engine, which alone composes (ADR 0593 §6)",
"compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)",
"compose/fromruntime": "the node-engine, which alone composes (ADR 0593 §6)",
"compose/judge": "the assigner's own package",
"takein": "the node-engine, which alone composes (ADR 0593 §6)",
"validate": "the node-engine, which alone composes (ADR 0593 §6)",
"facts": "nothing: the controller they served is ended (ADR 0590)",
"example": "nothing: no module imports it (ADR 0593 §12)",
"packageguard": "nothing: a test helper of mesh-sdk's own",
}
// BuildstepContract are the names mesh-sdk's buildstep keeps: the failure contract, the fetch request, the stale-step sweep,
// the step id and the module tree shape the delivery parts read.
var BuildstepContract = map[string]bool{
"AskWhat": true, "Boot": true, "By": true, "CapacitySetting": true, "Clip": true, "ConsoleFailure": true, "CutLine": true,
"EndOne": true, "EndStale": true, "EndStaleAtStart": true, "EndStaleRound": true, "Failure": true, "FetchRequest": true,
"FetchRequestProperties": true, "Hidden": true, "Launchers": true, "LauncherSuffix": true, "LineBytes": true,
"ModuleTree": true, "NewID": true, "OutputBytes": true, "Owner": true, "PackagesArg": true, "ParseFetchRequest": true,
"PassingLine": true, "RestartReason": true, "Scan": true, "ScanOf": true, "StaleAttempts": true, "StaleRetry": true,
"StaleServed": true,
}
// Findings is every refusal in the Go sources under root, sorted.
func Findings(root string) ([]string, error) {
var out []string
for _, top := range []string{"modules", "checks"} {
err := filepath.WalkDir(filepath.Join(root, top), func(path string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.IsDir() {
switch d.Name() {
case "testdata", "vendor", "node_modules", ".mesh-build":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
rel, _ := filepath.Rel(root, path)
found, err := fileFindings(path, filepath.ToSlash(rel))
if err != nil {
return err
}
out = append(out, found...)
return nil
})
if err != nil {
return nil, err
}
}
sort.Strings(out)
return out, nil
}
func fileFindings(path, rel string) ([]string, error) {
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution)
if err != nil {
return nil, fmt.Errorf("%s: %w", rel, err)
}
var out []string
buildstep := ""
for _, imp := range file.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err != nil {
continue
}
sub, ok := strings.CutPrefix(p, sdk+"/")
if !ok {
continue
}
for pkg, to := range Removed {
if sub == pkg || strings.HasPrefix(sub, pkg+"/") {
out = append(out, fmt.Sprintf("%s: imports %s, which left mesh-sdk; it is %s (novox/hq ADR 0593 §1)", rel, p, to))
break
}
}
if sub == "buildstep" {
buildstep = "buildstep"
if imp.Name != nil {
buildstep = imp.Name.Name
}
}
}
if buildstep == "" || buildstep == "_" || buildstep == "." {
return out, nil
}
seen := map[string]bool{}
ast.Inspect(file, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != buildstep || !sel.Sel.IsExported() || BuildstepContract[sel.Sel.Name] || seen[sel.Sel.Name] {
return true
}
seen[sel.Sel.Name] = true
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
return true
})
return out, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: removedpackages <catalogue root>")
os.Exit(2)
}
found, err := Findings(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("no source imports a package mesh-sdk gave up")
}
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ADR 0593 §1: a source that imports a package mesh-sdk gave up, or names the build runner through mesh-sdk's buildstep, is
// refused naming where it went; the contract that stays, the runner's own package and a testdata copy are not.
func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) {
const head = "package x\n\nimport (\n"
for _, row := range []struct {
name, file, src string
want string // empty: clean
}{
{"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""},
{"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"},
{"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"},
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"},
{"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"},
{"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"},
{"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""},
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""},
{"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, row.file)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(row.src), 0o644); err != nil {
t.Fatal(err)
}
got, err := Findings(root)
if err != nil {
t.Fatal(err)
}
if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) {
t.Fatalf("findings = %q, want one containing %q", all, row.want)
}
})
}
}
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2).
func TestTheCatalogueImportsNothingMeshSDKGaveUp(t *testing.T) {
got, err := Findings(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
for _, f := range got {
t.Error(f)
}
}
-6
View File
@@ -1,6 +0,0 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every Go source of the modules and of the checks, whose imports are read.
modules
checks
+7 -1
View File
@@ -36,7 +36,13 @@ import (
// composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no
// manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5).
var composition = map[string]string{
"catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template",
"catalogue/consumers.go:consumersPlaceholderProblems": "composition: rendering a consumer's template",
"catalogue/collections.go:collectedPlaceholderProblems": "composition: placeholders a contribution renders",
"catalogue/environment_into.go:placeholderProblems": "composition: placeholders a contribution renders",
"catalogue/seat_contributions.go:seatPlaceholderProblems": "composition: placeholders a contribution renders",
"catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template",
"catalogue/setting_takes.go:takenSettingsProblems": "composition: whether stored settings still compose",
"catalogue/template.go:HeldTemplateProblem": "composition: the take-in refusal of a resolved build",
}
// Problems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for
+1 -1
View File
@@ -1,4 +1,4 @@
57
58
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: checks/health fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
+2 -2
View File
@@ -2,10 +2,10 @@ module adwaita
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+1 -11
View File
@@ -61,7 +61,7 @@ record names leaves its platform manifests unrecorded with it.
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
definition the catalogue holds (the seat mesh-catalog's verbs `modules`, then `module` for each, eight at
definition the catalogue holds (`mesh-catalog.catalog_modules`, then `catalog_module` for each, eight at
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
@@ -115,13 +115,3 @@ Any command other than the two read-only scripts fails the test.
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
### The sweep through the registry seats
The sweep writes nothing to the store's door (CAT-AS1, ADR 0617 §2): it asks the registry seats the store's holder
serves. Each kept bundle or archive is held on `oci-artifact-registry` and each kept copy of an upstream base on
`container-image-registry` (`hold`, with why it is kept) before anything goes; each eligible one is released, then
removed for `retention` on its seat (`release`, `remove`). It reads a reference in both forms, `artifact-store://<path>`
and `<seat>/<module>/<artifact>@<digest>`, as one artifact, and says artifacts-gone in the form the build named it. A
call a seat refuses stops the sweep and writes the finding `sweep-refused` in the module's findings; the next sweep no
seat refuses clears it (`sweepseats.go`).
@@ -23,11 +23,10 @@ import (
// manifest's.
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
// CatalogueModules and CatalogueModule are the verbs of the seat mesh-catalog these definitions are read through
// (ADR 0604 §3); the answers are catalogue.CatalogModules and catalogue.CatalogOne.
// CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through.
const (
CatalogueModules = "seat:mesh-catalog.modules"
CatalogueModule = "seat:mesh-catalog.module"
CatalogueModules = "mesh-catalog.catalog_modules"
CatalogueModule = "mesh-catalog.catalog_module"
)
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
@@ -280,8 +279,11 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
return pins, ""
}
}
// The verb takes no commit: it answers the trunk's module, and the answer names the commit it is at.
raw, err := c.Ask(CatalogueModule, map[string]any{"module": m.Module})
args := map[string]any{"module": m.Module}
if m.Commit != "" {
args["commit"] = m.Commit
}
raw, err := c.Ask(CatalogueModule, args)
if err != nil {
return nil, "could not be asked: " + err.Error()
}
@@ -291,7 +293,6 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
}
var one struct {
Module *struct {
Commit string `json:"commit"`
Manifest json.RawMessage `json:"manifest"`
} `json:"module"`
Error string `json:"error"`
@@ -300,11 +301,9 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
}
pins := PinsIn(one.Module.Manifest, names)
// Kept by the commit the answer names, which may be newer than the list's: a definition and its commit never
// change together.
if at := firstOf(one.Module.Commit, m.Commit); at != "" {
if m.Commit != "" {
c.mu.Lock()
c.kept[m.Module+"@"+at+"|"+nameKey] = pins
c.kept[key] = pins
c.mu.Unlock()
}
return pins, ""
@@ -211,7 +211,7 @@ func TestReferencesReadTheDefinitionsUnderTheStoresOlderNames(t *testing.T) {
}
}
if before != 2 || after != 2 {
t.Errorf("asked the seat's module %d times, then %d: each definition at its commit is read once", before, after)
t.Errorf("asked catalog_module %d times, then %d: each definition at its commit is read once", before, after)
}
}
@@ -8,25 +8,30 @@ import (
"fmt"
"os"
"strings"
"sync/atomic"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
"artifactstoretools/internal/registry"
)
// door is registry.Store at the store's address as this machine reaches it.
type door struct{ s registry.Store }
// It is read only: what the sweep holds and removes it asks the registry seats (sweepseats.go).
func (d *door) Address() string { return d.s.Address }
func (d *door) Holds(ctx context.Context, r string) error { return d.s.Holds(ctx, r) }
func (d *door) Address() string { return d.s.Address }
func (d *door) Hold(ctx context.Context, r string) (bool, error) { return d.s.Hold(ctx, r) }
func (d *door) Holds(ctx context.Context, r string) error { return d.s.Holds(ctx, r) }
func (d *door) IsIndex(ctx context.Context, r string) (bool, error) {
return d.s.IsIndex(ctx, r)
}
func (d *door) LetGo(ctx context.Context, r string) error { return d.s.LetGo(ctx, r) }
func (d *door) SpareKept(ctx context.Context, keptImages, touching []string) error {
spare, err := d.s.SpareKeptIndexes(ctx, keptImages, touching)
if err == nil {
d.s.Spare = spare
}
return err
}
// DoorAt reads the store's address (host:port) from the file the manifest fills from the seat mesh-artifact-store's
// reach, each time, so a store that moved is reached where it is now.
@@ -48,22 +53,10 @@ func DoorAt(file string) func() (Door, error) {
}
// runtimeBus is the holder's bus through the runtime: its reads, its findings and its seat's event.
type runtimeBus struct{ views *views.Reader }
type runtimeBus struct{}
// followed says whether the state is the indexer's or the assigner's, which the holder asks once and follows by events
// (ADR 0615) rather than reads.
func (b runtimeBus) followed(state string) bool { return b.views != nil && b.views.Follows(state) }
func (b runtimeBus) Keys(state string) ([]string, error) {
if b.followed(state) {
return b.views.Keys(state)
}
return stdio.State(state).Keys()
}
func (b runtimeBus) Get(state, key string) ([]byte, bool, error) {
if b.followed(state) {
return b.views.Get(state, key)
}
func (runtimeBus) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
func (runtimeBus) Get(state, key string) ([]byte, bool, error) {
e, err := stdio.State(state).Get(key)
if err != nil || e == nil {
return nil, false, err
@@ -97,7 +90,7 @@ func (runtimeBus) Publish(subject string, body any) error {
// follow watches the builder's done keys and the wants: a build's end sweeps and
// observes again; a want changed observes again. Event-driven: nothing reads them on a timer.
func follow(h *Holder, v *views.Reader) {
func follow(h *Holder) {
<-stdio.Served()
watch := func(state string, handle func(stdio.StateChange) error) {
for {
@@ -126,22 +119,11 @@ func follow(h *Holder, v *views.Reader) {
}
return nil
})
// The wants are the assigner's: the first view then its events (ADR 0615). What Watch hands over before it returns
// is the first view, not a change.
var current atomic.Bool
current.Store(true)
for {
err := v.Watch(WantsRead, func(string, []byte, bool) {
if !current.Load() {
go observe()
}
})
if err == nil {
break
watch(WantsRead, func(c stdio.StateChange) error {
if !c.Current {
go observe()
}
h.logf("watching %s: %v; asked again in 10s", WantsRead, err)
time.Sleep(10 * time.Second)
}
current.Store(false)
return nil
})
observe()
}
@@ -29,8 +29,6 @@ import (
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/views"
"artifactstoretools/internal/registry"
)
@@ -71,12 +69,15 @@ type Bus interface {
Publish(subject string, body any) error
}
// Door is the artifact store's own door, read only: registry.Store, behind an interface so a test needs no store. What
// the sweep holds and removes it asks the registry seats (Seats, sweepseats.go).
// Door is the artifact store's own door: registry.Store, behind an interface so a test needs no store.
type Door interface {
Address() string
Hold(ctx context.Context, reference string) (bool, error)
Holds(ctx context.Context, reference string) error
IsIndex(ctx context.Context, reference string) (bool, error)
LetGo(ctx context.Context, reference string) error
// SpareKept sets the platform manifests a kept index names aside before an index is let go of (ADR 0257).
SpareKept(ctx context.Context, keptImages, touching []string) error
}
// Holder is the seat's holder.
@@ -86,10 +87,8 @@ type Holder struct {
// Listed is the store's own listing now (Store.View); nil reads none, and then nothing is said gone or collected.
Listed func(ctx context.Context) (*View, error)
Acts handacts.Recorder
// Seats asks a registry seat's verb, `seat:<seat>.<verb>` (hold, remove): the runtime's stdio.Ask in main.
Seats func(key string, body any) (json.RawMessage, error)
Now func() time.Time
Logf func(string, ...any)
Now func() time.Time
Logf func(string, ...any)
sweep sync.Mutex // one sweep at a time
mu sync.Mutex
@@ -218,7 +217,7 @@ func KeepSet(builds []Build, wants []Want) map[string]*Ref {
out := map[string]*Ref{}
for _, b := range builds {
for _, a := range b.Artifacts {
reference := canonical(catalogue.Recorded(a.Reference))
reference := catalogue.Recorded(a.Reference)
if _, ours := catalogue.InArtifactStore(reference); !ours || a.Kind == "package" {
continue
}
@@ -512,13 +511,13 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
return nil, err
}
a := &CollectAnswer{DryRun: s.DryRun, WouldLetGo: []string{}, LetGo: []string{}}
var keptArchives, eligible []string
var keptArchives, keptImages, eligible []string
for _, r := range j.sorted() {
switch {
case r.Kept && (r.Kind == "archive" || isCopy(r.Reference)):
// What retention would remove unless held: a bundle or an archive, and a copied base (ADR 0617 §2).
case r.Kept && r.Kind == "archive":
keptArchives = append(keptArchives, r.Reference)
case r.Kept:
keptImages = append(keptImages, r.Reference)
default:
if held, known := inStore(j.view, r.Reference); known && !held {
continue // collected already
@@ -544,25 +543,33 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
a.Store = door.Address()
within, stop := context.WithTimeout(ctx, SweepBudget)
defer stop()
// Hold before letting go (ADR 0251 §8, ADR 0617 §2): each kept archive and copied base is held on its seat; one that
// cannot be held stops the sweep before any removal and writes the sweep's finding.
// Hold before letting go (ADR 0251 §8): a kept archive that cannot be held stops the sweep before any delete.
var missing []string
for _, r := range keptArchives {
if held, known := inStore(j.view, r); known && !held {
wrote, err := door.Hold(within, r)
switch {
case err == nil:
if wrote {
a.HoldersWritten++
}
case errors.Is(err, registry.Gone):
missing = append(missing, r)
continue
}
if _, err := h.holdOnSeat(r, keptWhy(j.refs[r])); err != nil {
case errors.Is(err, registry.ErrNotOurs):
default:
a.Stopped = fmt.Sprintf("not every kept archive could be held, so nothing was let go: holding %s: %v", r, err)
a.Left = len(eligible)
h.sweepFinding("the hold of "+r, err)
return a, nil
}
a.HoldersWritten++
}
a.Held, a.Missing = len(keptArchives)-len(missing), len(missing)
h.sayGone(j, missing, "a kept archive the store does not hold")
refused := false
if s.Confirmed {
if err := door.SpareKept(within, keptImages, eligible); err != nil {
a.Stopped = fmt.Sprintf("which platform manifests a kept index names could not be read, so nothing was let go: %v", err)
a.Left = len(eligible)
return a, nil
}
}
for i, r := range eligible {
if i >= s.Most || within.Err() != nil {
a.Left = len(eligible) - i
@@ -586,19 +593,24 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
continue
}
}
// Removed through its seat (ADR 0617 §2), recorded in the hand-act log, each one (ADR 0565 §10; the operator's
// "option A").
err := door.LetGo(within, r)
switch {
case err == nil || errors.Is(err, registry.Gone):
case errors.Is(err, registry.ErrNotOurs):
a.Skipped++
continue
default:
a.Stopped = fmt.Sprintf("the store refused to let go of %s, so nothing more was asked: %v", r, err)
a.Left = len(eligible) - i
}
if a.Stopped != "" {
break
}
// Recorded in the hand-act log, each one (ADR 0565 §10; the operator's "option A").
why := s.Why
if why == "" {
why = "eligible: no want names it and it is not among its module's " + fmt.Sprint(KeptBuilds) + " newest builds"
}
if err := h.removeOnSeat(r, why); err != nil {
a.Stopped = fmt.Sprintf("a registry seat refused to remove %s, so nothing more was asked: %v", r, err)
a.Left = len(eligible) - i
h.sweepFinding("the removal of "+r, err)
refused = true
break
}
if _, err := h.Acts.Record(handacts.Request{What: "let go of " + r + " (" + s.By + ")", Why: why,
Cause: "artifact-store-sweep", Verb: "collect"}); err != nil {
h.logf("%s was let go of and could not be recorded in the hand-act log: %v", r, err)
@@ -606,9 +618,6 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
a.LetGo = append(a.LetGo, r)
}
h.sayGone(j, a.LetGo, "let go of")
if !refused {
h.sweepClean()
}
return a, nil
}
@@ -628,7 +637,7 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
byKey[b.Key] = g
keys = append(keys, b.Key)
}
g.Gone = append(g.Gone, namedBy(b, r))
g.Gone = append(g.Gone, r)
}
}
sort.Strings(keys)
@@ -643,17 +652,6 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
}
}
// namedBy is the reference b names r by, in the form its key holds (the older one or the seat's): the builder matches
// artifacts-gone against its own key.
func namedBy(b Build, r string) string {
for _, a := range b.Artifacts {
if rec := catalogue.Recorded(a.Reference); canonical(rec) == r {
return rec
}
}
return r
}
// AfterBuild is the sweep at a build's end.
func (h *Holder) AfterBuild(key string) {
a, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "after the build " + key})
@@ -687,7 +685,7 @@ func (h *Holder) Held(refs []string) (delivery.HeldAnswer, error) {
defer stop()
a := delivery.HeldAnswer{Store: door.Address(), Held: []string{}, Gone: []string{}}
for _, r := range refs {
switch err := door.Holds(asking, canonical(r)); {
switch err := door.Holds(asking, r); {
case err == nil:
a.Held = append(a.Held, r)
case errors.Is(err, registry.Gone):
@@ -901,16 +899,7 @@ func (h *Holder) Observe(ctx context.Context) error {
return fmt.Errorf("the findings: %w", err)
}
for _, k := range keys {
if k == sweepFindingKey {
continue // the sweep's own finding, which the sweep clears
}
if _, still := now[k]; !still {
// A finding of a first view unanswered is the reader's (views.Reader), cleared by its answer, not by a sweep.
if raw, found, err := h.Bus.Get(FindingsState, k); err == nil && found {
if f, err := health.DecodeFinding(raw); err == nil && f.Kind == views.UnansweredKind {
continue
}
}
if err := h.Bus.Delete(FindingsState, k); err != nil {
return err
}
@@ -173,32 +173,15 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
// The registry seats' holder on the bus (CAT-AS1): the sweep holds and removes through their verbs.
for _, seat := range []string{"oci-artifact-registry", "container-image-registry"} {
if _, err := nc.Subscribe("mesh.seat."+seat+".tool.*", func(m *nats.Msg) {
var body map[string]any
_ = json.Unmarshal(m.Data, &body)
mu.Lock()
out, err := door.seat("seat:"+seat+"."+m.Subject[strings.LastIndex(m.Subject, ".")+1:], body)
mu.Unlock()
if err != nil {
out, _ = json.Marshal(map[string]string{"error": err.Error()})
}
_ = m.Respond(out)
}); err != nil {
t.Fatal(err)
}
}
ask := func(key string, body any) (json.RawMessage, error) {
h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Now: time.Now, Logf: t.Logf}
h.Acts = handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
m, err := nc.Request("mesh.seat."+strings.TrimPrefix(strings.Replace(key, ".", ".tool.", 1), "seat:"), raw, 5*time.Second)
if err != nil {
return nil, err
}
return m.Data, nil
}
h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
h.Acts = handacts.Recorder{Ask: ask}
}}
// A build's end heard from the builds bucket, as follow hands it over.
kv, _ := bus.kv(BuildsRead)
@@ -3,9 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
@@ -17,8 +15,6 @@ import (
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"artifactstoretools/internal/registry"
)
@@ -89,56 +85,21 @@ func (b *memBus) Publish(subject string, body any) error {
return nil
}
// memDoor is the store's door in memory, read only, and the registry seats' holder: what it holds, which references are
// indexes, what the seats were asked (asked: "hold <ref>", "let go <ref>"; calls: each seat call as its key and name), the
// holds asked (held), and the calls it refuses (refuse: by "<verb> <ref>").
// memDoor is the store's door in memory: what it holds, which references are indexes, and what was asked.
type memDoor struct {
holds map[string]bool
indexes map[string]bool
asked []string
calls []string
held map[string]bool
refuse map[string]error
}
// seat is the registry seats' holder, as Holder.Seats calls it.
func (d *memDoor) seat(key string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
var in struct{ Name, Digest, Reason string }
_ = json.Unmarshal(raw, &in)
seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
ref := catalogue.ArtifactStoreScheme + in.Name + "@" + in.Digest
if seat == "oci-artifact-registry" {
ref = catalogue.ArtifactStoreScheme + in.Name + "/blobs/" + in.Digest
}
d.calls = append(d.calls, key+" "+in.Name+"@"+in.Digest+" "+in.Reason)
if err := d.refuse[verb+" "+ref]; err != nil {
return nil, err
}
if d.held == nil {
d.held = map[string]bool{}
}
switch verb {
case "hold":
d.asked = append(d.asked, "hold "+ref)
d.held[ref] = true
return json.Marshal(map[string]any{"held": true})
case "release":
released := d.held[ref] // what is not held answers released false, as the seat's definition says
delete(d.held, ref)
return json.Marshal(map[string]any{"released": released})
case "remove":
if d.held[ref] {
return nil, fmt.Errorf("remove: refused: %s@%s is held; release it first", in.Name, in.Digest)
}
d.asked = append(d.asked, "let go "+ref)
delete(d.holds, ref)
return json.Marshal(map[string]any{"removed": true})
}
return nil, fmt.Errorf("no verb %s", key)
}
func (d *memDoor) Address() string { return "10.0.0.5:5000" }
func (d *memDoor) Hold(_ context.Context, r string) (bool, error) {
d.asked = append(d.asked, "hold "+r)
if !d.holds[r] {
return false, registry.Gone
}
return false, nil
}
func (d *memDoor) Holds(_ context.Context, r string) error {
if !d.holds[r] {
return registry.Gone
@@ -146,6 +107,15 @@ func (d *memDoor) Holds(_ context.Context, r string) error {
return nil
}
func (d *memDoor) IsIndex(_ context.Context, r string) (bool, error) { return d.indexes[r], nil }
func (d *memDoor) LetGo(_ context.Context, r string) error {
d.asked = append(d.asked, "let go "+r)
delete(d.holds, r)
return nil
}
func (d *memDoor) SpareKept(context.Context, []string, []string) error {
d.asked = append(d.asked, "spare")
return nil
}
// mirror is a copy of an upstream base in the store, the n-th.
func mirror(n int) string {
@@ -224,7 +194,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
want, _ := json.Marshal(delivery.Want{V: delivery.Version, FP: fp(1), Self: true})
_ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want))
var acts []string
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil },
Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil },
Now: func() time.Time { return day.Add(48 * time.Hour) },
Acts: handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
@@ -276,43 +246,18 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
left int
skipped int
unlog int
// held is how many kept archives and copies are held (7 when 0); seatForm names letta's 2nd and 3rd builds in the
// seat's form; refuse is the seat call refused, "<verb> <ref>".
held int
seatForm bool
refuse string
}{
{name: "a build's end", how: "build", letGo: all},
// ADR 0605 §9: a build that names its artifacts in the seat's form is the same artifact: removed and held under the
// seat's name and digest, and said gone in its own form.
{name: "the seat's form", most: MostPerSweep, letGo: all, seatForm: true},
// A seat that refuses a removal stops the sweep; the finding sweep-refused is written, and cleared by a clean run.
{name: "a refused removal", most: MostPerSweep, letGo: []string{archiveRef("letta", 2)}, left: 2, refuse: "remove " + imageRef("letta", 2)},
{name: "a confirmed collect", how: "collect", letGo: all},
{name: "a sweep", most: MostPerSweep, letGo: all},
{name: "an index waits", index: true, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), mirror(1)}, skipped: 1},
{name: "bounded", most: 1, letGo: []string{archiveRef("letta", 2)}, left: 2},
// A kept build the log no longer holds: what it stood on is unknown, so no copy goes.
{name: "a kept build's bases unknown", most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2)}, unlog: 3, held: 8},
{name: "a kept build's bases unknown", most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2)}, unlog: 3},
} {
t.Run(c.name, func(t *testing.T) {
h, b, door, acts := aMesh(t)
door.indexes[imageRef("letta", 2)] = c.index
if c.held == 0 {
c.held = 7
}
if c.seatForm {
for _, n := range []int{2, 3} {
key, _ := delivery.BuilderBuildKey("letta", fp(n))
raw, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []delivery.BuiltArtifact{
{Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: catalogue.SeatReference("container-image-registry", "letta", "image", digestOf(imageRef("letta", n)))},
{Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: catalogue.SeatReference("oci-artifact-registry", "letta", "files", digestOf(archiveRef("letta", n)))}}})
_ = b.Put(BuildsRead, key, json.RawMessage(raw))
}
}
if c.refuse != "" {
door.refuse = map[string]error{c.refuse: errors.New("the store is read only")}
}
if c.unlog > 0 {
b.log = append(b.log[:c.unlog-1:c.unlog-1], b.log[c.unlog:]...)
}
@@ -339,7 +284,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(a.LetGo) != fmt.Sprint(c.letGo) || a.Left != c.left || a.Skipped != c.skipped || a.Held != c.held || a.Missing != 0 {
if fmt.Sprint(a.LetGo) != fmt.Sprint(c.letGo) || a.Left != c.left || a.Skipped != c.skipped || a.Held != 6 || a.Missing != 0 {
t.Fatalf("let go of %v (left %d, skipped %d, held %d, missing %d), want %v", a.LetGo, a.Left, a.Skipped, a.Held, a.Missing, c.letGo)
}
}
@@ -353,34 +298,8 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
held++
}
}
if held != c.held {
t.Errorf("the seats were asked %v: the %d kept archives and copies are held first", door.asked, c.held)
}
// Each removal asks the seat that holds it, by its name and digest, for retention, after a release.
for _, call := range door.calls {
if strings.Contains(call, ".remove ") && !strings.HasSuffix(call, " "+Retention) {
t.Errorf("a removal asked %q, not for retention", call)
}
}
if c.letGo[0] == archiveRef("letta", 2) && !slices.Contains(door.calls, "seat:oci-artifact-registry.remove letta/files@"+digestOf(archiveRef("letta", 2))+" "+Retention) {
t.Errorf("the seats were called %v", door.calls)
}
_, finding, _ := b.Get(FindingsState, sweepFindingKey)
if finding != (c.refuse != "") {
t.Errorf("the sweep's finding is written: %v, want %v", finding, c.refuse != "")
}
if c.refuse != "" {
door.refuse = nil
if _, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "again"}); err != nil {
t.Fatal(err)
}
if _, still, _ := b.Get(FindingsState, sweepFindingKey); still {
t.Error("a clean sweep left the sweep's finding")
}
return
}
if c.seatForm && !strings.Contains(b.published[0], `"container-image-registry/letta/image@`+digestOf(imageRef("letta", 2))+`"`) {
t.Errorf("artifacts-gone does not name the build's own form: %v", b.published)
if held != 6 {
t.Errorf("the store was asked %v: the six kept archives are held first", door.asked)
}
if len(*acts) != len(c.letGo) || !strings.HasPrefix((*acts)[0], "seat:mesh-hand-acts.hand-act ") ||
!strings.Contains((*acts)[0], c.letGo[0]) {
@@ -10,16 +10,12 @@
package main
import (
"context"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -30,23 +26,13 @@ func main() {
Run: ExecRunner,
UID: os.Getuid(),
}
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: views.OwnFindings{}, Acts: handacts.Recorder{Ask: stdio.Ask}}
v.Follow(delivery.AssignerModule, "wants", WantsTool)
v.Follow(delivery.IndexerModule, "manifests", ManifestsTool)
h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE")), Listed: s.View,
Seats: stdio.Ask, Now: time.Now, Logf: logf}
h := &Holder{Bus: runtimeBus{}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE")), Listed: s.View,
Now: time.Now, Logf: logf}
h.Acts = handacts.Recorder{Ask: stdio.Ask}
v.Start(context.Background())
go follow(h, v)
go follow(h)
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
if err := stdio.Serve("", append(append(Tools(s, h, &Catalogue{Ask: stdio.Ask}), SeatTools(h, s)...), v.Tools("artifact_store")...)); err != nil {
if err := stdio.Serve("", append(Tools(s, h, &Catalogue{Ask: stdio.Ask}), SeatTools(h, s)...)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// The owners' read tools of the states the holder follows, which the manifest invokes (ADR 0615).
const (
WantsTool = delivery.AssignerModule + ".assigner_read_wants"
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
)
@@ -2,14 +2,12 @@ package main
import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/views"
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"slices"
"sort"
"strconv"
"strings"
@@ -368,8 +366,8 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
if a.failing[name] {
return nil, fmt.Errorf("no answer")
}
if _, has := args["commit"]; has {
return nil, fmt.Errorf("asked %s at %v: the seat's verb takes no commit", name, args["commit"])
if args["commit"] != "c-"+name {
return nil, fmt.Errorf("asked %s at %v", name, args["commit"])
}
manifest, has := a.definitions[name]
if !has {
@@ -377,8 +375,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
break
}
// The catalogue's tool answers through the runtime as the protocol's content list.
raw, _ := json.Marshal(catalogue.CatalogOne{Module: catalogue.CatalogModule{
CatalogEntry: catalogue.CatalogEntry{Module: name, Commit: "c-" + name}, Manifest: json.RawMessage(manifest)}})
raw, _ := json.Marshal(map[string]any{"module": map[string]any{"module": name, "manifest": json.RawMessage(manifest)}})
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
case "collect":
if args["confirm"] == "true" {
@@ -445,11 +442,10 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
Invokes []string `json:"invokes"`
Reads []string `json:"reads"`
Consumes []string `json:"consumes"`
Claims []struct {
Tools []string `json:"tools"`
Invokes []string `json:"invokes"`
Reads []string `json:"reads"`
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
} `json:"claims"`
@@ -458,7 +454,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
t.Fatal(err)
}
var served []string
for _, tl := range append(Tools(&Store{}, recordsAsked{&asked{}}, &Catalogue{}), (&views.Reader{}).Tools("artifact_store")...) {
for _, tl := range Tools(&Store{}, recordsAsked{&asked{}}, &Catalogue{}) {
if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil {
t.Errorf("tool %q", tl.Name)
}
@@ -472,18 +468,10 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
}
// ADR 0571 clause 2: it holds the seat mesh-artifact-sweep and serves its verbs, and calls no verb of the controller's;
// it reads no controller state, the lease neither (issue 1126).
// CAT-AS1: what it holds and removes it asks the two registry seats the artifact store's holder serves.
want := []string{"seat:mesh-hand-acts.hand-act", CatalogueModules, CatalogueModule, WantsTool, ManifestsTool,
"seat:oci-artifact-registry.hold", "seat:oci-artifact-registry.release", "seat:oci-artifact-registry.remove",
"seat:container-image-registry.hold", "seat:container-image-registry.release", "seat:container-image-registry.remove"}
want := []string{"seat:mesh-hand-acts.hand-act", CatalogueModules, CatalogueModule}
if !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes %v", m.Invokes)
}
// ADR 0615: the assigner's wants and the indexer's manifests are asked once and followed by their events, not read.
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests"}) ||
slices.Contains(m.Reads, WantsRead) || slices.Contains(m.Reads, ManifestsRead) {
t.Errorf("consumes %v, reads %v", m.Consumes, m.Reads)
}
var verbs []string
for _, tl := range SeatTools(&Holder{}, &Store{}) {
verbs = append(verbs, strings.TrimPrefix(tl.Name, catalogue.ArtifactSweepSeat+"."))
@@ -1,147 +0,0 @@
package main
// **The sweep asks the registry seats** (CAT-AS1; novox/hq ADR 0617 §2, design 51): the artifact store's write side is
// its holder's, so the sweep no longer deletes or holds over the store's door. What it keeps from retention it asks the
// seat's `hold` for (a kept bundle or archive on oci-artifact-registry, a kept copy of an upstream base on
// container-image-registry), and what is eligible it asks the seat's `remove` for, with the sweep's reason; both verbs
// are in the manifest's `invokes`, and only this module is on their caller lists. A reference is read in both forms
// (ADR 0605 §9): the older `artifact-store://<path>` and the seat's `<seat>/<module>/<artifact>@<digest>`; the keep set
// keys each by the older form, so the two meet as one artifact. A call a seat refuses stops the sweep and writes the
// sweep's finding, sweep-refused; the next sweep with no refusal clears it.
import (
"encoding/json"
"fmt"
"strings"
ociseat "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
)
// SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked.
const SweepRefused = "sweep-refused"
// sweepFindingKey is the one key of the sweep's finding in the module's findings.
var sweepFindingKey = health.FindingKey(health.ScopeModule, Module, SweepRefused)
// canonical is a kept reference in the older form, `artifact-store://<path>`, whichever form it came in; another is as
// it was.
func canonical(reference string) string {
path, kept := catalogue.InArtifactStore(reference)
if !kept {
return reference
}
return catalogue.ArtifactStoreScheme + path
}
// seatOf is the seat, name and digest a kept reference is held under: a bundle or an archive on oci-artifact-registry,
// an image or a copy of an upstream base on container-image-registry.
func seatOf(reference string) (seat, name, digest string, err error) {
path, kept := catalogue.InArtifactStore(reference)
if !kept {
return "", "", "", fmt.Errorf("%s is no reference into the mesh's registries", reference)
}
if name, digest, ok := strings.Cut(path, "/blobs/"); ok {
return ociseat.Seat, name, digest, nil
}
if name, digest, ok := strings.Cut(path, "@"); ok {
return reg.Seat, name, digest, nil
}
return "", "", "", fmt.Errorf("%s names no digest", reference)
}
// isCopy is whether a kept reference is a copy of an upstream base.
func isCopy(reference string) bool {
path, _ := catalogue.InArtifactStore(reference)
return strings.HasPrefix(path, "upstream/")
}
// askSeat asks verb of the seat reference is held on, with body built from its name and digest.
func (h *Holder) askSeat(reference, verb string, body func(name, digest string) any, answer any) error {
seat, name, digest, err := seatOf(reference)
if err != nil {
return err
}
if h.Seats == nil {
return fmt.Errorf("this process reaches no registry seat")
}
raw, err := h.Seats("seat:"+seat+"."+verb, body(name, digest))
if err != nil {
return fmt.Errorf("%s's %s of %s@%s: %w", seat, verb, name, digest, err)
}
if answer != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, answer); err != nil {
return fmt.Errorf("%s's answer to %s: %w", seat, verb, err)
}
}
return nil
}
// holdOnSeat asks the seat to keep reference from retention, for why.
func (h *Holder) holdOnSeat(reference, why string) (bool, error) {
var a reg.HoldAnswer
err := h.askSeat(reference, reg.VerbHold, func(name, digest string) any {
return reg.HoldInput{Name: name, Digest: digest, Reason: why}
}, &a)
if err == nil && !a.Held {
err = fmt.Errorf("%s: the seat answered it is not held", reference)
}
return a.Held, err
}
// Retention is the reason of a removal by digest, the sweep's alone (design 51 *Who may call which verb*): the holder
// removes an untagged thing only for it. The seat's definition names it.
const Retention = reg.RemoveInputReasonRetention
// removeOnSeat lets reference go: a hold an earlier sweep asked while it was kept is released first (the holder
// refuses to remove a held thing; one not held answers released false), then it is removed for retention. why goes with
// the release; the hand act records it.
func (h *Holder) removeOnSeat(reference, why string) error {
if err := h.askSeat(reference, reg.VerbRelease, func(name, digest string) any {
return reg.ReleaseInput{Name: name, Digest: digest, Reason: why}
}, &reg.ReleaseAnswer{}); err != nil {
return err
}
return h.askSeat(reference, reg.VerbRemove, func(name, digest string) any {
return reg.RemoveInput{Name: name, Digest: digest, Reason: Retention}
}, &reg.RemoveAnswer{})
}
// sweepFinding writes the sweep's finding: what the seat refused, and why.
func (h *Holder) sweepFinding(what string, err error) {
at := h.now().UTC()
f := health.Finding{V: health.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
Subject: health.Subject{Scope: health.ScopeModule, ID: Module},
Severity: health.Warning, Resolver: health.ResolverOperator,
Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what),
Headline: "The artifact sweep stopped",
Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " +
"refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.",
Needs: "read the seat holder's journal for the refusal; the next sweep asks again.",
Resolved: "Resolved: the artifact sweep ran through without a refusal",
Evidence: []health.Evidence{{At: at, Said: fmt.Sprintf("%s: %v", what, err)}},
Source: "CAT-AS1", Observer: Module, At: at}
if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil {
h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr)
}
}
// sweepClean clears the sweep's finding after a sweep no seat refused.
func (h *Holder) sweepClean() {
if _, found, err := h.Bus.Get(FindingsState, sweepFindingKey); err == nil && found {
if err := h.Bus.Delete(FindingsState, sweepFindingKey); err != nil {
h.logf("the sweep's finding could not be cleared: %v", err)
}
}
}
// keptWhy is why a kept reference is held, as the seat's hold takes it.
func keptWhy(r *Ref) string {
if r == nil || len(r.Why) == 0 {
return "kept by the artifact sweep"
}
return "kept: " + strings.Join(r.Why, "; ")
}
+5 -9
View File
@@ -3,24 +3,20 @@ module artifactstoretools
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/nats-io/nats.go v1.54.0
)
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
require (
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.16.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+10 -16
View File
@@ -1,21 +1,15 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 h1:5HeZzk6tKzcSwVivD3IcIvQ/2WIvysZvNvNKyq/oIBA=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:pPxwhTMb3hIk7UUrBicJnGQCqfTEoBGJvNFspEK2U18=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 h1:+bkN8op2yrAlBYrOB+hYnIoIZkbjXsNmc/4//uMnL4E=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:ooQa2CiP8JOvhP0bdQvwurV5wskRE7Cm68ecURBbCwU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -29,5 +23,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
@@ -0,0 +1,57 @@
package registry
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
)
// blobBound is the most GetBlob reads: a templates archive is a few kilobytes; anything near this is no archive a
// take-in reads text from.
const blobBound = 64 << 20
// GetBlob is the archive a recorded reference names (`artifact-store://<repository>/blobs/sha256:<hex>`), read whole
// and checked against the digest it names: bytes that hash to another digest are refused, never read (R4e: the
// controller reads a templates archive by the reference of a builder's done key). Gone when the store does not hold
// it; ErrNotOurs for a reference outside the store or one that is not an archive.
func (s Store) GetBlob(ctx context.Context, reference string) ([]byte, error) {
repository, digest, archive, err := s.archive(reference)
if err != nil {
return nil, err
}
if !archive {
return nil, fmt.Errorf("%w: %s is not an archive", ErrNotOurs, reference)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "blobs", digest), nil)
if err != nil {
return nil, err
}
response, err := s.client().Do(request)
if err != nil {
return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, Gone
default:
return nil, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest)
}
body, err := io.ReadAll(io.LimitReader(response.Body, blobBound+1))
if err != nil {
return nil, fmt.Errorf("reading %s/blobs/%s: %w", repository, digest, err)
}
if len(body) > blobBound {
return nil, fmt.Errorf("%s/blobs/%s is larger than %d bytes, which no archive read here is", repository, digest, blobBound)
}
sum := sha256.Sum256(body)
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
return nil, fmt.Errorf("the artifact store answered %s/blobs/%s with bytes of the digest %s: refused", repository,
digest, got)
}
return body, nil
}
@@ -0,0 +1,44 @@
package registry
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A take-in reads a templates archive by its reference, and only bytes of the digest the reference names.
func TestGetBlobReadsOnlyTheBytesOfItsDigest(t *testing.T) {
good := []byte("the archive")
sum := sha256.Sum256(good)
digest := "sha256:" + hex.EncodeToString(sum[:])
liar := "sha256:" + strings.Repeat("0", 64)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+digest), strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+liar):
_, _ = w.Write(good)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
s := Store{Address: strings.TrimPrefix(server.URL, "http://")}
ctx := context.Background()
body, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+digest)
if err != nil || string(body) != string(good) {
t.Fatalf("the archive: %q %v", body, err)
}
if _, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+liar); err == nil || !strings.Contains(err.Error(), "refused") {
t.Fatalf("bytes of another digest are read: %v", err)
}
if _, err := s.GetBlob(ctx, "artifact-store://x/other/blobs/"+digest); !errors.Is(err, Gone) {
t.Fatalf("a blob the store does not hold: %v", err)
}
if _, err := s.GetBlob(ctx, "registry.example/x@"+digest); !errors.Is(err, ErrNotOurs) {
t.Fatalf("a reference outside the store: %v", err)
}
}
@@ -0,0 +1,345 @@
package registry
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Every archive the store keeps is held by a manifest (novox/hq issue 253, ADR 0189).
//
// **The store's collector marks only from manifests.** The mesh's store is a stock registry, and
// its nightly `registry garbage-collect` walks every manifest in every repository, marks the blobs
// those manifests name, and deletes every blob it did not mark. An image is a manifest, so what
// the mesh keeps of an image survives. An archive was not: the builder put it in the store as a
// bare blob — upload, then `PUT ?digest=` — and nothing in the store names it. To the collector a
// bare blob is unreferenced, so the first real collection would have deleted every archive the
// mesh holds, kept or not, and every machine pinning a bundle would have found it gone. The
// collector runs `--dry-run` until this is true.
//
// **So each archive gets a holder**: the smallest OCI image manifest that names it — the empty
// config, one layer, nothing else — put in the archive's own repository, by digest, untagged. The
// collector marks it and so keeps the archive; the sweep lets go of an archive by deleting its
// holder first, which is what lets the bytes go at the next collection.
//
// **Nothing a machine reads changes.** The recorded reference stays
// `artifact-store://<module>/<artifact>/blobs/sha256:…`, and machines fetch the blob exactly as
// before. The holder is the store's bookkeeping, not a second way to reach anything.
//
// **Deterministic, so it never needs recording.** The holder is composed from the archive's digest
// and size alone, in a fixed field order with no timestamps or annotations, so the sweep can
// compute which manifest holds any archive from the reference it already has plus one HEAD for the
// size. No schema change, no second record that could disagree with the store.
const (
// mediaManifest is the type a holder is put and asked for as.
mediaManifest = "application/vnd.oci.image.manifest.v1+json"
// mediaEmpty is the OCI empty descriptor's type: a config that says nothing, for a manifest
// whose only purpose is to name its layer.
mediaEmpty = "application/vnd.oci.empty.v1+json"
// emptyDigest is the digest of `{}`, the empty config's content, fixed by the OCI spec.
emptyDigest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
// mediaArchive is the layer type an archive is held as. Every archive the builder publishes is
// `pack`'s gzipped tar, so this is the true type and not a placeholder — and it is a constant,
// not read from anywhere, because the holder must be recomputable from the reference alone.
mediaArchive = "application/vnd.oci.image.layer.v1.tar+gzip"
)
// emptyConfig is the content emptyDigest names.
var emptyConfig = []byte("{}")
// manifestAccept is what a manifest is asked for as. A registry answers a manifest HEAD only in a
// type the caller named, and answers 404 to a bare one for a manifest it holds perfectly well
// (measured 2026-09-28; internal/builder/registry.go says how that was found).
var manifestAccept = []string{
mediaManifest,
"application/vnd.docker.distribution.manifest.v2+json",
}
type descriptor struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
type holderManifest struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config descriptor `json:"config"`
Layers []descriptor `json:"layers"`
}
// Holder is the manifest that holds an archive in the store, and its digest.
//
// A pure function of the archive's digest and size: the same two in give the same bytes out,
// always, because `encoding/json` writes a struct's fields in their declared order and there is
// nothing here that varies by when or where it was composed.
func Holder(digest string, size int64) (body []byte, holder string) {
body, err := json.Marshal(holderManifest{
SchemaVersion: 2,
MediaType: mediaManifest,
Config: descriptor{MediaType: mediaEmpty, Digest: emptyDigest, Size: int64(len(emptyConfig))},
Layers: []descriptor{{MediaType: mediaArchive, Digest: digest, Size: size}},
})
if err != nil {
// Marshalling a struct of strings and integers cannot fail.
panic(err)
}
sum := sha256.Sum256(body)
return body, "sha256:" + hex.EncodeToString(sum[:])
}
// Hold makes sure the store holds this archive by a manifest, and says whether it had to write one.
//
// Takes a reference as the mesh records it. An image is its own manifest and needs no holder, so
// it answers false and nothing is asked. Idempotent: a holder already there is left alone, which
// is what lets the sweep run it over every kept archive on every build and so backfill the bare
// blobs published before holders existed (novox/hq issue 253).
//
// Gone when the store does not hold the archive at all: there is nothing to hold, and that is a
// fact the caller reports rather than one this invents a remedy for.
func (s Store) Hold(ctx context.Context, reference string) (bool, error) {
repository, digest, archive, err := s.archive(reference)
if err != nil || !archive {
return false, err
}
size, err := s.blobSize(ctx, repository, digest)
if err != nil {
return false, err
}
return s.HoldBlob(ctx, repository, digest, size)
}
// Held is whether the store holds this archive by its manifest. Asks and changes nothing — the
// question an operator needs answered with "none unheld" before the collector is let loose.
//
// An image answers true: it is its own manifest. An archive the store does not have answers Gone.
func (s Store) Held(ctx context.Context, reference string) (bool, error) {
repository, digest, archive, err := s.archive(reference)
if err != nil {
return false, err
}
if !archive {
return true, nil
}
size, err := s.blobSize(ctx, repository, digest)
if err != nil {
return false, err
}
_, holder := Holder(digest, size)
return s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...)
}
// HoldBlob puts the holder for a blob of this digest and size into its repository, unless it is
// there already. Answers whether it wrote one.
//
// The builder calls this with the size it has just uploaded; the sweep, through Hold, with the size
// the store reports. Both arrive at the same holder, which is the point of composing it.
func (s Store) HoldBlob(ctx context.Context, repository, digest string, size int64) (bool, error) {
if s.Address == "" {
return false, fmt.Errorf("this mesh has no artifact store on its network to hold %s/%s in", repository, digest)
}
body, holder := Holder(digest, size)
there, err := s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...)
if err != nil {
return false, err
}
if there {
return false, nil
}
// The config must be in the repository before a manifest naming it is accepted: a registry
// refuses a manifest whose blobs it cannot find there, which is the property that makes a
// holder mean something.
if err := s.putBlob(ctx, repository, emptyDigest, emptyConfig); err != nil {
return false, err
}
// **By digest, never by tag.** A tag would be one more name to move and one more thing the
// collector's `--delete-untagged` would read as meaningful; the mesh names nothing by tag that
// it pins by digest, and an untagged manifest is kept by plain collection.
request, err := http.NewRequestWithContext(ctx, http.MethodPut,
s.url(repository, "manifests", holder), bytes.NewReader(body))
if err != nil {
return false, err
}
request.Header.Set("Content-Type", mediaManifest)
response, err := s.client().Do(request)
if err != nil {
return false, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
said, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return false, fmt.Errorf("the artifact store refused to hold %s/%s: %s %s",
repository, digest, response.Status, strings.TrimSpace(string(said)))
}
return true, nil
}
// letGoOfHolder deletes the manifest holding an archive, before the archive's own link goes.
//
// **Holder first.** Deleting the blob link alone leaves a manifest still naming the blob, and the
// collector would keep its bytes for ever on the strength of it — the sweep would record the
// archive collected while the disk said otherwise. Deleting the holder first and failing before
// the link goes leaves an unheld archive that the next sweep still offers, which is safe.
//
// A store that no longer has the blob answers Gone: without its size the holder cannot be named,
// and without the blob there is nothing left for a holder to keep. A store that never had a holder
// for it — an archive published before holders, never backfilled — answers 404 to the delete, and
// that is the outcome wanted.
func (s Store) letGoOfHolder(ctx context.Context, repository, digest string) error {
size, err := s.blobSize(ctx, repository, digest)
if err != nil {
return err
}
_, holder := Holder(digest, size)
err = s.remove(ctx, s.url(repository, "manifests", holder), repository+"/manifests/"+holder)
if err == Gone {
return nil
}
return err
}
// archive reads a recorded reference into its repository and digest, and whether it is an archive
// at all. Refuses as ErrNotOurs anything the mesh did not put in its own store.
func (s Store) archive(reference string) (repository, digest string, archive bool, err error) {
path, kept := catalogue.InArtifactStore(reference)
if !kept {
return "", "", false, fmt.Errorf("%w: %s", ErrNotOurs, reference)
}
if s.Address == "" {
return "", "", false, fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
}
repository, kind, digest, err := split(path)
if err != nil {
return "", "", false, err
}
return repository, digest, kind == "blobs", nil
}
// blobSize is how large the store says a blob is; Gone when it does not have it.
func (s Store) blobSize(ctx context.Context, repository, digest string) (int64, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodHead, s.url(repository, "blobs", digest), nil)
if err != nil {
return 0, err
}
response, err := s.client().Do(request)
if err != nil {
return 0, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return 0, Gone
default:
return 0, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest)
}
// Read from the header rather than ContentLength: a HEAD's ContentLength is what the response
// says it would have sent, which Go reports faithfully, but a proxy in between is free to drop
// it, and the header is what the registry itself wrote.
if length := response.Header.Get("Content-Length"); length != "" {
if n, err := strconv.ParseInt(length, 10, 64); err == nil && n >= 0 {
return n, nil
}
}
if response.ContentLength >= 0 {
return response.ContentLength, nil
}
return 0, fmt.Errorf("the artifact store holds %s/blobs/%s and will not say how large it is", repository, digest)
}
// putBlob uploads a small blob unless the repository already has it: ask where, then put it there
// naming the digest — the registry's own two steps, the same the builder takes for an archive.
func (s Store) putBlob(ctx context.Context, repository, digest string, body []byte) error {
if there, err := s.has(ctx, s.url(repository, "blobs", digest)); err != nil {
return err
} else if there {
return nil
}
start, err := http.NewRequestWithContext(ctx, http.MethodPost,
"http://"+s.Address+"/v2/"+repository+"/blobs/uploads/", nil)
if err != nil {
return err
}
begun, err := s.client().Do(start)
if err != nil {
return fmt.Errorf("cannot start an upload to %s: %w", repository, err)
}
begun.Body.Close()
if begun.StatusCode != http.StatusAccepted {
return fmt.Errorf("the artifact store answered %s when asked where to put a blob in %s", begun.Status, repository)
}
where := begun.Header.Get("Location")
if where == "" {
return fmt.Errorf("the artifact store accepted an upload to %s and said nowhere to put it", repository)
}
if strings.HasPrefix(where, "/") {
where = "http://" + s.Address + where
}
separator := "?"
if strings.Contains(where, "?") {
separator = "&"
}
put, err := http.NewRequestWithContext(ctx, http.MethodPut, where+separator+"digest="+digest, bytes.NewReader(body))
if err != nil {
return err
}
put.Header.Set("Content-Type", "application/octet-stream")
done, err := s.client().Do(put)
if err != nil {
return err
}
defer done.Body.Close()
if done.StatusCode != http.StatusCreated {
said, _ := io.ReadAll(io.LimitReader(done.Body, 4096))
return fmt.Errorf("the artifact store refused a blob in %s: %s %s", repository, done.Status, strings.TrimSpace(string(said)))
}
return nil
}
// has is whether the store answers 200 for a HEAD at that URL.
func (s Store) has(ctx context.Context, url string, accept ...string) (bool, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return false, err
}
for _, media := range accept {
request.Header.Add("Accept", media)
}
response, err := s.client().Do(request)
if err != nil {
return false, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("the artifact store answered %s for %s", response.Status, url)
}
}
func (s Store) url(repository, kind, digest string) string {
return "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
}
func (s Store) client() *http.Client {
if s.HTTP != nil {
return s.HTTP
}
return &http.Client{Timeout: 30 * time.Second}
}
@@ -0,0 +1,268 @@
package registry
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Every kept archive is held by a manifest (novox/hq issue 253, ADR 0189).
//
// Against an in-memory registry that keeps blobs and manifests per repository and refuses what a
// registry refuses — a blob whose digest does not match, a manifest whose digest does not match
// or whose blobs the repository does not have, a manifest asked for without an Accept naming its
// type. What is asserted is this side's decisions; the live test below asserts the registry's.
type memRegistry struct {
mu sync.Mutex
blobs map[string][]byte // repository + "@" + digest
manifests map[string][]byte // repository + "@" + digest
writes []string // every PUT and DELETE, as "METHOD path"
}
func digestOf(body []byte) string {
sum := sha256.Sum256(body)
return "sha256:" + hex.EncodeToString(sum[:])
}
func (m *memRegistry) serve(t *testing.T) Store {
t.Helper()
m.blobs = map[string][]byte{}
m.manifests = map[string][]byte{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
path := strings.TrimPrefix(r.URL.Path, "/v2/")
if r.Method == http.MethodPut || r.Method == http.MethodDelete {
m.writes = append(m.writes, r.Method+" "+r.URL.Path)
}
switch {
case r.Method == http.MethodPost && strings.HasSuffix(path, "/blobs/uploads/"):
repository := strings.TrimSuffix(path, "/blobs/uploads/")
w.Header().Set("Location", "/upload/"+repository+"?state=x")
w.WriteHeader(http.StatusAccepted)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"):
repository := strings.TrimPrefix(r.URL.Path, "/upload/")
body, _ := io.ReadAll(r.Body)
digest := r.URL.Query().Get("digest")
if digest != digestOf(body) {
w.WriteHeader(http.StatusBadRequest)
return
}
m.blobs[repository+"@"+digest] = body
w.WriteHeader(http.StatusCreated)
case strings.Contains(path, "/blobs/"):
repository, digest, _ := strings.Cut(path, "/blobs/")
key := repository + "@" + digest
body, ok := m.blobs[key]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case http.MethodHead:
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(http.StatusOK)
case http.MethodDelete:
delete(m.blobs, key)
w.WriteHeader(http.StatusAccepted)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
case strings.Contains(path, "/manifests/"):
repository, digest, _ := strings.Cut(path, "/manifests/")
key := repository + "@" + digest
switch r.Method {
case http.MethodHead:
if _, ok := m.manifests[key]; !ok || !strings.Contains(r.Header.Get("Accept"), mediaManifest) {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
case http.MethodPut:
body, _ := io.ReadAll(r.Body)
if digest != digestOf(body) {
w.WriteHeader(http.StatusBadRequest)
return
}
var named holderManifest
if err := json.Unmarshal(body, &named); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
for _, d := range append([]descriptor{named.Config}, named.Layers...) {
if _, ok := m.blobs[repository+"@"+d.Digest]; !ok {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "MANIFEST_BLOB_UNKNOWN %s", d.Digest)
return
}
}
m.manifests[key] = body
w.WriteHeader(http.StatusCreated)
case http.MethodDelete:
if _, ok := m.manifests[key]; !ok {
w.WriteHeader(http.StatusNotFound)
return
}
delete(m.manifests, key)
w.WriteHeader(http.StatusAccepted)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
return Store{Address: strings.TrimPrefix(server.URL, "http://")}
}
// bare puts an archive in the store the way the builder did before holders: a blob, nothing more.
func (m *memRegistry) bare(repository string, body []byte) string {
m.mu.Lock()
defer m.mu.Unlock()
digest := digestOf(body)
m.blobs[repository+"@"+digest] = body
return catalogue.ArtifactStoreScheme + repository + "/blobs/" + digest
}
func TestTheHolderIsComposedFromTheDigestAndSizeAlone(t *testing.T) {
// The sweep must arrive at the very manifest the builder wrote, with nothing recorded between
// them. Same inputs, same bytes — and a different size is a different holder, so a holder can
// never be mistaken for one of a different blob.
digest := "sha256:" + strings.Repeat("a", 64)
one, first := Holder(digest, 42)
two, second := Holder(digest, 42)
if !bytes.Equal(one, two) || first != second {
t.Fatalf("the same archive composed two holders:\n%s\n%s", one, two)
}
if _, other := Holder(digest, 43); other == first {
t.Fatal("a different size composed the same holder")
}
want := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` +
`"config":{"mediaType":"application/vnd.oci.empty.v1+json",` +
`"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` +
`"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"` + digest + `","size":42}]}`
if string(one) != want {
t.Fatalf("the holder is\n%s\nwant\n%s", one, want)
}
if digestOf(emptyConfig) != emptyDigest {
t.Fatalf("the empty config's digest is %s, not %s", digestOf(emptyConfig), emptyDigest)
}
}
func TestHoldBackfillsABareArchiveAndIsIdempotent(t *testing.T) {
// The archives published before this have no holder. Hold, run over every kept archive on
// every sweep, writes one the first time and nothing after.
m := &memRegistry{}
store := m.serve(t)
ctx := context.Background()
body := []byte("a theme")
reference := m.bare("shell/config", body)
if held, err := store.Held(ctx, reference); err != nil || held {
t.Fatalf("a bare blob reads as held=%v (%v)", held, err)
}
wrote, err := store.Hold(ctx, reference)
if err != nil {
t.Fatal(err)
}
if !wrote {
t.Fatal("holding a bare archive wrote nothing")
}
_, holder := Holder(digestOf(body), int64(len(body)))
if _, ok := m.manifests["shell/config@"+holder]; !ok {
t.Fatalf("the store holds manifests %v; want %s", m.manifests, holder)
}
if held, err := store.Held(ctx, reference); err != nil || !held {
t.Fatalf("after holding, held=%v (%v)", held, err)
}
writes := len(m.writes)
wrote, err = store.Hold(ctx, reference)
if err != nil {
t.Fatal(err)
}
if wrote || len(m.writes) != writes {
t.Fatalf("holding again wrote %v", m.writes[writes:])
}
}
func TestAnImageNeedsNoHolderAndAMissingArchiveIsGone(t *testing.T) {
m := &memRegistry{}
store := m.serve(t)
ctx := context.Background()
// An image is its own manifest: nothing is asked.
wrote, err := store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/app@sha256:"+strings.Repeat("b", 64))
if err != nil || wrote || len(m.writes) != 0 {
t.Fatalf("holding an image wrote=%v err=%v writes=%v", wrote, err, m.writes)
}
// An archive the store does not have is a fact to report, not something to invent a holder for.
_, err = store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/config/blobs/sha256:"+strings.Repeat("c", 64))
if !errors.Is(err, Gone) {
t.Fatalf("holding a missing archive answered %v, want Gone", err)
}
// And a reference that is not the mesh's is refused as such.
if _, err := store.Hold(ctx, "docker.io/library/registry@sha256:abc"); !errors.Is(err, ErrNotOurs) {
t.Fatalf("holding a vendor's image answered %v, want ErrNotOurs", err)
}
}
func TestLettingGoOfAnArchiveDeletesItsHolderFirst(t *testing.T) {
// A holder left behind would keep the bytes through every collection while the record said
// collected; the link deleted first and the holder failing after would be that exactly.
m := &memRegistry{}
store := m.serve(t)
ctx := context.Background()
body := []byte("an old theme")
reference := m.bare("shell/config", body)
if _, err := store.Hold(ctx, reference); err != nil {
t.Fatal(err)
}
m.writes = nil
if err := store.LetGo(ctx, reference); err != nil {
t.Fatal(err)
}
_, holder := Holder(digestOf(body), int64(len(body)))
want := []string{
"DELETE /v2/shell/config/manifests/" + holder,
"DELETE /v2/shell/config/blobs/" + digestOf(body),
}
if strings.Join(m.writes, "\n") != strings.Join(want, "\n") {
t.Fatalf("the store was asked\n%s\nwant\n%s", strings.Join(m.writes, "\n"), strings.Join(want, "\n"))
}
if len(m.manifests) != 0 {
t.Fatalf("a holder survived: %v", m.manifests)
}
// Asked again, the archive is already gone, which is the outcome wanted.
if err := store.LetGo(ctx, reference); !errors.Is(err, Gone) {
t.Fatalf("letting go twice answered %v, want Gone", err)
}
}
func TestLettingGoOfAnUnheldArchiveStillDeletesIt(t *testing.T) {
// An archive published before holders and let go of before any sweep held it: the holder's
// delete answers 404, which is the outcome wanted, and the blob still goes.
m := &memRegistry{}
store := m.serve(t)
reference := m.bare("shell/config", []byte("never held"))
if err := store.LetGo(context.Background(), reference); err != nil {
t.Fatal(err)
}
if len(m.blobs) != 0 {
t.Fatalf("the blob survived: %v", m.blobs)
}
}
@@ -0,0 +1,176 @@
package registry
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// An index is let go of with the platform manifests it names, except those a kept index of the same
// repository names too (novox/hq ADR 0257). Each platform manifest is a manifest of the repository in
// its own right, and the store's collector keeps every manifest a repository holds — so an index let go
// of alone frees nothing of the images it names.
func digestN(n int) string { return fmt.Sprintf("sha256:%064x", n) }
// indexStore holds indexes and images in one repository, answers GETs with their documents, and records
// every delete.
type indexStore struct {
mu sync.Mutex
indexes map[string][]string // digest → the platform manifests it names
images map[string]bool
deleted []string
}
func (s *indexStore) serve(t *testing.T) Store {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
digest := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
switch r.Method {
case http.MethodGet:
if children, ok := s.indexes[digest]; ok {
var named []string
for _, c := range children {
named = append(named, `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"`+c+`","size":1}`)
}
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
strings.Join(named, ",") + `]}`))
return
}
if s.images[digest] {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`))
return
}
w.WriteHeader(http.StatusNotFound)
case http.MethodHead:
if _, ok := s.indexes[digest]; ok || s.images[digest] {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
case http.MethodDelete:
if _, ok := s.indexes[digest]; !ok && !s.images[digest] {
w.WriteHeader(http.StatusNotFound)
return
}
s.deleted = append(s.deleted, digest)
delete(s.indexes, digest)
delete(s.images, digest)
w.WriteHeader(http.StatusAccepted)
default:
w.WriteHeader(http.StatusBadRequest)
}
}))
t.Cleanup(server.Close)
return Store{Address: strings.TrimPrefix(server.URL, "http://")}
}
func TestAnIndexGoesWithItsPlatformsAndAKeptIndexKeepsItsOwn(t *testing.T) {
// Two indexes of one image's repository: the old one names platforms 11 and 12, the kept one 12 and 13.
s := &indexStore{
indexes: map[string][]string{digestN(1): {digestN(11), digestN(12)}, digestN(2): {digestN(12), digestN(13)}},
images: map[string]bool{digestN(11): true, digestN(12): true, digestN(13): true},
}
store := s.serve(t)
repository := "upstream/docker.io/library/golang"
old := catalogue.ArtifactStoreScheme + repository + "@" + digestN(1)
kept := catalogue.ArtifactStoreScheme + repository + "@" + digestN(2)
spare, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old})
if err != nil {
t.Fatal(err)
}
store.Spare = spare
if err := store.LetGo(context.Background(), old); err != nil {
t.Fatal(err)
}
// Its own platform first, the index last; the platform the kept index names is left in place.
if !slices.Equal(s.deleted, []string{digestN(11), digestN(1)}) {
t.Fatalf("deleted %v; want the old index's own platform, then the index", s.deleted)
}
if !s.images[digestN(12)] || !s.images[digestN(13)] {
t.Fatal("a platform a kept index names was let go of")
}
}
func TestWithoutSparingAnIndexGoesAloneAndAnImageNamesNoPlatforms(t *testing.T) {
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true, digestN(5): true}}
store := s.serve(t)
// No Spare: as before ADR 0257, the index alone.
if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1)); err != nil {
t.Fatal(err)
}
if !slices.Equal(s.deleted, []string{digestN(1)}) {
t.Fatalf("without a Spare, deleted %v", s.deleted)
}
// An image is one delete, with a Spare or without.
s.deleted = nil
spare, err := store.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "web/app@" + digestN(5)})
if err != nil {
t.Fatal(err)
}
store.Spare = spare
if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@"+digestN(5)); err != nil {
t.Fatal(err)
}
if !slices.Equal(s.deleted, []string{digestN(5)}) {
t.Fatalf("an image was let go of as %v", s.deleted)
}
// An index the store no longer holds is Gone, and nothing is deleted on its account.
s.deleted = nil
err = store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(9))
if !errors.Is(err, Gone) || len(s.deleted) != 0 {
t.Fatalf("an index the store does not hold: %v, deleted %v", err, s.deleted)
}
}
func TestASpareThatCannotBeReadKeepsTheIndex(t *testing.T) {
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}}
store := s.serve(t)
store.Spare = func(context.Context, string) (map[string]bool, error) { return nil, fmt.Errorf("the store went away") }
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1))
if err == nil || !strings.Contains(err.Error(), "was kept") {
t.Fatalf("a spare that could not be read: %v", err)
}
if len(s.deleted) != 0 {
t.Fatalf("deleted %v without knowing what a kept index names", s.deleted)
}
}
// The spare list is read whole before the sweep: a kept index that cannot be read is an error before
// anything is deleted, and a repository not read before keeps its index.
func TestASpareListIsReadWholeBeforeAnythingGoes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
store := Store{Address: strings.TrimPrefix(server.URL, "http://")}
kept := catalogue.ArtifactStoreScheme + "web/base@" + digestN(2)
old := catalogue.ArtifactStoreScheme + "web/base@" + digestN(1)
if _, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old}); err == nil {
t.Fatal("a kept index the store would not answer for was taken as naming nothing")
}
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}}
good := s.serve(t)
spare, err := good.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "other/repo@" + digestN(3)})
if err != nil {
t.Fatal(err)
}
good.Spare = spare
if err := good.LetGo(context.Background(), old); err == nil || len(s.deleted) != 0 {
t.Fatalf("an index in a repository not read before the sweep was let go of: %v, deleted %v", err, s.deleted)
}
}
@@ -1,76 +0,0 @@
package registry
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
)
// blobSize is how large the store says a blob is; Gone when it does not have it.
func (s Store) blobSize(ctx context.Context, repository, digest string) (int64, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodHead, s.url(repository, "blobs", digest), nil)
if err != nil {
return 0, err
}
response, err := s.client().Do(request)
if err != nil {
return 0, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return 0, Gone
default:
return 0, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest)
}
// Read from the header rather than ContentLength: a HEAD's ContentLength is what the response
// says it would have sent, which Go reports faithfully, but a proxy in between is free to drop
// it, and the header is what the registry itself wrote.
if length := response.Header.Get("Content-Length"); length != "" {
if n, err := strconv.ParseInt(length, 10, 64); err == nil && n >= 0 {
return n, nil
}
}
if response.ContentLength >= 0 {
return response.ContentLength, nil
}
return 0, fmt.Errorf("the artifact store holds %s/blobs/%s and will not say how large it is", repository, digest)
}
// has is whether the store answers 200 for a HEAD at that URL.
func (s Store) has(ctx context.Context, url string, accept ...string) (bool, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return false, err
}
for _, media := range accept {
request.Header.Add("Accept", media)
}
response, err := s.client().Do(request)
if err != nil {
return false, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("the artifact store answered %s for %s", response.Status, url)
}
}
func (s Store) url(repository, kind, digest string) string {
return "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
}
func (s Store) client() *http.Client {
if s.HTTP != nil {
return s.HTTP
}
return &http.Client{Timeout: 30 * time.Second}
}
@@ -1,6 +1,13 @@
// Package registry reads the mesh's artifact store over its own door: whether it holds what a reference names, and
// whether a manifest is an index. It writes nothing: what the artifact sweep holds and removes it asks the registry
// seats the store's holder serves (CAT-AS1, novox/hq ADR 0617 §2).
// Package registry speaks to the mesh's artifact store over its own door.
//
// Only what the mesh needs that nothing else does: letting go of something it put there
// (novox/hq ADR 0189, issue 108), and holding every archive it keeps by a manifest so the store's
// own collector does not take it (novox/hq issue 253). Pushing is the builder's, through the container runtime; reading
// is every machine's, through its runtime. This is the one operation that belongs to the thing
// holding the records, because it is the only one that is a decision rather than a transfer.
//
// Copied from mesh-controller internal/artifacts at e04846e9 (novox/hq ADR 0571 clause 2): the seat mesh-artifact-sweep lets
// go of what it no longer keeps exactly as the controller's sweep did; the controller's copy goes with its lease.
package registry
import (
@@ -22,6 +29,10 @@ type Store struct {
Address string
// HTTP is the client used; nil is a client with a modest timeout.
HTTP *http.Client
// Spare says, for one repository, which manifests a kept index there names: the platform
// manifests an index being let go of must leave in place (novox/hq ADR 0257). Nil spares every
// platform manifest — an index is then let go of alone, as it was before.
Spare func(ctx context.Context, repository string) (map[string]bool, error)
}
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
@@ -35,6 +46,86 @@ var Gone = errors.New("the store does not hold it")
// error" is how a cautious loop became one that did nothing while reporting the right number.
var ErrNotOurs = errors.New("not a reference into the mesh's artifact store")
// LetGo asks the store to drop one artifact the mesh recorded making.
//
// Takes a reference as the mesh records it — `artifact-store://<module>/<artifact>@sha256:…` for
// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses,
// and composes the address here at the moment of use.
//
// An archive is let go of in two deletes, its holder manifest and then the blob's link (novox/hq
// issue 253); an image in one.
//
// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
// which of the two happened.
func (s Store) LetGo(ctx context.Context, reference string) error {
// **Strict, and deliberately** (novox/hq issue 226). Only a reference the mesh keeps in its
// own vocabulary is addressed here. `Recorded` would read `docker.io/library/registry@sha256:…`
// as the mesh's too — it cannot tell one registry host from another — so normalising belongs
// where the provenance is known, which is the sweep reading its own build records, not here
// where the only job is to refuse anything that is not plainly ours.
path, kept := catalogue.InArtifactStore(reference)
if !kept {
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
// delete for a reference of unknown shape is how a sweep reaches something that is not
// the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on.
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
}
if s.Address == "" {
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
}
repository, kind, digest, err := split(path)
if err != nil {
return err
}
if kind == "blobs" {
// **An archive's holder goes before the archive** (novox/hq issue 253): a manifest left
// naming the blob would keep its bytes through every collection while the record said
// collected. Gone here means the store has no such blob, so there is nothing to let go.
if err := s.letGoOfHolder(ctx, repository, digest); err != nil {
return err
}
}
if kind == "manifests" && s.Spare != nil {
// **An index's platform manifests go before the index** (novox/hq ADR 0257). Each was put
// under its own digest, so each is a manifest of the repository in its own right, and the
// store's collector keeps every manifest a repository holds: an index let go of alone frees
// no byte of the images it names. Before, not after, so a sweep cut short leaves the index
// to be asked about again, and with it the list of what is still to go.
if err := s.letGoOfPlatforms(ctx, repository, digest, reference); err != nil {
return err
}
}
return s.remove(ctx, s.url(repository, kind, digest), reference)
}
// letGoOfPlatforms deletes the manifests an index names, except those a kept index of the same
// repository also names. A manifest that is not an index names none.
func (s Store) letGoOfPlatforms(ctx context.Context, repository, digest, reference string) error {
children, err := s.Platforms(ctx, repository, digest)
if err != nil || len(children) == 0 {
// Gone: there is no index to read, and the delete that follows says so.
if errors.Is(err, Gone) {
return nil
}
return err
}
spared, err := s.Spare(ctx, repository)
if err != nil {
return fmt.Errorf("cannot say which platform manifests of %s a kept index names, so %s was kept: %w",
repository, reference, err)
}
for _, child := range children {
if spared[child] {
continue
}
if err := s.remove(ctx, s.url(repository, "manifests", child), reference+" (its "+child+")"); err != nil && !errors.Is(err, Gone) {
return err
}
}
return nil
}
// anyManifest is every manifest the store may hold, indexes included, so it answers with the
// document as it is.
var anyManifest = []string{
@@ -81,6 +172,84 @@ func (s Store) Platforms(ctx context.Context, repository, digest string) ([]stri
return out, nil
}
// SpareKeptIndexes is a Spare for a sweep, read whole before it starts: for every repository an
// image reference of `touching` is in, each kept manifest there and every platform manifest a kept
// index there names. Any read that fails is an error, and the sweep lets nothing go. A repository not
// read here is answered with an error, so an index in it is kept. A kept index the store no longer
// holds names nothing.
func (s Store) SpareKeptIndexes(ctx context.Context, kept, touching []string) (func(context.Context, string) (map[string]bool, error), error) {
imageIn := func(reference string) (repository, digest string, ok bool) {
path, ours := catalogue.InArtifactStore(reference)
if !ours {
return "", "", false
}
repository, kind, digest, err := split(path)
if err != nil || kind != "manifests" {
return "", "", false
}
return repository, digest, true
}
read := map[string]map[string]bool{}
for _, reference := range touching {
if repository, _, ok := imageIn(reference); ok {
read[repository] = map[string]bool{}
}
}
for _, reference := range kept {
repository, digest, ok := imageIn(reference)
spared, touched := read[repository]
if !ok || !touched {
continue
}
spared[digest] = true
children, err := s.Platforms(ctx, repository, digest)
if errors.Is(err, Gone) {
continue
}
if err != nil {
return nil, fmt.Errorf("reading the kept %s@%s: %w", repository, digest, err)
}
for _, c := range children {
spared[c] = true
}
}
return func(_ context.Context, repository string) (map[string]bool, error) {
spared, done := read[repository]
if !done {
return nil, fmt.Errorf("%s was not read before the sweep began", repository)
}
return spared, nil
}, nil
}
// remove asks the store to delete what is at url. Gone when it has no such thing.
func (s Store) remove(ctx context.Context, url, what string) error {
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return err
}
response, err := s.client().Do(request)
if err != nil {
return err
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusAccepted, http.StatusOK, http.StatusNoContent:
return nil
case http.StatusNotFound:
return Gone
case http.StatusMethodNotAllowed:
// The registry was started without deletion enabled. Said plainly, because the remedy is
// a setting on the store's module and not anything about this artifact.
return fmt.Errorf(
"the artifact store refuses deletion: its server was started without it enabled "+
"(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+
"module is applied again (novox/hq ADR 0189). Asking about %s", what)
default:
return fmt.Errorf("the artifact store answered %s for %s", response.Status, what)
}
}
// split reads a recorded path into the repository, which endpoint names the thing, and the digest.
//
// Two shapes, which are the two the mesh records: `<repository>@sha256:<hex>` is a manifest, and
@@ -110,6 +279,22 @@ func (s Store) IsIndex(ctx context.Context, reference string) (bool, error) {
return len(children) > 0, err
}
// HoldsManifest is whether the store holds the manifest a recorded image reference names.
func (s Store) HoldsManifest(ctx context.Context, reference string) (bool, error) {
path, ours := catalogue.InArtifactStore(reference)
if !ours {
return false, fmt.Errorf("%w: %s", ErrNotOurs, reference)
}
repository, kind, digest, err := split(path)
if err != nil {
return false, err
}
if kind != "manifests" {
return false, fmt.Errorf("%w: %s names a blob, not a manifest", ErrNotOurs, reference)
}
return s.has(ctx, s.url(repository, kind, digest), anyManifest...)
}
// Holds is whether the store holds what a recorded reference names: the blob of an archive, the manifest of an image.
// Gone when it does not; asks and changes nothing (ADR 0526 clause 3: a registration by hand is refused at bytes that
// are not there).
@@ -11,66 +11,124 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The store's door is read only (CAT-AS1: what the sweep holds and removes it asks the registry seats): Holds says
// whether the store holds what a reference names, in either reference form, IsIndex whether it is an index; a reference
// that is not the mesh's own is never asked. A fake store answers by path and records what it was asked.
func TestTheDoorReadsWhatTheStoreHoldsAndWritesNothing(t *testing.T) {
const index = `{"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"sha256:c1"}]}`
const image = `{"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`
// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
//
// A fake store records what it was asked to delete, so what is asserted is the mesh's decision
// and the shape of the request — not the registry's behaviour, which is the registry's to test.
func fakeStore(t *testing.T, answer int) (Store, *[]string) {
t.Helper()
var asked []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked = append(asked, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/v2/web/app/manifests/sha256:abc", "/v2/web/files/blobs/sha256:def":
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(image))
} else {
w.Header().Set("Content-Length", "7")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/") {
// An archive's size, asked so its holder can be named (novox/hq issue 253). A store
// that does not have the thing does not have its blob either.
if answer == http.StatusNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
case "/v2/web/app/manifests/sha256:idx":
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(index))
}
default:
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Length", "7")
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodDelete {
t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path)
}
asked = append(asked, r.URL.Path)
w.WriteHeader(answer)
}))
defer srv.Close()
store := Store{Address: strings.TrimPrefix(srv.URL, "http://")}
t.Cleanup(server.Close)
return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
}
func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) {
// The two shapes the mesh records: a manifest by digest, and a blob by digest. They are
// different endpoints, and asking at the wrong one answers 404 — which this would then
// record as collected, leaving the bytes on disk for ever while the record says otherwise.
store, asked := fakeStore(t, http.StatusAccepted)
ctx := context.Background()
for name, c := range map[string]struct {
reference string
holds error // nil, Gone or ErrNotOurs
index bool
}{
"an image": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:abc"},
"an image in the seat's form": {reference: catalogue.SeatReference("container-image-registry", "web", "app", "sha256:abc")},
"an archive": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:def"},
"an archive in the seat's form": {reference: catalogue.SeatReference("oci-artifact-registry", "web", "files", "sha256:def")},
"an index": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:idx", index: true},
"an image the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:999", holds: Gone},
"an archive the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:999", holds: Gone},
"another registry's image": {reference: "docker.io/library/alpine@sha256:abc", holds: ErrNotOurs},
"a reference naming no digest": {reference: catalogue.ArtifactStoreScheme + "web/app:latest", holds: ErrNotOurs},
image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123"
archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456"
if err := store.LetGo(ctx, image); err != nil {
t.Fatal(err)
}
if err := store.LetGo(ctx, archive); err != nil {
t.Fatal(err)
}
// The archive's holder goes first, then the archive (novox/hq issue 253).
_, holder := Holder("sha256:def456", 7)
want := []string{
"/v2/web/app/manifests/sha256:abc123",
"/v2/web/config/manifests/" + holder,
"/v2/web/config/blobs/sha256:def456",
}
if strings.Join(*asked, " ") != strings.Join(want, " ") {
t.Fatalf("the store was asked %v; want %v", *asked, want)
}
}
func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) {
// The outcome wanted, already true. Told apart from success only so the sweep can say which
// happened; both are recorded, because retrying for ever is the thing to avoid.
store, _ := fakeStore(t, http.StatusNotFound)
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
if !errors.Is(err, Gone) {
t.Fatalf("a store that does not hold it answered %v, want Gone", err)
}
}
func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) {
// The registry answers 405 when it was started without deletion enabled. The remedy is a
// setting on the store's module, and saying "405" would send somebody to the wrong place.
store, _ := fakeStore(t, http.StatusMethodNotAllowed)
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
if err == nil {
t.Fatal("a store that refuses deletion was read as success")
}
if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") {
t.Fatalf("the refusal does not name the remedy: %v", err)
}
}
func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
// The whole safety of the sweep is that it names only what the mesh recorded putting there.
// A reference of another shape — a vendor's image, a package version — is refused rather
// than composed into a delete somewhere that is not the mesh's store.
store, asked := fakeStore(t, http.StatusAccepted)
for _, reference := range []string{
"docker.io/library/registry@sha256:abc123",
"registry@sha256:abc123",
"1.4.2",
} {
t.Run(name, func(t *testing.T) {
asked = nil
if err := store.Holds(ctx, c.reference); !errors.Is(err, c.holds) && (err != nil || c.holds != nil) {
t.Errorf("Holds = %v, want %v", err, c.holds)
}
if c.holds == nil {
if got, err := store.IsIndex(ctx, c.reference); err != nil || got != c.index {
t.Errorf("IsIndex = %v, %v, want %v", got, err, c.index)
}
}
for _, a := range asked {
if !strings.HasPrefix(a, "HEAD ") && !strings.HasPrefix(a, "GET ") {
t.Errorf("the door asked %s: it writes nothing", a)
}
}
if errors.Is(c.holds, ErrNotOurs) && len(asked) != 0 {
t.Errorf("a reference that is not the mesh's own was asked: %v", asked)
}
})
if err := store.LetGo(context.Background(), reference); err == nil {
t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference)
}
}
if len(*asked) != 0 {
t.Fatalf("the store was asked about %v", *asked)
}
}
// A reference this sweep will not address says so as ErrNotOurs, which is a fact about the
// record and not about the store (novox/hq issue 226).
//
// The sweep skips one and abandons itself for the other, so they cannot be the same error. The
// first live run met a reference recorded with the store's old address, read the refusal as "the
// store refuses everything", and collected none of the 1681 it had found.
func TestAReferenceThisSweepWillNotAddressIsToldApartFromAStoreRefusing(t *testing.T) {
store, asked := fakeStore(t, http.StatusAccepted)
for _, reference := range []string{
"docker.io/library/registry@sha256:abc123",
"127.0.0.1:5100/mesh-tools/build@sha256:abc123",
"1.4.2",
} {
err := store.LetGo(context.Background(), reference)
if !errors.Is(err, ErrNotOurs) {
t.Errorf("%s answered %v; a sweep must be able to skip it and go on", reference, err)
}
}
if len(*asked) != 0 {
t.Fatalf("the store was asked about %v", *asked)
}
}
+5 -17
View File
@@ -19,26 +19,16 @@
],
"invokes": [
"seat:mesh-hand-acts.hand-act",
"seat:mesh-catalog.modules",
"seat:mesh-catalog.module",
"mesh-delivery-assigner.assigner_read_wants",
"mesh-delivery-indexer.indexer_read_manifests",
"seat:oci-artifact-registry.hold",
"seat:oci-artifact-registry.release",
"seat:oci-artifact-registry.remove",
"seat:container-image-registry.hold",
"seat:container-image-registry.release",
"seat:container-image-registry.remove"
"mesh-catalog.catalog_modules",
"mesh-catalog.catalog_module"
],
"reads": [
"mesh-delivery-assigner.wants",
"mesh-delivery-builder.builds",
"mesh-delivery-builder.build-log",
"mesh-delivery-indexer.manifests",
"node-engine.have"
],
"consumes": [
"mesh-delivery-assigner.views.wants",
"mesh-delivery-indexer.views.manifests"
],
"state": [
"findings"
],
@@ -46,9 +36,7 @@
"artifact_store_repositories",
"artifact_store_usage",
"artifact_store_references",
"artifact_store_collect",
"artifact_store_first_views",
"artifact_store_first_view_again"
"artifact_store_collect"
],
"config": {
"older-store-names": {
+2 -2
View File
@@ -2,10 +2,10 @@ module asuszephyrusg14
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+2 -2
View File
@@ -2,10 +2,10 @@ module avahi
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+2 -2
View File
@@ -2,10 +2,10 @@ module blueman
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+2 -2
View File
@@ -2,10 +2,10 @@ module bluetooth
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+5 -6
View File
@@ -3,21 +3,20 @@ module brother
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
require (
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.16.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+10 -12
View File
@@ -1,17 +1,15 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -25,5 +23,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
@@ -269,18 +269,12 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
Invokes []string `json:"invokes"`
Reads []string `json:"reads"`
Consumes []string `json:"consumes"`
Tools []string `json:"tools"`
Invokes []string `json:"invokes"`
}
_ = json.Unmarshal(raw, &m)
// The hand-act seat's warranted and hand-act, and the owners' read tools of the three followed states; no verb of
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", ModulesTool, ManifestsTool, PlacementTool}) ||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.placement"}) {
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted"}) {
t.Errorf("invokes = %v, want the hand-act seat's warranted alone", m.Invokes)
}
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
strings.Contains(string(src), "seat:mesh-controller.warranted") {
@@ -291,7 +285,6 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
listed[n] = true
}
p, _ := node(t, "laptop")
meshViews = newMeshViews()
served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p))
for _, tool := range served {
if !listed[tool.Name] {
+6 -7
View File
@@ -25,8 +25,7 @@ import (
"path/filepath"
"strings"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
@@ -56,16 +55,16 @@ func (f *ForgeHealth) key() string { return "claude-code.machine." + f.Node + ".
// Look is the condition to say after one placing of the credential, nil when nothing changed: raised on a failure, again
// when its reason changes, cleared when it places again; on the first look a success clears what a run before raised.
func (f *ForgeHealth) Look(err error) *oc.Condition {
func (f *ForgeHealth) Look(err error) *asks.Condition {
first := !f.looked
f.looked = true
subject := oc.ConditionSubject{Scope: "machine", ID: f.Node, Machine: f.Node}
subject := asks.Scope{Scope: "machine", ID: f.Node, Machine: f.Node}
if err == nil {
if f.raised == "" && !first {
return nil
}
f.raised = ""
return &oc.Condition{Key: f.key(), Subject: subject, State: occ.StateCleared}
return &asks.Condition{Key: f.key(), Subject: subject, State: asks.StateCleared}
}
why := err.Error()
if why == f.raised {
@@ -73,10 +72,10 @@ func (f *ForgeHealth) Look(err error) *oc.Condition {
}
f.raised = why
needs := "read claude-code's journal on " + f.Node + " and the owner of its forge credential file"
return &oc.Condition{Key: f.key(), Severity: occ.SeverityWarning, State: occ.StateRaised, Subject: subject,
return &asks.Condition{Key: f.key(), Severity: asks.SeverityWarning, State: asks.StateRaised, Subject: subject,
Headline: "Agents on " + f.Node + " cannot use the forge credential",
Summary: why, Needs: needs,
Explanation: occ.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " +
Explanation: asks.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " +
"clone or fetch the mesh's private repositories.",
Resolved: "The forge's read credential is placed again on " + f.Node + "."}
}
@@ -12,7 +12,7 @@ import (
"strings"
"testing"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/asks"
)
const agentToken = "0123456789abcdef0123456789abcdef01234567"
@@ -316,7 +316,7 @@ func TestTheForgeCredentialIsTheRuntimesAndItsFailureIsAConditionOfTheModule(t *
got := ""
if c != nil {
got = c.State
if _, err := occ.EncodeCondition(*c); err != nil {
if _, err := asks.EncodeCondition(*c); err != nil {
t.Errorf("look %d: the condition is refused: %v", i, err)
}
if c.Key != "claude-code.machine.anchor.forge-credential" {
@@ -9,25 +9,25 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the
// seats' contract and the modules' manifests say them.
func meshForTheGuard() GuardData {
defined := []seats.Seat{
{Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{
seats := []catalogue.Seat{
{Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{
{Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}},
{Name: "node-service-manager", Scope: "node", Serves: []seats.Verb{
{Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{
{Name: "status", Description: "One unit as the service manager sees it now: its states.", Replaces: []string{"systemctl status", "systemctl is-active"}},
{Name: "restart", Description: "Restart one unit.", Replaces: []string{"systemctl restart"}},
{Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}},
{Name: "node-hostname", Scope: "node", Serves: []seats.Verb{
{Name: "node-hostname", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{
{Name: "add", Description: "Add one address and its names to the operator's lines of /etc/hosts.", Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}}},
}
modules := []ModuleTools{{Module: "docker", On: []string{"anchor"}, Replaces: map[string][]string{"docker_logs": {"docker logs"}, "docker_list": {"docker ps"}}},
{Module: "systemd", On: []string{"anchor", "laptop"}}}
return GuardData{Node: "laptop", Replacements: ReplacementsOf(defined, modules), Machines: []Machine{
return GuardData{Node: "laptop", Replacements: ReplacementsOf(seats, modules), Machines: []Machine{
{Name: "anchor", Domains: []string{"anchor" + InternalSuffix, "anchor.example"}, Addresses: []string{"192.0.2.10", "10.10.0.1"}},
{Name: "laptop", Domains: []string{"laptop" + InternalSuffix}, Addresses: []string{"10.10.0.4"}},
}}
@@ -1,20 +1,16 @@
package main
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
)
// aBus is the states claude-code reads, as a test holds them: by read, key and value.
@@ -37,12 +33,12 @@ func (b aBus) Get(read, key string) ([]byte, bool, error) {
}
// someSeats are two seats as the seats' contract says them: one held once for the mesh, one on every machine.
func someSeats() []seats.Seat {
return []seats.Seat{
{Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{
func someSeats() []catalogue.Seat {
return []catalogue.Seat{
{Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{
{Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}},
{Name: "propose", Description: "Propose a change."}}},
{Name: "node-service-manager", Scope: "node", Serves: []seats.Verb{
{Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot.", Replaces: []string{"systemctl status", "systemctl is-active"}},
{Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}},
}
@@ -196,44 +192,10 @@ func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) {
}
}
// ADR 0615 §1: the tools and the placement come from the owners' first views and their views events, never their state.
// A reader whose first views are answered from the fixture reads what the state read gave, and a views event changes
// what it reads (CAT-CC3).
func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
bus := aMesh(t)
r := newMeshViews()
for _, name := range []string{ModulesRead, ManifestsRead, PlacementRead} {
state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".")
values := map[string]json.RawMessage{}
for k, v := range bus[name] {
values[k] = v
}
f := r.Follow(strings.Split(name, ".")[0], state, "x")
f.Ask = func(context.Context, string) (views.Answer, error) {
return views.Answer{V: views.Version, Owner: f.Owner, State: state, Revision: 5, Created: time.Unix(1, 0), Values: values, At: time.Now()}, nil
}
if err := f.Start(context.Background()); err != nil {
t.Fatal(err)
}
}
want, err := ReadMeshToolsFrom(bus, seats.List(), resolving)
if err != nil {
t.Fatal(err)
}
got, err := ReadMeshToolsFrom(viewStates{r}, seats.List(), resolving)
if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 {
t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want)
}
// A reader not yet answered says so, and reads nothing.
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, seats.List(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
t.Fatalf("an unanswered first view: %v", err)
}
}
// Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them,
// name no address of the controller, which is not placed: each job is named by the seat that owns it now.
func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) {
m, err := ReadMeshToolsFrom(aMesh(t), seats.List(), resolving)
m, err := ReadMeshToolsFrom(aMesh(t), catalogue.Seats(), resolving)
if err != nil {
t.Fatal(err)
}
@@ -71,17 +71,6 @@ type Judged struct {
Pending bool `json:"pending,omitempty"`
}
// JudgedEvent is the body of `judged`: one per judgement asked with a bound past JudgeBound, said when it ends, with
// its answer or why there is none. The question and the schema are never in it.
type JudgedEvent struct {
Ticket string `json:"ticket"`
Node string `json:"node"`
Model string `json:"model,omitempty"`
Seconds float64 `json:"seconds,omitempty"`
Answer json.RawMessage `json:"answer,omitempty"`
Error string `json:"error,omitempty"`
}
// running is one judgement asked with a bound past JudgeBound, until it is collected.
type running struct {
done chan struct{}
@@ -103,9 +92,6 @@ type Judge struct {
// Wait is how long a call waits for a long judgement before it answers its ticket; JudgeWait unless a test
// says less.
Wait time.Duration
// Say tells the bus a ticketed judgement ended: the event `judged`, once, under the message id it is given so a
// repeat is dropped. Nil says nothing (a test's judge); the ticket is collected by claude_code_judged all the same.
Say func(body JudgedEvent, id string) error
turn chan struct{}
mu sync.Mutex
@@ -222,28 +208,10 @@ func (j *Judge) start(schema, model, question string, bound time.Duration) (*Jud
go func() {
r.out, r.err = j.run(schema, model, question, bound)
close(r.done)
j.said(ticket, r)
}()
return j.Collect(ticket)
}
// said tells the bus that the judgement under a ticket ended (the event `judged`), once. A failure to say is logged: the
// ticket stays collectible by claude_code_judged, and the asker's own bound ends its wait.
func (j *Judge) said(ticket string, r *running) {
if j.Say == nil {
return
}
e := JudgedEvent{Ticket: ticket, Node: j.P.Node}
if r.err != nil {
e.Error = r.err.Error()
} else {
e.Model, e.Seconds, e.Answer = r.out.Model, r.out.Seconds, r.out.Answer
}
if err := j.Say(e, "judged-"+j.P.Node+"-"+ticket); err != nil {
say("the judgement under ticket %s ended and could not be said on the bus: %v", ticket, err)
}
}
// Collect waits as long as a call may for the judgement under a ticket: its answer, why it failed, or that it is
// still pending. A ticket is answered once; an answer nobody collects is kept JudgedKept past its bound.
func (j *Judge) Collect(ticket string) (*Judged, error) {
@@ -182,9 +182,6 @@ func TestALongJudgementIsCollectedByItsTicket(t *testing.T) {
}
})
j.Wait = 50 * time.Millisecond
said := make(chan JudgedEvent, 4)
ids := make(chan string, 4)
j.Say = func(e JudgedEvent, id string) error { said <- e; ids <- id; return nil }
start := time.Now()
out, err := j.Judge(JudgeAsk{Question: "a long record", Schema: verdictSchema, Within: 90 * time.Second})
if err != nil {
@@ -206,21 +203,6 @@ func TestALongJudgementIsCollectedByItsTicket(t *testing.T) {
if got.Pending || string(got.Answer) != `{"verdict":"none"}` || got.Ticket != out.Ticket {
t.Fatalf("%+v", got)
}
// One `judged` per ticket, with the answer, under an id of the ticket's own; none for a judgement with no ticket.
select {
case e := <-said:
if e.Ticket != out.Ticket || e.Node != "control" || string(e.Answer) != `{"verdict":"none"}` || e.Error != "" || e.Model != "sonnet" {
t.Fatalf("judged: %+v", e)
}
if id := <-ids; id != "judged-control-"+out.Ticket {
t.Fatalf("judged under %q", id)
}
case <-time.After(5 * time.Second):
t.Fatal("the ended judgement was not said")
}
if len(said) != 0 {
t.Fatal("judged said twice")
}
// Read once the judgement is collected: its end is what orders the agent's write before this read.
if left := deadline.Sub(start); left < 80*time.Second || left > 91*time.Second {
t.Fatalf("the judgement's bound is %s, not the 90s asked", left)
@@ -242,23 +224,10 @@ func TestALongJudgementIsBoundedAndSaysWhyItFailed(t *testing.T) {
}
j.Wait = 20 * time.Millisecond
j.Bound = time.Second // the test's stand-in for JudgeBound: within is past it, so a ticket; far past Wait on a loaded machine
failed := make(chan JudgedEvent, 1)
j.Say = func(e JudgedEvent, _ string) error { failed <- e; return nil }
out, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Within: 2 * time.Second})
if err != nil || !out.Pending {
t.Fatalf("%+v %v", out, err)
}
defer func() {
// A failed judgement is said too, with why and no answer.
select {
case e := <-failed:
if e.Ticket != out.Ticket || e.Answer != nil || !strings.Contains(e.Error, "did not answer within") {
t.Errorf("judged: %+v", e)
}
case <-time.After(5 * time.Second):
t.Error("the failed judgement was not said")
}
}()
for i := 0; i < 3000; i++ {
got, err := j.Collect(out.Ticket)
if err != nil {
+18 -53
View File
@@ -12,7 +12,6 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -22,13 +21,9 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-contracts/go/seats"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
func say(format string, args ...any) {
@@ -185,12 +180,12 @@ func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
var memoryCarrier *MemoryCarrier
// sayCondition says a condition on the operator-channel seat under this module's name (novox/hq ADR 0437 §9).
func sayCondition(c oc.Condition) error {
subject, err := occ.ConditionSubject("claude-code")
func sayCondition(c asks.Condition) error {
subject, err := asks.ConditionSubject("claude-code")
if err != nil {
return err
}
raw, err := occ.EncodeCondition(c)
raw, err := asks.EncodeCondition(c)
if err != nil {
return err
}
@@ -210,46 +205,23 @@ func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
return e.Value, true, nil
}
// nodesRunningMe is the nodes claude-code runs on, from the assigner's placement — for the register tool's question.
func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshViews}, "claude-code") }
// nodesRunningMe is the nodes claude-code runs on, from the seat mesh-placement's placement — for the register
// tool's question.
func nodesRunningMe() ([]string, error) { return NodesRunning(busStates{}, "claude-code") }
// The owners' read tools of the states this module follows, which the manifest invokes (novox/hq ADR 0615 §1).
const (
ModulesTool = delivery.IndexerModule + ".indexer_read_modules"
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
PlacementTool = delivery.AssignerModule + ".assigner_read_placement"
)
// busStates reads the states the manifest declares in its reads through the runtime.
type busStates struct{}
// meshViews is this module's picture of the indexer's modules and manifests and the assigner's placement: each asked
// once of the owner's read tool, then followed on the owner's views family. It reads none of their state.
var meshViews *views.Reader
func (busStates) Keys(read string) ([]string, error) { return stdio.State(read).Keys() }
// meshChanged is told, without waiting, that a followed state changed: the instructions' table is read again.
var meshChanged = make(chan struct{}, 1)
// newMeshViews builds the reader of those three states; Start asks them.
func newMeshViews() *views.Reader {
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: views.OwnFindings{},
Acts: handacts.Recorder{Ask: stdio.Ask},
Changed: func(string, string, json.RawMessage, bool) {
select {
case meshChanged <- struct{}{}:
default:
}
}}
v.Follow(delivery.IndexerModule, "modules", ModulesTool)
v.Follow(delivery.IndexerModule, "manifests", ManifestsTool)
v.Follow(delivery.AssignerModule, "placement", PlacementTool)
return v
func (busStates) Get(read, key string) ([]byte, bool, error) {
e, err := stdio.State(read).Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// viewStates reads the followed states from the reader's pictures, never from the owners' state.
type viewStates struct{ v *views.Reader }
func (s viewStates) Keys(read string) ([]string, error) { return s.v.Keys(read) }
func (s viewStates) Get(read, key string) ([]byte, bool, error) { return s.v.Get(read, key) }
func fingerprintOfFile(path string) any {
raw, err := os.ReadFile(path)
if err != nil {
@@ -319,7 +291,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
Run: func(map[string]any) (any, error) {
answer := map[string]any{}
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup); err != nil {
if m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup); err != nil {
answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered"
} else {
KeepMeshTools(p, m)
@@ -400,12 +372,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
out = append(out, proposalTools(p)...)
out = append(out, waitTools(p)...)
judge := NewJudge(p)
// The event `judged`, one per ticket when its judgement ends: the asker hears it instead of asking again.
judge.Say = func(e JudgedEvent, id string) error {
return stdio.Emit("judged", e, stdio.EmitOptions{MessageID: id})
}
out = append(out, judgeTool(judge), judgedTool(judge))
out = append(out, meshViews.Tools("claude_code")...)
return append(out, configTools(p, config, configView)...)
}
@@ -451,7 +418,6 @@ func main() {
return
}
p, launched := PathsFrom(os.Getenv)
meshViews = newMeshViews()
if launched {
fsys = FilesFor(p)
}
@@ -481,7 +447,6 @@ func main() {
}
}
}
meshViews.Start(context.Background())
go refreshMeshTools(p)
servers := stateOf{stdio.State("servers")}
view := NewServerView(p)
+12 -30
View File
@@ -5,7 +5,7 @@ package main
// replaces, and the mesh's machines by every name and address a session could reach one by.
//
// Read from the bus at start and every few minutes (issue 1126 B13; the controller that answered this before is
// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-contracts), the modules'
// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-sdk catalogue), the modules'
// own tools from the manifests the indexer keeps (`mesh-delivery-indexer.modules` and `.manifests`), and the
// machines and where each module runs from the seat mesh-placement's `placement`. Kept in the module's state so
// a render never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb
@@ -23,7 +23,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -217,14 +217,14 @@ func firstSentence(s string) string {
// ReplacementsOf is every seat verb and module tool that says it replaces a command: the seats' verbs first, in the
// seats' order, then the modules' own tools by module and tool.
func ReplacementsOf(defined []seats.Seat, modules []ModuleTools) []Replacement {
func ReplacementsOf(seats []catalogue.Seat, modules []ModuleTools) []Replacement {
var out []Replacement
for _, s := range defined {
for _, s := range seats {
for _, v := range s.Serves {
if len(v.Replaces) == 0 {
continue
}
out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true,
out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == catalogue.ScopeNode, Seat: true,
Does: firstSentence(v.Description), Replaces: v.Replaces})
}
}
@@ -257,15 +257,15 @@ func lookup(name string) []string {
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node placement names
// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept.
func ReadMeshToolsFrom(st States, defined []seats.Seat, resolve func(string) []string) (MeshTools, error) {
func ReadMeshToolsFrom(st States, seats []catalogue.Seat, resolve func(string) []string) (MeshTools, error) {
var m MeshTools
placed, err := placements(st)
if err != nil {
m.Replacements = ReplacementsOf(defined, nil)
m.Replacements = ReplacementsOf(seats, nil)
return m, err
}
modules, merr := modulesTools(st, runsOn(placed))
m.Replacements = ReplacementsOf(defined, modules)
m.Replacements = ReplacementsOf(seats, modules)
for node, p := range placed {
machine := Machine{Name: node, Domains: []string{node + InternalSuffix}}
if d := strings.ToLower(strings.TrimSpace(p.Decided.PublicDomain)); d != "" {
@@ -334,23 +334,13 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
}
// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the placement are read again
// when their followed states change (a views event), not on this timer.
// MeshToolsEvery is how often the bus is read again.
const MeshToolsEvery = 10 * time.Minute
// MeshToolsFirst is how long the first read waits for the first views to be answered.
const MeshToolsFirst = 2 * time.Minute
// refreshMeshTools reads the pictures once the first views are answered, and again whenever one changes (and the
// machines' addresses every few minutes), and renders when what it said changed.
// refreshMeshTools reads the bus at start and every few minutes, and renders when what it said changed.
func refreshMeshTools(p Paths) {
select {
case <-meshViews.Ready():
case <-time.After(MeshToolsFirst):
say("the first views of the tools and the placement were not answered in %s; reading what is held", MeshToolsFirst)
}
for {
m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup)
m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup)
if err != nil {
say("reading what each tool replaces and which machines the mesh has: %v", err)
}
@@ -361,15 +351,7 @@ func refreshMeshTools(p Paths) {
say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines))
}
}
select {
case <-meshChanged:
time.Sleep(2 * time.Second) // a burst of events is one read
select {
case <-meshChanged:
default:
}
case <-time.After(MeshToolsEvery):
}
time.Sleep(MeshToolsEvery)
}
}
@@ -9,8 +9,8 @@ import (
"sync"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Proposing is what the proposal tools reach: the `proposals` state, the operator channel, the controller's
@@ -71,13 +71,9 @@ func (d *Proposing) watch(p Paths) {
}
}
go persist("hearing the operator's answers", func() error {
return stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w oc.Warrant
if err := json.Unmarshal(e.Body, &w); err == nil {
if _, err := warrantAt(w); err != nil {
say("not taking the router's word on %s: %v", w.Ask, err)
return nil
}
return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w asks.Warrant
if json.Unmarshal(e.Body, &w) == nil {
if strings.HasPrefix(w.Ask, QuestionPrefix) {
HeardQuestionEnd(d.questions(), w)
return nil
@@ -15,7 +15,7 @@ package main
// and lets this module read only its own asks);
// 2. composes the ask again from the proposal — the words, the options, the expiry, the digest of the text —
// and holds the router's warrant to it: the same ask digest, an option it offered, at its level, in time
// (oc.Warrant.For), and the option's bound act is this proposal's (oc.Option.Performs). An ask published
// (asks.Warrant.For), and the option's bound act is this proposal's (asks.Option.Performs). An ask published
// under this module's name with other words, or a proposal changed after it was asked, finds the digests
// differ and nothing is done;
// 3. applies the approved text to its own file, `approved.json` in the module's state directory, which is what
@@ -42,9 +42,8 @@ import (
"unicode"
"unicode/utf8"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Module is this module's name: the one the bus grants its asks under.
@@ -69,7 +68,7 @@ const RouterKeeps = 30 * 24 * time.Hour
// ProposalLasts is how long the operator has to answer: a day less a margin, so the router never refuses it for
// lasting a day and a moment (the SDK's bound for an approval).
const ProposalLasts = occ.ApproveLasts - 10*time.Minute
const ProposalLasts = asks.ApproveLasts - 10*time.Minute
// What a proposal does.
const (
@@ -165,8 +164,8 @@ func joinAnd(list []string) string {
// Act is what an answer binds: this proposal, whole, and the answer. The router's warrant names the ask's digest,
// and the ask names this act's digest in the option, so an approval authorises this text and nothing else.
func (p Proposal) Act(option string) occ.Act {
return occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
func (p Proposal) Act(option string) asks.Act {
return asks.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
"scope": p.Scope, "nodes": strings.Join(p.Nodes, ","), "name": p.Name, "digest": p.Digest, "from": p.From,
"at": p.At.UTC().Format(time.RFC3339Nano)}
}
@@ -176,7 +175,7 @@ const AskGlance = 1
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
// router's warrant to it — in the form it was asked in.
func (p Proposal) Ask() oc.Ask {
func (p Proposal) Ask() asks.Ask {
if p.Form == AskGlance {
return p.glanceAsk()
}
@@ -186,7 +185,7 @@ func (p Proposal) Ask() oc.Ask {
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
// Details, which the router notes after the answers, collapsed where the channel can.
func (p Proposal) glanceAsk() oc.Ask {
func (p Proposal) glanceAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
@@ -194,7 +193,7 @@ func (p Proposal) glanceAsk() oc.Ask {
part = " Part shown"
}
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
if len([]rune(headline)) > occ.HeadlineLength {
if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " Claude instructions?" + part
}
var d strings.Builder
@@ -215,7 +214,7 @@ func (p Proposal) glanceAsk() oc.Ask {
if p.Action == ActionRemove {
does = "the instructions are removed from those sessions"
}
return p.withOptions(oc.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
}
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
@@ -245,8 +244,8 @@ func SummaryProblem(summary string) string {
return ""
case strings.Contains(s, "\n"):
return "the summary is more than one line"
case len([]rune(s)) > occ.SummaryLength:
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), occ.SummaryLength)
case len([]rune(s)) > asks.SummaryLength:
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
case !shownLine(s):
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
}
@@ -255,8 +254,8 @@ func SummaryProblem(summary string) string {
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
// options, each binding its act.
func (p Proposal) withOptions(a oc.Ask, does string) oc.Ask {
a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(p.At.Add(ProposalLasts)), "the proposal is discarded; nothing changes"
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
if len(p.Nodes) == 0 {
a.About = "instructions." + p.Scope + "." + p.Name
@@ -265,15 +264,15 @@ func (p Proposal) withOptions(a oc.Ask, does string) oc.Ask {
{OptionApprove, "Approve", does},
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
} {
binds, _ := occ.ActDigest(p.Act(o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
binds, _ := asks.ActDigest(p.Act(o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
// warrant of a proposal made then.
func (p Proposal) listedAsk() oc.Ask {
func (p Proposal) listedAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
@@ -281,7 +280,7 @@ func (p Proposal) listedAsk() oc.Ask {
part = " Part shown"
}
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
if len([]rune(headline)) > occ.HeadlineLength {
if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " Claude instructions?" + part
}
var b strings.Builder
@@ -312,8 +311,8 @@ func (p Proposal) listedAsk() oc.Ask {
if p.Action == ActionRemove {
does = "the instructions are removed from those sessions"
}
a := oc.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: occ.Operator,
Expires: occ.FormatTime(p.At.Add(ProposalLasts)), OnExpiry: "the proposal is discarded; nothing changes",
a := asks.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: asks.Operator,
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes",
About: "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name}
if len(p.Nodes) == 0 {
a.About = "instructions." + p.Scope + "." + p.Name
@@ -322,8 +321,8 @@ func (p Proposal) listedAsk() oc.Ask {
{OptionApprove, "Approve", does},
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
} {
binds, _ := occ.ActDigest(p.Act(o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
binds, _ := asks.ActDigest(p.Act(o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return a
}
@@ -434,7 +433,7 @@ func invisible(text string) string {
// write the record and ask for it under this module's name.
func ProposalProblem(p Proposal) string {
switch {
case !occ.UsableID(p.ID):
case !asks.UsableID(p.ID):
return "its id is not one"
case !itemName.MatchString(p.Name):
return "its name is not one"
@@ -839,21 +838,21 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
if why := approverReady(bus); why != "" {
return refuse(why + "; " + AtTheTerminalInstead)
}
if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen {
if waiting := waitingProposals(store, approved, now); waiting >= asks.MostOpen {
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
"questions at once; claude_code_proposals lists them", waiting, occ.MostOpen))
"questions at once; claude_code_proposals lists them", waiting, asks.MostOpen))
}
if problem := ProposalProblem(prop); problem != "" {
return refuse(problem)
}
ask := prop.Ask()
if err := occ.Check(ask, now); err != nil {
if err := ask.Check(now); err != nil {
return refuse(err.Error())
}
if err := store.Create(prop.ID, prop); err != nil {
return nil, fmt.Errorf("the proposal could not be kept, so the operator was not asked: %w", err)
}
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
prop.Asked = "not asked: " + err.Error()
_ = store.Put(prop.ID, prop)
say("proposal %s (%s %s %s) could not be asked: %v", prop.ID, prop.Action, prop.Scope, prop.Name, err)
@@ -875,15 +874,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
}
time.Sleep(RouterAnswersEvery)
}
expires, err := askExpires(ask)
if err != nil {
return nil, err
}
return map[string]any{
"router": taken,
"proposed": prop.ID, "action": prop.Action, "reaches": prop.where(), "fingerprint": prop.Fingerprint(),
"asked": "the operator is asked on a channel that proves who answers: \"" + ask.Headline + "\"",
"expires": expires.Format(time.RFC3339),
"expires": ask.Expires.Format(time.RFC3339),
"until then": "nothing changes on any node; the text becomes part of those sessions only on the operator's Approve, " +
"and is discarded on Decline or at expiry. claude_code_proposals shows where it stands",
}, nil
@@ -932,10 +927,10 @@ var (
// read.
var (
endedMu sync.Mutex
ended = map[string]oc.Warrant{}
ended = map[string]asks.Warrant{}
)
func endedAsk(id string) (oc.Warrant, bool) {
func endedAsk(id string) (asks.Warrant, bool) {
endedMu.Lock()
defer endedMu.Unlock()
w, ok := ended[id]
@@ -945,8 +940,8 @@ func endedAsk(id string) (oc.Warrant, bool) {
// HeardEnd takes the router's word that one of this module's asks ended without a choice: refused, expired,
// cancelled or replaced. The proposal is discarded here at once. Nothing is applied on such a word, so it needs no
// proof beyond its subject; a choice is only ever taken from the router's record (Reconcile).
func HeardEnd(p Paths, store ProposalStore, w oc.Warrant, now time.Time) (string, error) {
if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !occ.UsableID(w.Ask) {
func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (string, error) {
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !asks.UsableID(w.Ask) {
return "", nil
}
reconcileMu.Lock()
@@ -973,7 +968,7 @@ func HeardEnd(p Paths, store ProposalStore, w oc.Warrant, now time.Time) (string
}
// Heard, not read from a record: Reconcile still reads the router's record of it, so an ask the router took after
// all (its queue offered it again once a channel was ready) is answered as any other.
d := ProposalDecision{At: now.UTC(), Says: occ.Says(w), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
if w.Words != "" {
d.Outcome += " (" + w.Words + ")"
}
@@ -991,9 +986,9 @@ func HeardEnd(p Paths, store ProposalStore, w oc.Warrant, now time.Time) (string
// routerRecord is the router's record of one ask, as far as this module reads it.
type routerRecord struct {
State string `json:"state"`
Ask *oc.Ask `json:"ask"`
Warrant *oc.Warrant `json:"warrant"`
State string `json:"state"`
Ask *asks.Ask `json:"ask"`
Warrant *asks.Warrant `json:"warrant"`
}
var reconcileMu sync.Mutex
@@ -1015,7 +1010,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
}
type closed struct {
prop Proposal
w oc.Warrant
w asks.Warrant
}
var ready []closed
discarded := false
@@ -1032,8 +1027,8 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
if done {
// Discarded on the router's word alone: only a record of a person's choice reopens it.
var rec routerRecord
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.check() == nil && rec.Warrant != nil &&
rec.Warrant.Outcome == occ.OutcomeChosen {
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.Warrant != nil &&
rec.Warrant.Outcome == asks.OutcomeChosen {
ready = append(ready, closed{prop, *rec.Warrant})
}
continue
@@ -1051,16 +1046,12 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
continue // open, not yet recorded, or not readable now: asked again on the next look
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil || rec.check() != nil || rec.State == "open" || rec.Warrant == nil {
if json.Unmarshal(entry.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
continue
}
ready = append(ready, closed{prop, *rec.Warrant})
}
sort.SliceStable(ready, func(i, j int) bool {
a, _ := warrantAt(ready[i].w) // checked when read
b, _ := warrantAt(ready[j].w)
return a.Before(b)
})
sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) })
changed, wrote := false, discarded
var said []string
for _, c := range ready {
@@ -1117,21 +1108,17 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
// decide holds one closed ask's warrant to its proposal, and applies it to this node's record when it holds. final
// is false when the answer did not hold here: it is judged again on the next look, and nothing is applied.
func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.Time) (ProposalDecision, bool, string, bool) {
d := ProposalDecision{At: now.UTC(), Says: occ.Says(w)}
func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time.Time) (ProposalDecision, bool, string, bool) {
d := ProposalDecision{At: now.UTC(), Says: w.Says()}
refused := func(why string) (ProposalDecision, bool, string, bool) {
d.Outcome = "refused: " + why
say("REFUSED the answer to proposal %s: %s", prop.ID, why)
return d, false, "proposal " + prop.ID + ": refused, " + why, false
}
at, err := warrantAt(w)
if err != nil {
return refused(err.Error())
}
if w.Asker != Module || w.Ask != prop.ID {
return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask))
}
if w.Outcome != occ.OutcomeChosen {
if w.Outcome != asks.OutcomeChosen {
d.Outcome = "discarded: the ask " + string(w.Outcome)
if w.Words != "" {
d.Outcome += " (" + w.Words + ")"
@@ -1143,11 +1130,11 @@ func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.T
if problem := ProposalProblem(prop); problem != "" {
return refused("the proposal is not one this module makes: " + problem)
}
option, err := occ.For(w, Module, prop.Ask())
option, err := w.For(Module, prop.Ask())
if err != nil {
return refused(err.Error())
}
if err := occ.Performs(option, prop.Act(option.ID)); err != nil {
if err := option.Performs(prop.Act(option.ID)); err != nil {
return refused(err.Error())
}
d.Warranted = true
@@ -1159,7 +1146,7 @@ func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.T
return refused("the option " + option.ID + " is not one a proposal offers")
}
key := sectionKey(prop.Scope, prop.Nodes, prop.Name)
if last, ok := approved.Changed[key]; ok && !at.After(last) {
if last, ok := approved.Changed[key]; ok && !w.At.After(last) {
d.Outcome = "approved, and superseded here: a change of this section approved later was applied first"
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
}
@@ -1170,16 +1157,16 @@ func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.T
held, ok := approved.Sections[key]
if !ok || held.Digest != prop.Digest {
d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint"
approved.Changed[key] = at.UTC()
approved.Changed[key] = w.At.UTC()
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
}
delete(approved.Sections, key)
default:
approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Text: prop.Text,
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w)}
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: w.At.UTC(), By: w.Says()}
}
approved.Changed[key] = at.UTC()
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), occ.Says(w))
approved.Changed[key] = w.At.UTC()
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), w.Says())
return d, reaches, "proposal " + prop.ID + ": approved, " + prop.Action + " " + prop.Name, true
}
@@ -1235,12 +1222,12 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time
// the router took, both read now.
if entry, err := bus.Record(prop.ID); err == nil && entry != nil {
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) == nil && rec.check() == nil {
if json.Unmarshal(entry.Value, &rec) == nil {
row["router"] = rec.State
switch {
case rec.Ask == nil:
row["the phone"] = "cannot be compared: the router's record keeps no ask"
case occ.Digest(*rec.Ask) == occ.Digest(prop.Ask()):
case rec.Ask.Digest() == prop.Ask().Digest():
row["the phone"] = "shows the ask composed from exactly this text (fingerprint " + prop.Fingerprint() + ")"
default:
row["the phone"] = "shows ANOTHER ask than this record composes: do not approve it"
@@ -13,9 +13,8 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// memProposals is the `proposals` state, shared by every node of a test as the bus shares it.
@@ -47,7 +46,7 @@ func (s *memProposals) Get(key string) (json.RawMessage, bool, error) {
// router is the operator channel's holder as the module reaches it: what was asked, its record of each ask, and
// the channel kinds.
type router struct {
published map[string]oc.Ask
published map[string]asks.Ask
subjects []string
records map[string]json.RawMessage
kinds []stdio.KindHeld
@@ -55,7 +54,7 @@ type router struct {
}
func newRouter() *router {
return &router{published: map[string]oc.Ask{}, records: map[string]json.RawMessage{},
return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{},
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "novox",
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
}
@@ -66,7 +65,7 @@ func (r *router) Publish(subject string, body any, id string) error {
}
r.subjects = append(r.subjects, subject)
raw, _ := json.Marshal(body)
var a oc.Ask
var a asks.Ask
_ = json.Unmarshal(raw, &a)
r.published[a.ID] = a
r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"})
@@ -82,19 +81,19 @@ func (r *router) Record(id string) (*stdio.StateEntry, error) {
func (r *router) Kinds() ([]stdio.KindHeld, error) { return r.kinds, nil }
// close ends an ask with a warrant, as the router records it.
func (r *router) close(w oc.Warrant) {
func (r *router) close(w asks.Warrant) {
w.Asker = Module
state := string(w.Outcome)
r.records[w.Ask], _ = json.Marshal(map[string]any{"state": state, "warrant": w})
}
// chose is the warrant for a person's choice of an option of the ask as the router took it.
func (r *router) chose(id, option string, at time.Time) oc.Warrant {
func (r *router) chose(id, option string, at time.Time) asks.Warrant {
a := r.published[id]
o, _ := occ.OptionOf(a, option)
return oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)}
o, _ := a.Option(option)
return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()}
}
type recorded struct{ asks []string }
@@ -145,13 +144,13 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
t.Fatalf("the ask was published on %v, not under this module's own name", r.subjects)
}
a := r.published[id]
if err := occ.Check(a, t0); err != nil {
if err := a.Check(t0); err != nil {
t.Fatalf("the router would refuse the ask: %v", err)
}
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
// router notes collapsed after the answers.
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
occ.Glance(a.Explanation) != nil {
asks.Glance(a.Explanation) != nil {
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
}
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
@@ -164,11 +163,11 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
t.Fatalf("options: %+v", a.Options)
}
for _, o := range a.Options {
if o.Level != occ.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
t.Errorf("the option %s is %s binding %q: both answers need a proven sender and bind the act", o.ID, o.Level, o.Binds)
}
}
if a.Expires != occ.FormatTime(t0.Add(ProposalLasts)) {
if !a.Expires.Equal(t0.Add(ProposalLasts)) {
t.Errorf("expires %s", a.Expires)
}
// Nothing is active, here or anywhere, while the ask is open.
@@ -213,10 +212,10 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
}{
{"declined", func(r *router, _ *memProposals, id string) { r.close(r.chose(id, OptionDecline, t0.Add(time.Hour))) }, "declined"},
{"expired", func(r *router, _ *memProposals, id string) {
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(24 * time.Hour))})
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(24 * time.Hour)})
}, "discarded"},
{"refused by the router", func(r *router, _ *memProposals, id string) {
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeRefused, Words: "no channel can carry it", At: occ.FormatTime(t0)})
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused, Words: "no channel can carry it", At: t0})
}, "discarded"},
{"the text changed after the ask, with its digest", func(r *router, store *memProposals, id string) {
var prop Proposal
@@ -244,7 +243,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
}, "refused"},
{"answered at the level acknowledge", func(r *router, _ *memProposals, id string) {
w := r.chose(id, OptionApprove, t0.Add(time.Hour))
w.Level = occ.Acknowledge
w.Level = asks.Acknowledge
r.close(w)
}, "refused"},
{"no person chose", func(r *router, _ *memProposals, id string) {
@@ -429,7 +428,7 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
}
if len(a.Details) > 3000 || occ.Glance(a.Explanation) != nil {
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
}
list, err := ListProposals(p, store, r, id, t0)
@@ -497,7 +496,7 @@ func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) {
_ = json.Unmarshal(store.m[id], &prop)
prop.Text = strings.Replace(prop.Text, "Ask before you push.", "Push without asking.", 1)
_ = store.Put(id, prop)
if occ.Digest(prop.Ask()) != occ.Digest(r.published[id]) {
if prop.Ask().Digest() != r.published[id].Digest() {
t.Fatal("the test means a change the ask's words do not show")
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
@@ -560,7 +559,7 @@ func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
} {
prop.Digest = textDigest(prop.Text)
_ = store.Put(prop.ID, prop)
if err := r.Publish(oc.AcceptSubjectAsk(Module), prop.Ask(), prop.ID); err != nil {
if err := r.Publish(asks.AskSubject(Module), prop.Ask(), prop.ID); err != nil {
t.Fatal(err)
}
r.close(r.chose(prop.ID, OptionApprove, t0.Add(time.Hour)))
@@ -719,8 +718,8 @@ func (r *refusingRouter) Publish(subject string, body any, id string) error {
r.id = id
go func() {
time.Sleep(50 * time.Millisecond)
_, _ = HeardEnd(r.p, r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused,
Words: "no channel can carry any of its answers now: telegram is not linked", At: occ.FormatTime(t0)}, t0)
_, _ = HeardEnd(r.p, r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
Words: "no channel can carry any of its answers now: telegram is not linked", At: t0}, t0)
}()
return nil
}
@@ -730,23 +729,12 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
p, _ := node(t, "novox")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
if _, err := HeardEnd(p, store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused, Words: "no channel", At: occ.FormatTime(t0)}, t0); err != nil {
if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil {
t.Fatal(err)
}
if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded") {
t.Fatalf("decided %q", got)
}
// A warrant whose time does not parse is refused, naming the field, as the decode of it was.
bad := r.chose(id, OptionApprove, t0.Add(time.Hour))
bad.At = "yesterday"
prop, _ := readProposal(store, id)
appr := Approved{Decided: map[string]ProposalDecision{}, Changed: map[string]time.Time{}, Refused: map[string]string{}}
if d, _, _, _ := decide(p, &appr, prop, bad, t0.Add(time.Hour)); !strings.HasPrefix(d.Outcome, "refused: the warrant's at") {
t.Errorf("a warrant with a malformed at was decided %q", d.Outcome)
}
if (routerRecord{Warrant: &bad}).check() == nil {
t.Error("a router record with a malformed at passed its check")
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); !changed {
t.Fatal("the approval of an ask taken after its refusal was lost")
@@ -781,7 +769,7 @@ func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
older := prop
older.Form = 0
listed := older.Ask()
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || occ.Digest(listed) == occ.Digest(prop.Ask()) {
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
}
}
@@ -11,7 +11,7 @@ package main
//
// The session reads the answer by the question's key with claude_code_answer: from the router's own record of the
// ask, which only the router writes and only this module reads, held to the question as the `questions` state keeps
// it (oc.Warrant.For and oc.Option.Performs). A record that does not hold is said as not proven, never as a
// it (asks.Warrant.For and asks.Option.Performs). A record that does not hold is said as not proven, never as a
// choice. An answer informs the session; it authorises nothing (ADR 0286 stays the way to an approved call).
import (
@@ -27,9 +27,8 @@ import (
"sync"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// QuestionsState is the module's state the questions are kept in.
@@ -39,16 +38,16 @@ const QuestionsState = "questions"
const QuestionPrefix = "q-"
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
// (asks.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
const MostQuestions = occ.MostOpen - 1
const MostQuestions = asks.MostOpen - 1
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
// when it is taken (oc.Ask.Check), so the slot is free and the question has ended.
// when it is taken (asks.Ask.Check), so the slot is free and the question has ended.
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
@@ -70,7 +69,7 @@ const (
// The bounds of what a question says.
const (
FewestQuestionOptions = 2
MostContextBytes = occ.SummaryLength
MostContextBytes = asks.SummaryLength
MostMeansBytes = 200
MostWhyBytes = 120
MostOnExpiryBytes = 200
@@ -152,8 +151,8 @@ func (q Question) recommended() (QuestionOption, bool) {
}
// Act is what an answer binds: this question, whole, and the option chosen.
func (q Question) Act(option string) occ.Act {
act := occ.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
func (q Question) Act(option string) asks.Act {
act := asks.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
"context": q.Context, "why": q.Why, "on-expiry": q.OnExpiry, "by": q.By, "from": q.From,
"at": q.At.UTC().Format(time.RFC3339Nano), "expires": q.Expires.UTC().Format(time.RFC3339Nano),
"options": strconv.Itoa(len(q.Options))}
@@ -164,7 +163,7 @@ func (q Question) Act(option string) occ.Act {
}
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
func (q Question) Ask() oc.Ask {
func (q Question) Ask() asks.Ask {
if q.Form == AskGlance {
return q.glanceAsk()
}
@@ -173,31 +172,31 @@ func (q Question) Ask() oc.Ask {
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
// answer and why as its one point; who asked and what the answer does are its Details.
func (q Question) glanceAsk() oc.Ask {
func (q Question) glanceAsk() asks.Ask {
explanation := strings.TrimSpace(q.Context)
if rec, ok := q.recommended(); ok {
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
}
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
return q.withOptions(oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
}
func (q Question) withOptions(a oc.Ask) oc.Ask {
a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(q.Expires), q.OnExpiry
func (q Question) withOptions(a asks.Ask) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
for _, o := range q.Options {
does := o.Means
if o.Recommended {
does = "Recommended. " + does
}
binds, _ := occ.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds})
binds, _ := asks.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
func (q Question) listedAsk() oc.Ask {
func (q Question) listedAsk() asks.Ask {
var b strings.Builder
b.WriteString(strings.TrimSpace(q.Context))
if rec, ok := q.recommended(); ok {
@@ -206,15 +205,15 @@ func (q Question) listedAsk() oc.Ask {
}
fmt.Fprintf(&b, "\n\nAsked by the agent session %s on %s through the claude-code module, at %s UTC. "+
"Your answer is told to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
a := oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: occ.Operator, Expires: occ.FormatTime(q.Expires),
a := asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: asks.Operator, Expires: q.Expires,
OnExpiry: q.OnExpiry}
for _, o := range q.Options {
does := o.Means
if o.Recommended {
does = "Recommended. " + does
}
binds, _ := occ.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds})
binds, _ := asks.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
}
return a
}
@@ -229,8 +228,8 @@ func QuestionProblem(q Question) string {
switch {
case strings.TrimSpace(q.Headline) == "":
add("it has no headline")
case len([]rune(q.Headline)) > occ.HeadlineLength:
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), occ.HeadlineLength)
case len([]rune(q.Headline)) > asks.HeadlineLength:
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), asks.HeadlineLength)
}
switch {
case strings.TrimSpace(q.Context) == "":
@@ -240,16 +239,16 @@ func QuestionProblem(q Question) string {
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
}
if n := len(q.Options); n < FewestQuestionOptions || n > occ.MostOptions {
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, occ.MostOptions)
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
}
recommended, labels := 0, map[string]bool{}
for i, o := range q.Options {
switch {
case strings.TrimSpace(o.Label) == "":
add("option %d has no label", i+1)
case len([]rune(o.Label)) > occ.LabelLength:
add("option %d's label is longer than %d characters", i+1, occ.LabelLength)
case len([]rune(o.Label)) > asks.LabelLength:
add("option %d's label is longer than %d characters", i+1, asks.LabelLength)
case labels[strings.ToLower(o.Label)]:
add("two options are labelled %q", o.Label)
}
@@ -259,7 +258,7 @@ func QuestionProblem(q Question) string {
} else if len(o.Means) > MostMeansBytes {
add("what option %d means is longer than %d bytes", i+1, MostMeansBytes)
}
if !occ.UsableID(o.ID) {
if !asks.UsableID(o.ID) {
add("option %d's id is not one", i+1)
}
if o.Recommended {
@@ -369,7 +368,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
return refuse(problem)
}
ask := q.Ask()
if err := occ.Check(ask, now); err != nil {
if err := ask.Check(now); err != nil {
return refuse(err.Error())
}
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
@@ -385,7 +384,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
}
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return nil, fmt.Errorf("the operator could not be asked: %v", err)
}
@@ -518,10 +517,10 @@ func readQuestionErr(store QuestionStore, key string) (Question, bool, error) {
// The router's word that a question ended without a choice, as heard on the decided subject.
var (
questionEndsMu sync.Mutex
questionEnds = map[string]oc.Warrant{}
questionEnds = map[string]asks.Warrant{}
)
func questionEnd(id string) (oc.Warrant, bool) {
func questionEnd(id string) (asks.Warrant, bool) {
questionEndsMu.Lock()
defer questionEndsMu.Unlock()
w, ok := questionEnds[id]
@@ -538,8 +537,8 @@ func forgetQuestionEnd(id string) {
// replaced — and keeps it on the question, because a refused ask leaves no record of the router's. A refused question
// keeps none of its words (tombstone). A choice is never taken from this word: only from the router's record, held to
// the question (ReadAnswer).
func HeardQuestionEnd(store QuestionStore, w oc.Warrant) {
if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
func HeardQuestionEnd(store QuestionStore, w asks.Warrant) {
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
return
}
q, known := readQuestion(store, w.Ask)
@@ -551,7 +550,7 @@ func HeardQuestionEnd(store QuestionStore, w oc.Warrant) {
if w.Words != "" {
ended += ": " + w.Words
}
if w.Outcome == occ.OutcomeRefused {
if w.Outcome == asks.OutcomeRefused {
q = tombstone(q, ended)
} else {
q.Ended = ended
@@ -590,10 +589,7 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
}
var rec routerRecord
if entry != nil {
if err := json.Unmarshal(entry.Value, &rec); err != nil || rec.check() != nil {
if err == nil {
err = rec.check()
}
if err := json.Unmarshal(entry.Value, &rec); err != nil {
out["state"], out["words"] = AnswerNotProven, "the router's record of it cannot be read: "+err.Error()
return out, nil
}
@@ -617,12 +613,12 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
}
default:
w := *rec.Warrant
if w.Outcome != occ.OutcomeChosen {
if w.Outcome != asks.OutcomeChosen {
out["state"], out["words"] = AnswerEnded, string(w.Outcome)
if w.Words != "" {
out["words"] = string(w.Outcome) + ": " + w.Words
}
if w.Outcome == occ.OutcomeExpired {
if w.Outcome == asks.OutcomeExpired {
out["now"] = "do what you said: " + q.OnExpiry
}
return out, nil
@@ -631,9 +627,9 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem
return out, nil
}
option, err := occ.For(w, Module, q.Ask())
option, err := w.For(Module, q.Ask())
if err == nil {
err = occ.Performs(option, q.Act(option.ID))
err = option.Performs(q.Act(option.ID))
}
if err != nil {
say("REFUSED the answer to question %s: %v", q.ID, err)
@@ -646,9 +642,8 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
chosen["means"], chosen["recommended"] = o.Means, o.Recommended
}
}
at, _ := warrantAt(w) // checked with the record above
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, occ.Says(w), w.Channel,
at.Format(time.RFC3339)
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, w.Says(), w.Channel,
w.At.Format(time.RFC3339)
out["note"] = "the operator's word to this session; it authorises no call that needs the operator's approval"
}
return out, nil
@@ -668,37 +663,3 @@ func WaitForAnswer(store QuestionStore, bus AskBus, key string, wait time.Durati
time.Sleep(AnswerLooksEvery)
}
}
// warrantAt is when a warrant was made; the contract carries it as an RFC 3339 string, and one that does not parse is
// refused, as the decode of the router's word was before the contract carried it as a string.
func warrantAt(w oc.Warrant) (time.Time, error) {
t, err := occ.Time(w.At)
if err != nil {
return time.Time{}, fmt.Errorf("the warrant's at %q is not a time: %w", w.At, err)
}
return t, nil
}
// askExpires is when an ask ends, read from the contract's RFC 3339 string; one that does not parse is refused.
func askExpires(a oc.Ask) (time.Time, error) {
t, err := occ.Time(a.Expires)
if err != nil {
return time.Time{}, fmt.Errorf("the ask's expires %q is not a time: %w", a.Expires, err)
}
return t, nil
}
// check refuses a router record whose warrant or ask carries a time that does not parse.
func (r routerRecord) check() error {
if r.Warrant != nil {
if _, err := warrantAt(*r.Warrant); err != nil {
return err
}
}
if r.Ask != nil {
if _, err := askExpires(*r.Ask); err != nil {
return err
}
}
return nil
}
@@ -12,9 +12,8 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
func mergeTonight() QuestionInput {
@@ -51,18 +50,18 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
if !strings.HasPrefix(key, QuestionPrefix) || !strings.Contains(out["router"].(string), "took the question") {
t.Fatalf("answered %v", out)
}
if len(r.subjects) != 1 || r.subjects[0] != oc.AcceptSubjectAsk(Module) {
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject(Module) {
t.Fatalf("published on %v", r.subjects)
}
a := r.published[key]
if a.Who != occ.Operator || a.OnExpiry != "I leave the pull request open" || a.Expires != occ.FormatTime(t0.Add(QuestionDefault)) {
if a.Who != asks.Operator || a.OnExpiry != "I leave the pull request open" || !a.Expires.Equal(t0.Add(QuestionDefault)) {
t.Errorf("ask %+v", a)
}
if len(a.Options) != 2 {
t.Fatalf("options %+v", a.Options)
}
for _, o := range a.Options {
if o.Level != occ.Approve || o.Binds == "" {
if o.Level != asks.Approve || o.Binds == "" {
t.Errorf("option %s is %s binding %q: every option is an approval bound to the question", o.ID, o.Level, o.Binds)
}
}
@@ -70,7 +69,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
}
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || occ.Glance(a.Explanation) != nil {
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
}
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
@@ -78,7 +77,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
t.Errorf("the details do not say %q:\n%s", want, a.Details)
}
}
if err := occ.Check(a, t0); err != nil {
if err := a.Check(t0); err != nil {
t.Errorf("the ask does not pass the asks contract: %v", err)
}
}
@@ -213,8 +212,8 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er
r.id = id
go func() {
time.Sleep(50 * time.Millisecond)
HeardQuestionEnd(r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused,
Words: "its words carry path (a file path), which may not leave the mesh", At: occ.FormatTime(t0)})
HeardQuestionEnd(r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
Words: "its words carry path (a file path), which may not leave the mesh", At: t0})
}()
return nil
}
@@ -248,7 +247,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
r.close(oc.Warrant{Ask: key, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(QuestionDefault))})
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute))
if err != nil || out["state"] != AnswerEnded || out["now"] != "do what you said: I leave the pull request open" {
t.Errorf("expired: %v %v", out, err)
@@ -277,7 +276,7 @@ func TestTheQuestionToolsAreServed(t *testing.T) {
var asked map[string]any
_ = json.Unmarshal([]byte(text), &asked)
key, _ := asked["key"].(string)
if a := r.published[key]; a.Expires != occ.FormatTime(t0.Add(30*time.Minute)) {
if a := r.published[key]; !a.Expires.Equal(t0.Add(30 * time.Minute)) {
t.Errorf("expires %v", a.Expires)
}
r.close(r.chose(key, "o1", t0.Add(time.Minute)))
@@ -382,10 +381,10 @@ func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
p, _ := node(t, "g14")
for name, change := range map[string]func(*oc.Warrant){
"another asker": func(w *oc.Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *oc.Warrant) { w.Ask = "q-0000000000000000" },
"an option unoffered": func(w *oc.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
for name, change := range map[string]func(*asks.Warrant){
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
"an option unoffered": func(w *asks.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
} {
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
@@ -475,7 +474,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
store, r := newMemQuestions(), newRouter()
// Two questions that ended: their slots are free to take again.
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))})
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)})
}
shanks, _ := node(t, "shanks")
other := newRouter()
@@ -488,7 +487,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
_ = store.Put(q.ID, q)
_ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID)
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
r.records[q.ID] = other.records[q.ID]
_ = store.Put(key, slot{Question: q.ID, At: t0})
}
+4 -9
View File
@@ -2,17 +2,12 @@ module claude-code
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require gopkg.in/yaml.v3 v3.0.1
require (
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -22
View File
@@ -1,23 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.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/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
@@ -28,8 +12,6 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+7 -19
View File
@@ -23,16 +23,7 @@
"operator-channel"
],
"invokes": [
"seat:mesh-hand-acts.warranted",
"seat:mesh-hand-acts.hand-act",
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-assigner.assigner_read_placement"
],
"consumes": [
"mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-assigner.views.placement"
"seat:mesh-hand-acts.warranted"
],
"state": [
"servers",
@@ -41,14 +32,13 @@
"proposals",
"questions",
"memory",
"waits",
"findings"
],
"emits": [
"judged"
"waits"
],
"reads": [
"claude-licence-manager.bindings"
"claude-licence-manager.bindings",
"mesh-delivery-assigner.placement",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests"
],
"tools": [
"claude_code_status",
@@ -99,9 +89,7 @@
"claude_code_home_removed",
"claude_code_home_restore",
"claude_code_judge",
"claude_code_judged",
"claude_code_first_views",
"claude_code_first_view_again"
"claude_code_judged"
],
"data": {
"own": [
+2 -2
View File
@@ -3,7 +3,7 @@ module claude-licence-manager
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/jackc/pgx/v5 v5.11.0
)
@@ -11,7 +11,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,7 +1,5 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -13,8 +11,8 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+2 -2
View File
@@ -2,10 +2,10 @@ module clipmenu
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+2 -2
View File
@@ -2,10 +2,10 @@ module cups
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+2 -2
View File
@@ -3,12 +3,12 @@ module dbus
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
github.com/godbus/dbus/v5 v5.1.0
)
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,11 +1,9 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+21 -21
View File
@@ -35,7 +35,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/channel"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Kind is this channel's kind on both benches.
@@ -59,19 +59,19 @@ const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in channel.Standing once the SDK carries it; until then the
// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
type Standing struct {
channel.Standing
asks.Standing
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
// machine.
type Shown struct {
Message channel.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
Message asks.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
}
// Store keeps what is shown, by the router's handle.
@@ -146,7 +146,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
return nil
}
var m channel.Message
var m asks.Message
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
@@ -158,8 +158,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// would appear later, to whoever is at the screen then.
seeing, why := d.seeing()
if len(seeing) == 0 {
f := channel.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
}
machines = seeing
}
@@ -176,7 +176,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
// it was shown; a message said again is shown anew where it was not.
edit := parts[4] == channel.Edit
edit := parts[4] == asks.Edit
var before *Shown
switch {
case edit:
@@ -226,8 +226,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
if len(machines) == 0 {
why = "no machine to show it on: the setting desktop-machines names none"
}
f := channel.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
f := asks.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
return err
}
return nil
@@ -322,7 +322,7 @@ func sameShown(a, b Shown) bool {
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m channel.Message, replace int) (int, error) {
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
urgency := "normal"
switch {
case m.Urgent:
@@ -376,12 +376,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
handle, shown := d.find(ticket)
if shown == nil {
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0)
return
}
if _, here := shown.On[machine]; !here {
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
return
}
@@ -395,11 +395,11 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
d.clickedOn[id] = machine
d.mu.Unlock()
c := channel.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: channel.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(channel.IntakeSubject(channel.Chosen, Kind), c, id); err != nil {
c := asks.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: asks.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(asks.IntakeSubject(asks.Chosen, Kind), c, id); err != nil {
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
_, _ = d.show(machine, channel.Message{Title: "That answer could not be taken now",
_, _ = d.show(machine, asks.Message{Title: "That answer could not be taken now",
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
}
}
@@ -508,7 +508,7 @@ func (d *Desk) seeing() ([]string, string) {
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() Standing {
d.init()
s := Standing{Standing: channel.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -542,7 +542,7 @@ func (d *Desk) SayStanding() {
if same {
return
}
if err := d.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
if err := d.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
d.Logf("[desk-channel] could not say whether it can send: %v", err)
return
}
@@ -10,7 +10,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/channel"
"git.novox.be/novox/mesh-sdk/go/asks"
)
type memShown map[string]Shown
@@ -62,10 +62,10 @@ func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][
return d, &shown, said, store
}
func take(t *testing.T, d *Desk, verb string, m channel.Message) {
func take(t *testing.T, d *Desk, verb string, m asks.Message) {
t.Helper()
raw, _ := json.Marshal(m)
if err := d.Take(channel.ChannelSubject(verb, Kind), raw); err != nil {
if err := d.Take(asks.ChannelSubject(verb, Kind), raw); err != nil {
t.Fatal(err)
}
}
@@ -74,16 +74,16 @@ func take(t *testing.T, d *Desk, verb string, m channel.Message) {
// says a click as a choice the router judges — only on a machine the message was shown on.
func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
d, shown, said, _ := newDesk(t, "laptop", "away-machine")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 ||
(*shown)[0].args["body"] != "Needs you: &lt;mend&gt; them." {
t.Fatalf("shown %+v", *shown)
}
d.Chosen("e1", "laptop", "t1")
d.Chosen("e1", "laptop", "t1") // a redelivery
got := said[channel.IntakeSubject(channel.Chosen, Kind)]
var c channel.Choice
got := said[asks.IntakeSubject(asks.Chosen, Kind)]
var c asks.Choice
if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" ||
c.Sender.Identity != "laptop" || c.Sender.Authenticated {
t.Fatalf("said %s", got)
@@ -91,14 +91,14 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// Elsewhere it is refused, there, and nothing is said to the router.
d.Chosen("e2", "workstation", "t1")
d.Chosen("e3", "laptop", "forged")
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
t.Error("a click not shown there was said")
}
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" {
t.Errorf("the refusal was not shown where it was chosen: %+v", last)
}
// The router's reply to the click is shown on the machine it was made on.
take(t, d, channel.Send, channel.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
take(t, d, asks.Send, asks.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" {
t.Errorf("%+v", last)
}
@@ -107,8 +107,8 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
func TestTheNoteFollowsTheBody(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
t.Errorf("the body: %q", last.args["body"])
}
@@ -116,8 +116,8 @@ func TestTheNoteFollowsTheBody(t *testing.T) {
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, channel.Edit, channel.Message{Handle: "m1", Title: "Silence for a week: chosen"})
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
@@ -134,17 +134,17 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, channel.Show, channel.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, channel.Edit, channel.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, channel.Show, channel.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []channel.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, channel.Edit, channel.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, asks.Show, asks.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []asks.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
openID := store["open"].On["laptop"]
before := len(*shown)
standings := len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
standings := len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
@@ -157,25 +157,25 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[channel.IntakeSubject(channel.StandingWhat, Kind)]
got := said[asks.IntakeSubject(asks.StandingWhat, Kind)]
var s Standing
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
t.Fatalf("the open message said again: %+v", last.args)
}
d.Chosen("e2", "laptop", "t-open")
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
t.Error("a click on the message said again was not said to the router")
}
// The same start event again (a redelivery) does nothing more.
standings = len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[channel.IntakeSubject(channel.StandingWhat, Kind)]) != standings {
standings = len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) != standings {
t.Errorf("a redelivered start was acted on again: %d", n)
}
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
@@ -187,8 +187,8 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
d, _, said, _ := newDesk(t, "away-machine")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "x"})
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "x"})
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
strings.Contains(string(f[0]), `"permanent":true`) {
t.Errorf("%s", f)
}
@@ -229,7 +229,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) {
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
d.Paused("laptop", false)
take(t, d, channel.Send, channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
if len(*shown) != 1 {
t.Fatal("not shown")
}
@@ -252,8 +252,8 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
if s := d.Standing(); !s.Ready {
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
}
code := channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, channel.Send, code)
code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, asks.Send, code)
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
t.Fatalf("the code was shown as %+v", *shown)
}
@@ -262,11 +262,11 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
}
// Paused: not shown anywhere, said as failed, and the desk is not ready.
d.Paused("laptop", true)
take(t, d, channel.Send, code)
take(t, d, asks.Send, code)
if len(*shown) != 1 {
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
}
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
t.Errorf("not said as failed: %s", f)
}
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") {
@@ -284,7 +284,7 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
t.Error("a machine not named made the desk ready")
}
// A message that is not secret still goes to every named machine, paused or not.
take(t, d, channel.Send, channel.Message{Handle: "m9", Title: "x"})
take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"})
if len(*shown) != 3 {
t.Errorf("an ordinary message was held back: %+v", *shown)
}
@@ -330,8 +330,8 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
// was not shown; a message under a new handle replaces nothing.
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop", "desktop")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, channel.Send, channel.Message{Handle: "m2", Title: "another problem"})
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
if len(*shown) != 4 {
t.Fatalf("shown %d", len(*shown))
}
@@ -343,8 +343,8 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
first := store["m1"].On
// The desk now names a third machine as well.
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
again := (*shown)[4:]
if len(again) != 3 {
t.Fatalf("said again on %d machines", len(again))
@@ -397,18 +397,18 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
raised, _ := json.Marshal(channel.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(channel.ChannelSubject(channel.Send, Kind), raised); err != nil {
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
t.Fatalf("the raise was not taken: %v", err)
}
cleared, _ := json.Marshal(channel.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(channel.ChannelSubject(channel.Edit, Kind), cleared); err != nil {
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
t.Fatalf("the clearing was not taken: %v", err)
}
d.KeepUnkept() // the minute's standing, store still down
down = !back
d.KeepUnkept()
take(t, d, channel.Send, channel.Message{Handle: "m10", Title: "another"})
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
}
@@ -423,7 +423,7 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
notify := d.Notify
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
@@ -432,13 +432,13 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
return notify(machine, args)
}
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
if got := store["m1"].On["desktop"]; got != first["desktop"] {
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
}
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[channel.IntakeSubject(channel.FailedWhat, Kind)]) != 1 {
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
t.Fatalf("shown nowhere, and not said as failed")
}
_ = shown
+2 -2
View File
@@ -2,10 +2,10 @@ module deskchannel
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -6
View File
@@ -1,9 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+1 -1
View File
@@ -33,7 +33,7 @@ All six only read.
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name With `unit` (a unit name such as `session-c1.scope`, or a cgroup path) it ranks that unit's processes instead (pid, command, cpu_pct, share_pct of the unit's busy time, threads), from `/proc/<pid>/stat` | `top`, `systemd-cgtop` |
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
### How each number is read
@@ -1,151 +0,0 @@
package main
import (
"fmt"
"io/fs"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// clockTicks is USER_HZ, the unit of utime and stime in /proc/<pid>/stat: 100 on every Linux the mesh runs.
const clockTicks = 100.0
// ProcessCPU is one process of a unit and its share of CPU over the window (cpu_top with unit set).
type ProcessCPU struct {
PID int `json:"pid"`
Command string `json:"command"` // comm, then the first 120 characters of the command line
CPUPct float64 `json:"cpu_pct"` // % of one CPU: 100 = one CPU busy the whole window
SharePct float64 `json:"share_pct"` // this process's share of the unit's busy time
Threads int `json:"threads"`
raw float64
}
// procTicks is a process's utime+stime ticks and thread count from its /proc/<pid>/stat, whose comm
// field may hold spaces and parentheses, so the fields are counted from the last ")".
func parseProcStat(s string) (comm string, ticks uint64, threads int, start string, ok bool) {
l, r := strings.Index(s, "("), strings.LastIndex(s, ")")
if l < 0 || r < l {
return "", 0, 0, "", false
}
f := strings.Fields(s[r+1:])
if len(f) < 20 {
return "", 0, 0, "", false
}
ut, e1 := strconv.ParseUint(f[11], 10, 64)
st, e2 := strconv.ParseUint(f[12], 10, 64)
th, e3 := strconv.Atoi(f[17])
if e1 != nil || e2 != nil || e3 != nil {
return "", 0, 0, "", false
}
return s[l+1 : r], ut + st, th, f[19], true
}
// cgroupOfUnit is the cgroup path (relative to /sys/fs/cgroup) a unit name or a path names.
func (m *Machine) cgroupOfUnit(unit string) (string, error) {
base := m.path("/sys/fs/cgroup")
if strings.Contains(unit, "/") {
rel := strings.Trim(strings.TrimPrefix(unit, "/sys/fs/cgroup"), "/")
if strings.Contains(rel, "..") || filepath.Clean("/"+rel) != "/"+rel {
return "", fmt.Errorf("cgroup %q is not a path under /sys/fs/cgroup", unit)
}
if rel == "" || !m.exists("/sys/fs/cgroup/"+rel) {
return "", fmt.Errorf("no cgroup %q", unit)
}
return rel, nil
}
var found []string
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err == nil && d.IsDir() && d.Name() == unit {
rel, _ := filepath.Rel(base, p)
found = append(found, filepath.ToSlash(rel))
}
return nil
})
switch len(found) {
case 0:
return "", fmt.Errorf("no unit %q in the cgroup tree", unit)
case 1:
return found[0], nil
}
return "", fmt.Errorf("unit %q is %d cgroups (%s): give the cgroup path", unit, len(found), strings.Join(found, ", "))
}
type procReading struct {
ticks uint64
comm string
threads int
pid int
start string // starttime, to tell a reused pid
}
// unitProcs reads every process in a cgroup and its child cgroups: its ticks, name and threads.
func (m *Machine) unitProcs(cgroup string) map[int]procReading {
out := map[int]procReading{}
base := m.path("/sys/fs/cgroup/" + cgroup)
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || d.Name() != "cgroup.procs" {
return nil
}
for _, f := range strings.Fields(m.read(strings.TrimPrefix(p, filepath.Clean(m.Root)))) {
pid, err := strconv.Atoi(f)
if err != nil {
continue
}
comm, ticks, th, start, ok := parseProcStat(m.read("/proc/" + f + "/stat"))
if ok {
out[pid] = procReading{ticks: ticks, comm: comm, threads: th, pid: pid, start: start}
}
}
return nil
})
return out
}
// commandLine is a process's program path (argv[0], cut to 120 characters), never its arguments:
// they can carry a password.
func (m *Machine) commandLine(pid int) string {
s, _, _ := strings.Cut(m.read("/proc/"+strconv.Itoa(pid)+"/cmdline"), "\x00")
s, _, _ = strings.Cut(s, " ") // a process that rewrites its title puts its whole command line here
s, _, _ = strings.Cut(s, "\t")
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > 120 {
s = string(r[:120])
}
return s
}
// topProcessCPU ranks a unit's processes by CPU between two readings; a process missing from either
// reading (started or ended in the window) is skipped.
func (m *Machine) topProcessCPU(a, b map[int]procReading, window time.Duration, limit int) []ProcessCPU {
var out []ProcessCPU
var sum float64
for pid, end := range b {
start, ok := a[pid]
if !ok || end.start != start.start || end.ticks <= start.ticks {
continue
}
pct := float64(end.ticks-start.ticks) / clockTicks / window.Seconds() * 100
sum += pct
out = append(out, ProcessCPU{PID: pid, Command: end.comm, Threads: end.threads, raw: pct})
}
sort.Slice(out, func(i, j int) bool {
if out[i].raw != out[j].raw {
return out[i].raw > out[j].raw
}
return out[i].PID < out[j].PID
})
if len(out) > limit {
out = out[:limit]
}
for i := range out {
out[i].CPUPct = round1(out[i].raw)
out[i].SharePct = round1(out[i].raw / sum * 100)
if cl := m.commandLine(out[i].PID); cl != "" {
out[i].Command += ": " + cl
}
}
return out
}
@@ -180,52 +180,3 @@ func TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows(t *testing.T) {
t.Errorf("the user session's init.scope: %+v", byCgroup)
}
}
// TestCPUTopRanksAUnitsProcesses is the unit argument: a session scope with a child cgroup, ranked by
// the utime+stime each process used over a 2 s window; one process ends, one starts, one comm holds ")".
func TestCPUTopRanksAUnitsProcesses(t *testing.T) {
f := aCPUHog(t)
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/cgroup.procs", "10\n11\n12\n")
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/app/cgroup.procs", "13\n")
st := func(pid int, comm string, ticks, threads int) {
f.then("/proc/"+itoa(pid)+"/stat", itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 "+itoa(ticks)+" 0 0 0 20 0 "+itoa(threads)+" 0 1 1 1\n")
}
for pid, c := range map[int][2]string{10: {"firefox", "1"}, 11: {"sh (x)", "1"}, 12: {"gone", "1"}} {
f.file("/proc/"+itoa(pid)+"/stat", itoa(pid)+" ("+c[0]+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 "+c[1]+" 0 1 1 1\n")
}
f.file("/proc/13/stat", "13 (deep) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 4 0 1 1 1\n")
f.file("/proc/10/cmdline", "/usr/bin/firefox\x00--new-window\x00")
f.file("/proc/11/cmdline", "mysql\x00--password\x00hunter2\x00")
f.file("/proc/13/cmdline", "/usr/bin/tool --password hunter2 -x") // a rewritten title: one string, spaces, no NUL
st(10, "firefox", 150, 40) // 75 % of one CPU over 2 s
st(11, "sh (x)", 40, 1) // 20 %
st(13, "deep", 10, 4) // 5 %, in the child cgroup
f.then("/sys/fs/cgroup/user.slice/session-c1.scope/cgroup.procs", "10\n11\n14\n")
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/app/cgroup.procs", "13\n15\n")
f.file("/proc/15/stat", "15 (old) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 1 1\n")
f.then("/proc/15/stat", "15 (reused) S 1 1 1 0 -1 0 0 0 0 0 900 0 0 0 20 0 1 0 77 1 1\n") // new starttime: another process
st(14, "newborn", 500, 1) // not in the first reading: skipped
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": "session-c1.scope", "seconds": 2.0, "limit": 3.0})
if err != nil {
t.Fatal(err)
}
top := out.(map[string]any)["top"].([]ProcessCPU)
if len(top) != 3 || top[2].PID != 13 || top[2].CPUPct != 5 || top[0].PID != 10 || top[0].CPUPct != 75 || top[0].SharePct != 75 || top[0].Threads != 40 ||
top[0].Command != "firefox: /usr/bin/firefox" || top[1].PID != 11 || top[1].CPUPct != 20 || top[1].Command != "sh (x): mysql" || top[2].Command != "deep: /usr/bin/tool" {
t.Errorf("processes of the unit: %+v", top)
}
for _, p := range top {
if p.PID == 15 || strings.Contains(p.Command, "hunter2") {
t.Errorf("a reused pid was counted, or an argument leaked: %+v", p)
}
}
for _, u := range []string{"../../etc", "/sys/fs/cgroup/../../proc", "user.slice/../.."} {
if _, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": u}); err == nil {
t.Errorf("cgroup path %q escaping the root was accepted", u)
}
}
if _, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": "nothing.scope"}); err == nil {
t.Error("an unknown unit was accepted")
}
}
+1 -23
View File
@@ -90,14 +90,11 @@ func Tools(m *Machine) []stdio.Tool {
Description: "What used the most CPU over a few seconds, from each unit's cgroup cpu.stat, which needs no root: the machine's " +
"CPU in use (as % of all CPUs and as CPUs busy, from /proc/stat), then by=unit (the default) each systemd unit and " +
"container ranked by CPU, busiest first, with the busy time no unit accounts for; by=container only the containers, " +
"by name. With unit (a systemd unit such as session-c1.scope, or its cgroup path) it ranks that unit's processes instead, CPU by process, the top process: " +
"pid, command, cpu_pct, share_pct of the unit's busy time and threads, from /proc/<pid>/stat, needing no root; a desktop session is one unit, " +
"and this names the program in it. Replaces top, systemd-cgtop. (r)",
"by name. Replaces top, systemd-cgtop. (r)",
Input: map[string]any{
"seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, at most 10)"},
"limit": map[string]any{"type": "integer", "description": "how many units or containers to name (default 15, at most 50)"},
"by": map[string]any{"type": "string", "enum": []string{"unit", "container"}, "description": "what to rank (default unit)"},
"unit": map[string]any{"type": "string", "description": "a systemd unit name (session-c1.scope) or cgroup path: rank that unit's processes by CPU instead"},
},
Run: func(args map[string]any) (any, error) {
n, err := count(args, "seconds", 3, 1, 10)
@@ -121,25 +118,6 @@ func Tools(m *Machine) []stdio.Tool {
if err != nil {
return nil, err
}
if unit := text(args, "unit"); unit != "" {
cg, err := m.cgroupOfUnit(unit)
if err != nil {
return nil, err
}
pa := m.unitProcs(cg)
m.Wait(w)
bc, err := parseCPU(m.read("/proc/stat"))
if err != nil {
return nil, err
}
return map[string]any{
"window_s": w.Seconds(),
"by": "process",
"cgroup": cg,
"cpu": machineCPUOver(ac, bc),
"top": m.topProcessCPU(pa, m.unitProcs(cg), w, limit),
}, nil
}
a := m.unitCPU()
m.Wait(w)
bc, err := parseCPU(m.read("/proc/stat"))

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