Compare commits

..
50 changed files with 440 additions and 3850 deletions
-7
View File
@@ -14,7 +14,6 @@ modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
@@ -35,9 +34,3 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
/checks/paths/paths
/modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate
modules/mesh-config/cmd/mesh-config/mesh-config
checks/placement/placement
# Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools
modules/networkmanager/uplink-tools
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/nobinaries
go 1.22
-78
View File
@@ -1,78 +0,0 @@
// Package nobinaries refuses a compiled program under modules/ (issue 1577): it is build output, made by the module's build step,
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
package nobinaries
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Kind says what executable format the first bytes of a file are, or "" when they are none.
func Kind(head []byte) string {
switch {
case bytes.HasPrefix(head, []byte("\x7fELF")):
return "ELF"
case bytes.HasPrefix(head, []byte("MZ")):
return "PE"
case bytes.HasPrefix(head, []byte{0xcf, 0xfa, 0xed, 0xfe}), bytes.HasPrefix(head, []byte{0xce, 0xfa, 0xed, 0xfe}),
bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xcf}), bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xce}),
bytes.HasPrefix(head, []byte{0xca, 0xfe, 0xba, 0xbe}) && len(head) >= 8 && head[7] < 0x20 && head[6] == 0 && head[5] == 0 && head[4] == 0:
return "Mach-O"
}
return ""
}
// Files are the files of the catalogue's root that the change holds under modules/: the tracked ones when the root is a git
// checkout (an ignored binary a local build left is not the change's), else every file there.
func Files(root string) ([]string, error) {
cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "modules")
if out, err := cmd.Output(); err == nil && len(out) > 0 {
var files []string
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
if _, err := os.Lstat(filepath.Join(root, f)); err == nil { // a file the change deletes is not there
files = append(files, f)
}
}
return files, nil
}
var files []string
err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !d.Type().IsRegular() {
return err
}
rel, _ := filepath.Rel(root, p)
files = append(files, rel)
return nil
})
return files, err
}
// Find names each file of files (paths from root) that is a compiled program, with its format.
func Find(root string, files []string) ([]string, error) {
var found []string
for _, f := range files {
p := filepath.Join(root, f)
if st, err := os.Lstat(p); err != nil || !st.Mode().IsRegular() {
continue
}
fh, err := os.Open(p)
if err != nil {
return nil, err
}
head := make([]byte, 8)
n, err := io.ReadFull(fh, head)
fh.Close()
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
if k := Kind(head[:n]); k != "" {
found = append(found, fmt.Sprintf("%s (%s)", f, k))
}
}
return found, nil
}
-54
View File
@@ -1,54 +0,0 @@
package nobinaries
import (
"os"
"path/filepath"
"testing"
)
func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
root := t.TempDir()
files := map[string][]byte{
"modules/a/a": []byte("\x7fELF\x02\x01\x01\x00rest"),
"modules/b/cmd/b/b.exe": []byte("MZ\x90\x00"),
"modules/c/c": {0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1},
"modules/d/main.go": []byte("package main\n"),
"modules/e/short": []byte("\x7f"),
"modules/f/empty": {},
"modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O
}
var names []string
for f, b := range files {
p := filepath.Join(root, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
names = append(names, f)
}
got, err := Find(root, names)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c only", got)
}
}
// The catalogue's own tree holds no compiled program: a compiled program is build output, so remove it and add it to .gitignore.
func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
root := filepath.Join("..", "..")
files, err := Files(root)
if err != nil {
t.Fatal(err)
}
found, err := Find(root, files)
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f)
}
}
-1
View File
@@ -1 +0,0 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
-5
View File
@@ -1,5 +0,0 @@
module checks/placement
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36
-30
View File
@@ -1,30 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026171759-a431746c0e8c h1:zaSfYTJd03b0pF29QTZ6G5ajfjOXRXjHIpOiGu/niHA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026171759-a431746c0e8c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026172611-5b1e990eb4bb h1:Mo+XdKBGFhUbH1XLSyMdkqIL/JhMIUAucjT+ipZoTfg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026172611-5b1e990eb4bb/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026173336-9789c8379a74 h1:+LrIwBeG0eCN6xOS9jhzL6ag0ipXHZ1vfCFEJqtT1eI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026173336-9789c8379a74/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6 h1:sIagBHevPfpISBRK2r5EemnhHEGubdfEx/l3K6ujomo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4 h1:rKDfp+IHnv903jcCujf5IdTrWd3mnudpNCfKaKvqp3w=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181846-91413de33516 h1:xbbss6kQFrAiyy8UEvqh2Ddi2KP+O9oTUSh7GkqmCYU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181846-91413de33516/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520 h1:twiziR88X6ttKmw5JTd3aW6xb5bMZZ3Bl7WS2QOP3PE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18 h1:HWNPfnT9YT9O6xHPLy2y6Q+LYT8XRjvLnZw9O61NETk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36 h1:aBkmFUq/zAVdR3PzfgjjC9AxSymNl3eVC7UEZv9tZ0Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
-80
View File
@@ -1,80 +0,0 @@
// Command placement is the catalogue's check of its placement files (novox/hq ADR 0549 §1, ADR 0555; issue 1126): every
// `placement/<node>.json` reads as a placement of the node its name says, and the files together are refused, every
// finding named at once, for two modules of one node on one host port, a pin, binding or holding naming a provider not
// placed where it says, a seat held once for the mesh held twice, two nodes with one address on the private network, or
// more than one hub (mesh-sdk delivery.CheckPlacements).
//
// A module the catalogue does not hold and a node not enrolled are the composition check's to refuse: placement names
// modules of every repository the indexer reads and the nodes the mesh enrolled, neither of which this tree holds.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Check reads every placement file under root and answers each finding; none is a catalogue whose placement holds. A
// catalogue with no placement directory has none to check.
func Check(root string) ([]string, error) {
dir := filepath.Join(root, delivery.PlacementDir)
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var found []string
placements := map[string]delivery.Placement{}
for _, e := range entries {
rel := delivery.PlacementDir + "/" + e.Name()
node, ok := delivery.PlacementFileNode(rel)
if e.IsDir() || !ok {
found = append(found, fmt.Sprintf("%s is not a placement file: the directory holds only <node>.json, one per node", rel))
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
p, err := delivery.DecodePlacement(raw)
if err != nil {
found = append(found, fmt.Sprintf("%s does not read as a placement: %v", rel, err))
continue
}
if p.Revision != "" {
found = append(found, fmt.Sprintf("%s names a revision, which only the indexer writes into the key", rel))
}
placements[node] = p
}
all := func(string) bool { return true }
found = append(found, delivery.CheckPlacements(placements, all, all)...)
sort.Strings(found)
return found, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: placement <catalogue root>")
os.Exit(2)
}
found, err := Check(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The catalogue's placement files as the merge check reads them (ADR 0549 §1, ADR 0555): the real tree holds every
// rule, and a tree that breaks them is refused with every finding named at once.
func TestPlacementFiles(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s (novox/hq ADR 0549 §1)", f)
}
root := t.TempDir()
write := func(name, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "placement"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "placement", name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if found, err := Check(root); err != nil || len(found) != 0 {
t.Fatalf("a catalogue with no placement directory: %v, %v", found, err)
}
write("ace.json", `{"v":1,"node":"ace","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"a","ports":{"80":8080}},{"module":"b","ports":{"81":8080}}]}`)
write("g14.json", `{"v":1,"node":"g14","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"c","bindings":{"pg":{"node":"ace","module":"pg"}}}]}`)
write("novox.json", `{"v":1,"node":"shanks","modules":[]}`)
write("bad.json", `{"v":1,"node":"bad","modules":{}}`)
write("dated.json", `{"v":1,"node":"dated","revision":"abc","modules":[]}`)
write("notes.md", "x")
found, err = Check(root)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"a and b are both published on host port 8080", "bound to ace/pg", "both have the address 10.77.0.1",
"ace, g14 are each placed as it", "placement file of novox names the node", "bad.json does not read", "dated.json names a revision",
"notes.md is not a placement file"} {
hit := false
for _, f := range found {
hit = hit || strings.Contains(f, want)
}
if !hit {
t.Errorf("no finding says %q; found %v", want, found)
}
}
}
-1
View File
@@ -1 +0,0 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
Binary file not shown.
+45 -22
View File
@@ -540,7 +540,7 @@ export class GiteaClient {
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
* see, since a directory left out would be taken for one without a module. Reads only. */
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>, listed?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; listed?: Record<string, Record<string, string>> }> {
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
const entries: any[] = [];
@@ -567,23 +567,7 @@ export class GiteaClient {
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
trees[dir] = tree;
}
// **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name
// to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a
// merge. A directory the commit lacks lists nothing (the catalogue before its first placement file).
let files: Record<string, Record<string, string>> | undefined;
if (listed !== undefined) {
files = {};
for (const dir of listed) {
files[dir] = {};
const prefix = dir === "" ? "" : `${dir}/`;
for (const e of entries) {
if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue;
const name = e.path.slice(prefix.length);
if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha);
}
}
}
if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files };
if (paths === undefined) return { root, trees };
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
// naming both, and nothing is answered in part.
@@ -598,7 +582,7 @@ export class GiteaClient {
named[dir][p] = id;
}
}
return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files };
return { root, trees, paths: named };
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -1063,12 +1047,19 @@ export class GiteaAdmin {
return true;
}
/** What the forge itself says an account may do on a repository: none, read, write, admin or owner. */
/** What the forge gives an account on a repository, effective: none, read, write, admin or owner. It is the repository as
* that account sees it (the site administrator acts as the account with `sudo`), so access through an organisation's team
* counts. The collaborator endpoint is not used: it names only a collaborator's own permission, and says "read" of an
* account that writes through a team (novox/hq issue 1945). */
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}/permission`;
const path = `${repoPath(owner, repo)}?sudo=${encodeURIComponent(user)}`;
const res = await this.request(path);
if (res.status === 404) return "none";
if (res.status !== 200) GiteaAdmin.fail(path, res);
return String(res.body?.permission ?? "none");
const p = res.body?.permissions;
if (p?.admin === true) return res.body?.owner?.login === user ? "owner" : "admin";
if (p?.push === true) return "write";
return p?.pull === true ? "read" : "none";
}
/** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for
@@ -1083,6 +1074,38 @@ export class GiteaAdmin {
await this.addCollaborator(owner, repo, user, "write");
}
/** The rule that applies to main in a repository, as its administrator reads it: a rule named main, else the first glob
* rule covering main; null when none does. With the administrator's login. */
async mainRule(owner: string, repo: string): Promise<BranchProtection | null> {
const rules = await this.branchProtections(owner, repo);
return rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH)
?? rules.find((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH)) ?? null;
}
/** Put an account on main's merge whitelist in a repository of a user, and nothing else (the operator, 2026-10-26:
* write, the rule untouched). Only an existing rule whose merge whitelist is on is changed, and only its whitelist: the
* account is added to the logins already there. Push, status checks, approvals and the administrator override stay as
* the operator set them; with no rule, or the whitelist off, nothing is written (whoever writes may merge). Read back:
* the forge drops a whitelist login that cannot write the repository and still answers success, so a whitelist read back
* without the account fails loud. Answers the rule as the forge holds it after. With the administrator's login. */
async ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }> {
const rule = await this.mainRule(owner, repo);
const listed = rule?.merge_whitelist_usernames ?? [];
if (!rule || rule.enable_merge_whitelist !== true || listed.includes(account)) return { changed: false, rule };
const name = String(rule.rule_name ?? rule.branch_name);
const path = `${repoPath(owner, repo)}/branch_protections/${encodeURIComponent(name)}`;
const res = await this.request(path, { method: "PATCH", body: JSON.stringify({ merge_whitelist_usernames: [...listed, account] }) });
if (res.status !== 200) GiteaAdmin.fail(path, res);
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === name);
if (!after) throw new Error(`main's rule ${name} of ${owner}/${repo} was written, but the forge does not list it`);
const held = after.merge_whitelist_usernames ?? [];
if (after.enable_merge_whitelist === true && !held.includes(account)) {
throw new Error(`${account} was added to the merge whitelist of main's rule ${name} of ${owner}/${repo}, but the forge ` +
`holds [${held.join(", ")}]: it drops a login that cannot write the repository`);
}
return { changed: true, rule: after };
}
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
+1 -15
View File
@@ -183,15 +183,6 @@ test("named paths: each asked path's git object id at the commit, a file's or a
});
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
// Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none.
const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]);
assert.deepEqual(listed.listed, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": {},
"": { "README.md": h("1"), "module.json": h("2") },
placement: {},
});
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed");
} finally {
server.close();
}
@@ -218,12 +209,7 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments");
const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any);
assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } });
for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad));
}
assert.ok("paths" in tool.input, "the tool names the argument");
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
const before = asked.length;
+53 -4
View File
@@ -28,6 +28,7 @@ interface Team { id: number; name: string; units: any; members: Set<string> }
interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
const forgeRepos: ForgeRepo[] = [];
const userRules = new Map<string, any>();
const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() };
function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo {
const [owner, name] = full.split("/");
@@ -84,7 +85,7 @@ function fakeForge() {
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const p = url.pathname.replace(/^\/api\/v1/, "");
const me = who(req);
const me = url.searchParams.has("sudo") && isAdmin(who(req)) ? url.searchParams.get("sudo") : who(req);
calls.push(`${req.method} ${p} as ${me}`);
if (me === null) return json(res, 401, { message: "unauthorized" });
@@ -168,6 +169,23 @@ function fakeForge() {
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
return json(res, 404, { message: "branch does not exist" });
}
// A repository of a user: its rules are its administrator's (a site administrator, or a collaborator with admin).
m = p.match(/^\/repos\/(?!novox\/)([^/]+)\/([^/]+)\/branch_protections(?:\/(.*))?$/);
if (m) {
const key = `${m[1]}/${m[2]}`;
const repo = forgeRepos.find((r) => r.full === key);
if (!repo || !["admin", "owner"].includes(permissionOf(me, repo))) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (req.method === "GET" && m[3] === undefined) return json(res, 200, userRules.has(key) ? [userRules.get(key)] : []);
// As gitea does: a whitelist login that cannot write the repository is dropped, and the write still answered.
const keep = (b: any) => (Array.isArray(b.merge_whitelist_usernames)
? { ...b, merge_whitelist_usernames: b.merge_whitelist_usernames.filter((u: string) => ["write", "admin", "owner"].includes(permissionOf(u, repo))) } : b);
if (req.method === "PATCH" && m[3] === "main") {
if (!userRules.has(key)) return json(res, 404, { message: "branch protection does not exist" });
Object.assign(userRules.get(key), keep(await read(req)));
return json(res, 200, userRules.get(key));
}
if (req.method === "POST") { const b = keep(await read(req)); userRules.set(key, b); return json(res, 201, b); }
}
// gitea's reqAdmin on the whole group: reading the rules is administration too.
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/);
if (m) {
@@ -205,6 +223,7 @@ function fakeForge() {
m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/);
if (m) {
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
if (repo && req.method === "GET" && !m[3] && url.searchParams.has("sudo") && repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
if (req.method === "GET" && !m[3]) {
const mine = permissionOf(me, repo);
@@ -214,7 +233,8 @@ function fakeForge() {
if (req.method === "GET" && m[4]) {
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" });
return json(res, 200, { permission: permissionOf(user, repo) });
// As gitea answers: the collaborator's own permission, not what a team gives the account.
return json(res, 200, { permission: repo.collaborators.get(user) ?? "read" });
}
if (req.method === "PUT" && !m[4]) {
// gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds.
@@ -608,7 +628,8 @@ test("the merging account is given write on each repository the indexer's module
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write");
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write", "a user's repository is written, not administered");
assert.equal(userRules.has("jschoubben/car-hunter"), false, "a repository with no rule for main is given none");
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
@@ -636,9 +657,37 @@ test("the merging account is given write on each repository the indexer's module
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from");
assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
repoFaults.refuse.clear();
// The operator's rule is left as set (the operator, 2026-10-26: "Write, rule untouched"): only mesh-tools is added to its
// merge whitelist; a status the mesh does not set and required approvals are said as blocking the mesh's merge, unchanged.
const operators = { rule_name: "main", enable_push: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "ci/external"], required_approvals: 2,
block_admin_merge_override: false, require_signed_commits: true, block_on_outdated_branch: true, block_on_rejected_reviews: true };
userRules.set("jschoubben/snake", structuredClone(operators));
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
assert.deepEqual(after.granted, ["jschoubben/snake"]);
assert.deepEqual(((await unwritableTool.run({})) as any).unwritable, []);
assert.equal(repo("jschoubben/snake").collaborators.get(TOOLS_ACCOUNT), "write");
assert.deepEqual(userRules.get("jschoubben/snake"), { ...operators, merge_whitelist_usernames: ["jschoubben", TOOLS_ACCOUNT] }, "only the whitelist changed");
assert.deepEqual(after.merge_whitelist, [{ repository: "jschoubben/snake", changed: true, listed: ["jschoubben", TOOLS_ACCOUNT] }]);
const blockedWhy = "writable, but main's rule blocks the mesh's merge: it requires the status ci/external, which the mesh does not set; " +
"it requires 2 approving reviews on the forge, which the gate does not give; it requires signed commits, which the mesh does not make; " +
"it refuses a branch behind main, which the mesh does not bring up to date; " +
"it refuses a merge after a review asked for changes, which the gate does not clear";
assert.deepEqual(after.merge_blocked, [{ repository: "jschoubben/snake", why: blockedWhy }]);
const again = await unwritableTool.run({}) as any;
assert.deepEqual([again.unwritable, again.merge_blocked], [[], [{ repository: "jschoubben/snake", why: blockedWhy }]]);
const writesBefore = forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length;
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_whitelist[0].changed, false);
assert.equal(forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length, writesBefore, "a listed account is not written again");
// The mesh's own statuses never block: a rule requiring only them, with mesh-tools listed, is not reported.
userRules.set("jschoubben/snake", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT],
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "mesh/repo-check"] });
assert.deepEqual(((await unwritableTool.run({})) as any).merge_blocked, []);
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_blocked, []);
// The forge drops a whitelist login that cannot write and still answers 200: the whitelist is read back and that fails loud.
userRules.set("jschoubben/dotfiles", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"] });
await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT),
/mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/);
userRules.delete("jschoubben/dotfiles");
// A module gone from the indexer leaves its repository out of the delivered list.
await writes.take({ key: "snake", op: "delete" });
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
+2 -14
View File
@@ -76,17 +76,6 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
return out;
}
/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root
* the empty name); anything else is refused before the forge is asked. Absent is absent. */
function listedDirs(raw: unknown): string[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]");
for (const d of raw as string[]) {
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`);
}
return raw as string[];
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
}
@@ -561,20 +550,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalogue's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit, its full 40-hex sha" },
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), listedDirs(args.listed));
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
},
},
{
+100 -25
View File
@@ -18,10 +18,18 @@
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request
// whose merge the forge would refuse, reads that repository again on it.
//
// **A repository of a user is written, and main's rule is left as the operator set it** (the operator, 2026-10-26, asked
// "Write, rule untouched"): the account is a write collaborator there, and only added to main's merge whitelist when that
// rule keeps one (client.ts ensureMergerListed). What else in main's rule stops the mesh's merge (a status the mesh does not
// set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but
// blocked, with why (merge_blocked).
//
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it;
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
import type { ForgeRepository } from "./client.js";
import type { BranchProtection } from "./protection.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js";
/** The indexer's state this module reads: one key per registered module, its value naming the repository. */
export const INDEXER_MODULES = "mesh-delivery-indexer.modules";
@@ -34,8 +42,35 @@ export interface WriteForge {
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
mainRule(owner: string, repo: string): Promise<BranchProtection | null>;
ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }>;
}
/** The statuses every pull request the mesh delivers carries (mesh-delivery's checks); a rule requiring another blocks. */
const MESH_CONTEXTS = new Set([CHECK_STATUS, CHECK_CONTEXT, REPO_CHECK_CONTEXT]);
/** What in main's rule stops the mesh's merge, in words; empty when nothing does. Changed by nobody here: the operator's. */
export function mergeBlocks(rule: BranchProtection | null, account: string): string[] {
if (!rule) return [];
const blocks: string[] = [];
if (rule.enable_merge_whitelist === true && !(rule.merge_whitelist_usernames ?? []).includes(account)) {
blocks.push(`its merge whitelist [${(rule.merge_whitelist_usernames ?? []).join(", ")}] leaves out ${account} (gitea_grant_write adds it)`);
}
if (rule.enable_status_check === true) {
for (const c of rule.status_check_contexts ?? []) {
if (!MESH_CONTEXTS.has(c)) blocks.push(`it requires the status ${c}, which the mesh does not set`);
}
}
const approvals = Number(rule.required_approvals ?? 0);
if (approvals > 0) blocks.push(`it requires ${approvals} approving review${approvals === 1 ? "" : "s"} on the forge, which the gate does not give`);
if (rule.require_signed_commits === true) blocks.push("it requires signed commits, which the mesh does not make");
if (rule.block_on_outdated_branch === true) blocks.push("it refuses a branch behind main, which the mesh does not bring up to date");
if (rule.block_on_rejected_reviews === true) blocks.push("it refuses a merge after a review asked for changes, which the gate does not clear");
return blocks;
}
const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`;
/** One change of the indexer's modules state, as the SDK's state watch hands it. */
export interface ModulesChange {
key: string;
@@ -51,8 +86,13 @@ export interface Written {
delivered: string[];
/** Made writable now, each read back from the forge. */
granted: string[];
/** Written already (the organisation's team, or a collaborator that writes). */
/** Written already (a collaborator of a user's repository), or written through the organisation's team. */
already: string[];
/** Each repository of a user: whether the account was added to main's merge whitelist now, and the whitelist read back
* (null: main's rule keeps no whitelist, or there is no rule, and whoever writes may merge). */
merge_whitelist: { repository: string; changed: boolean; listed: string[] | null }[];
/** Writable, but main's rule (the operator's, left as set) stops the mesh's merge, and why. */
merge_blocked: { repository: string; why: string }[];
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
refused: { repository: string; why: string }[];
/** Repositories the account cannot write that are left so, and why: they build no registered module. */
@@ -118,7 +158,7 @@ export class DeliveredWrites {
"(the watch is not granted or not answered); nothing is granted");
}
const delivered = this.delivered();
const result: Written = { account: this.account, delivered, granted: [], already: [], refused: [], left_read_only: [] };
const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] };
const named = only && only.length ? only.map((x) => String(x).trim()) : null;
const targets: string[] = [];
for (const full of named ?? delivered) {
@@ -140,36 +180,55 @@ export class DeliveredWrites {
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
continue;
}
if (WRITES.has(permission)) {
this.writable.add(full);
result.already.push(full);
continue;
}
if (owner === this.org) {
if (WRITES.has(permission)) {
this.writable.add(full);
result.already.push(full);
continue;
}
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator.
result.refused.push({ repository: full, why: `a repository of ${this.org}, which ${this.account} writes through the ` +
`team ${this.org}/${this.account}; the forge gives it "${permission}", so the team is not as it should be (it is ` +
"set again at the next mint of the tools' token)" });
continue;
}
try {
await this.forge.addWriteCollaborator(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: said(err) });
continue;
// A repository of a user: the account writes it, and is put on main's merge whitelist when the rule keeps one.
let changed = false;
if (!WRITES.has(permission)) {
try {
await this.forge.addWriteCollaborator(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: said(err) });
continue;
}
let after: string;
try {
after = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
continue;
}
if (!WRITES.has(after)) {
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
continue;
}
changed = true;
}
let after: string;
try {
after = await this.forge.repositoryPermission(owner, name, this.account);
const { changed: listedNow, rule } = await this.forge.ensureMergerListed(owner, name, this.account);
result.merge_whitelist.push({ repository: full, changed: listedNow,
listed: rule?.enable_merge_whitelist === true ? (rule.merge_whitelist_usernames ?? []) : null });
const blocks = mergeBlocks(rule, this.account);
if (blocks.length > 0) result.merge_blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) {
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
continue;
}
if (!WRITES.has(after)) {
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
result.refused.push({ repository: full, why: `${this.account} writes it, but could not be put on main's merge whitelist — ${said(err)}` });
continue;
}
this.writable.add(full);
if (!changed) {
result.already.push(full);
continue;
}
result.granted.push(full);
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) =>
this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
@@ -192,10 +251,12 @@ export class DeliveredWrites {
}
/** Every delivered repository the account cannot write, as the forge says now, and why. */
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[] }> {
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[];
merge_blocked: { repository: string; why: string }[] }> {
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`);
const delivered = this.delivered();
const out: { repository: string; permission: string; why: string }[] = [];
const blocked: { repository: string; why: string }[] = [];
for (const full of delivered) {
const [owner, name] = split(full);
let permission: string;
@@ -205,12 +266,21 @@ export class DeliveredWrites {
out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` });
continue;
}
if (WRITES.has(permission)) continue;
if (WRITES.has(permission)) {
if (owner === this.org) continue; // the organisation's main rules are the mesh's own (mainrules.ts)
try {
const blocks = mergeBlocks(await this.forge.mainRule(owner, name), this.account);
if (blocks.length > 0) blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) {
blocked.push({ repository: full, why: `writable, but main's rule could not be read — ${said(err)}` });
}
continue;
}
const modules = [...this.modules].filter(([, r]) => r === full).map(([m]) => m).sort();
out.push({ repository: full, permission, why: `builds ${modules.join(", ")}, and the forge gives ${this.account} ` +
`"${permission}" on it: its commit status and its merge are refused until gitea_grant_write grants it` });
}
return { account: this.account, delivered: delivered.length, unwritable: out };
return { account: this.account, delivered: delivered.length, unwritable: out, merge_blocked: blocked };
}
}
@@ -224,14 +294,19 @@ export function writeTools(writes: () => DeliveredWrites): Tool[] {
description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " +
"the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " +
"permission the forge gives it there and why: such a pull request can be checked but never merged, and the " +
"delivery gate does not ask its Approve. Empty when it writes every one. Read with the site administrator's login.",
"delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +
"a user it writes whose main's rule (the operator's, left as set) still stops the mesh's merge: a merge whitelist " +
"without it, a required status the mesh does not set, required approving reviews. Read with the site administrator's login.",
input: {},
run: async () => writes().unwritable(),
},
{
name: "gitea_grant_write",
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository that " +
"builds a registered module and that it cannot write now, or on the repositories named (each must build one). " +
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository of a " +
"user that builds a registered module, or on the repositories named (each must build one). Main's rule there is left " +
"as the operator set it; only when it keeps a merge whitelist is mesh-tools added to it, read back (merge_whitelist). " +
"What else in that rule stops the mesh's merge (a required status the mesh does not set, required approving reviews) " +
"is changed by nobody and said in merge_blocked. " +
"The module does this itself at its start and when a module of a new repository is registered; this is the same " +
"act by hand, such as when a merge or a commit status was refused 403. A repository of the organisation novox " +
"is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
+1 -1
View File
@@ -115,7 +115,7 @@ func resolving(c *Config) {
}
return nil
}
for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
for _, read := range []string{AssignmentsRead, DeclaredConfigRead, PredefinedRead} {
go func(read string) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
time.Sleep(wait)
@@ -21,16 +21,4 @@ func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer pulled in moved %+v", r.Moved)
}
// 1126 (ADR 0549 §3, §5): a module the catalogue's placement names on a node resolves there as an assigned one does,
// while the two sources stand; it goes when neither names it.
m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[{"module":"dunst"}]}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a placed module's values moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(PlacementRead, "placement.ace")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer placed moved %+v", r.Moved)
}
}
@@ -20,10 +20,7 @@ const (
PredefinedState = "predefined"
ProposalsState = "proposals"
AssignmentsRead = "mesh-controller.assignments"
// PlacementRead is the indexer's placement, read from the catalogue at each merge (novox/hq ADR 0549 §2, §3): each
// node's key `placement.<node>` with the modules placed there.
PlacementRead = "mesh-delivery-indexer.placement"
AssignmentsRead = "mesh-controller.assignments"
DeclaredConfigRead = "mesh-controller.declared-config"
PredefinedRead = "mesh-controller.predefined"
@@ -27,38 +27,6 @@ func (c *Config) sources() (Sources, error) {
}); err != nil {
return s, fmt.Errorf("the assignments cannot be read: %w", err)
}
// And every module the catalogue's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
// Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said.
standing := map[string]bool{}
for _, a := range s.Assignments {
standing[a.Node+"."+a.Module] = true
}
if err := c.each(PlacementRead, func(key string, raw []byte) error {
node, ok := strings.CutPrefix(key, "placement.")
if !ok {
return nil
}
var p struct {
Node string `json:"node"`
Modules []struct {
Module string `json:"module"`
} `json:"modules"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
problems = append(problems, fmt.Sprintf("the placement %s is not one", key))
return nil
}
for _, m := range p.Modules {
if m.Module != "" && !standing[node+"."+m.Module] {
standing[node+"."+m.Module] = true
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
}
}
return nil
}); err != nil {
problems = append(problems, fmt.Sprintf("the catalogue's placement cannot be read, so the assignments alone are resolved: %v", err))
}
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
m, err := ParseModuleDeclarations(raw)
if err != nil {
-1
View File
@@ -29,7 +29,6 @@
],
"reads": [
"mesh-controller.assignments",
"mesh-delivery-indexer.placement",
"mesh-controller.declared-config",
"mesh-controller.predefined"
],
+2 -5
View File
@@ -22,11 +22,8 @@ what it refuses.
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor), `numbers` (only these issues, in the order given, naming any not an issue in `missing`), `worker` (only that worker's held issues), `linked` (only the issues with that link, as `kind:target`, such as `decision:0493`) | short rows, newest first, or best first for a `search`, or in the order given for `numbers`: number, status, owner, title, `moved` (when the issue last changed: opened, moved, noted, linked, worked on, or its fix's delivery said a new state), and each flag that holds; `next`, the `before` that reads on, when more match |
| `move` | `number`, `status`, and what that status needs (`by` for `diagnosing` and `fixing`, optionally `take-over`) | changes the status under the development cycle's rules |
| `note` | `number`, `text` | appends a note; a note is never edited |
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link; kind `milestone` (design/id, such as 47/R6) at most once per issue and only to an open milestone, kind `commit` as repository@full sha (ADR 0493 §2 to §4) |
| `milestones` | optionally `design`, `status`, or `one` (design/id) | the milestones, in their design's table order, each with its issues and how many are done; one with its history |
| `milestone-set` | `design`, `id`, and `outcome` to make one; optionally `order`, `announce` | makes a milestone (state `milestones`, event `milestone-set`), changes its outcome or order, or says it again |
| `milestone-move` | `milestone`, `status` (`open` or `closed`), `reason` | opens or closes it (event `milestone-moved`); closing is refused while a linked issue is neither resolved nor wontfix |
| `import` | optionally `dry-run`, `commit`; or `milestones` (a design's number) | hq's issue folders, read through the records module, each written as an issue under its own number; see below. With `milestones`, seeds that design's milestone records from its table once (issue 1781): an issue two rows name, or that another milestone holds, is refused and named; a milestone whose issues are all done is closed; run again, it changes nothing |
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link |
| `import` | optionally `dry-run`, `commit` | hq's issue folders, read through the records module, each written as an issue under its own number; see below |
| `set` | `number`, any of `severity`, `needs`, `suggested-owner` | changes them as a person decides |
| `groom` | optionally `pass`, `confirm` | the triage over the backlog: without arguments a dry run in the background, answering its pass id; with `pass` its progress and findings; with `confirm` and `pass` exactly that pass's findings applied, each checked again; see below |
| `tracking` | optionally `conditions` (condition keys) | for every condition key an issue links (link kind `condition`), the issues that link it, those not closed first: status, severity, needs, worker with since, active and stale, and where the fix is (`fix`); the operator's channel says it in a condition's message (hq ADR 0285) |
+1 -18
View File
@@ -215,18 +215,10 @@ const (
LinkDesign = "design"
LinkCondition = "condition"
LinkDelivery = "delivery"
// LinkMilestone is the one milestone an issue belongs to, design/id (ADR 0493 §1, §2).
LinkMilestone = "milestone"
// LinkCommit is a commit that fixes the issue, repository@full sha (ADR 0493 §4).
LinkCommit = "commit"
)
// LinkKinds is every kind, in the order they are told.
var LinkKinds = []string{LinkDuplicates, LinkRelatesTo, LinkDependsOn, LinkPullRequest, LinkDecision, LinkDesign, LinkCondition, LinkDelivery,
LinkMilestone, LinkCommit}
// madeByPeople are the kinds only a person or a session links, never the triage (ADR 0493 §4).
var madeByPeople = []string{LinkPullRequest, LinkMilestone, LinkCommit}
var LinkKinds = []string{LinkDuplicates, LinkRelatesTo, LinkDependsOn, LinkPullRequest, LinkDecision, LinkDesign, LinkCondition, LinkDelivery}
var (
pullRequest = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}#[1-9][0-9]{0,6}$`)
@@ -288,15 +280,6 @@ func checkTarget(kind, target string) (string, error) {
case LinkDelivery:
ok, shape = deliveryID.MatchString(t), "a delivery's id as mesh-delivery names it: the repository as owner/name, @ and twelve "+
"characters of its commit, such as novox/mesh-controller@d2e9dc61590f"
case LinkMilestone:
var c string
c, ok = checkMilestoneTarget(t)
if ok {
t = c
}
shape = "a milestone as design/id, such as 47/R6"
case LinkCommit:
ok, shape = commitRef.MatchString(t), "a repository, @ and the commit's full 40-character id, such as mesh-catalog@"+strings.Repeat("0", 40)
default:
return "", fmt.Errorf("kind %q is not a kind of link: one of %s", kind, strings.Join(LinkKinds, ", "))
}
+7 -124
View File
@@ -22,8 +22,7 @@ import (
const Seat = "issue-tracker"
// Verbs are the seat's verbs, as the manifest declares and claims them.
var Verbs = []string{"open", "get", "list", "move", "note", "link", "milestones", "milestone-set", "milestone-move", "import",
"groom", "set", "tracking", "reserve", "fix-times"}
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set", "tracking", "reserve", "fix-times"}
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -618,9 +617,8 @@ func Tools(t *Tracker) []stdio.Tool {
Description: "Link an issue to something, or with remove, take a link away. Kinds: duplicates or relates-to " +
"another issue (its number), pull-request (such as mesh-controller#246), decision (a decision record's " +
"number, such as 0275), design (a design document's path), condition (a condition's key) and delivery " +
"(a delivery's id, such as novox/mesh-controller@d2e9dc61590f), milestone (the one milestone it belongs to, design/id such as " +
"47/R6) and commit (a commit that fixes it, repository@full sha). Refused: a target not of its kind's shape, an issue that does not exist, a link it " +
"already has, a second milestone (remove the first to move it), a milestone that does not exist or is closed.",
"(a delivery's id, such as novox/mesh-controller@d2e9dc61590f). Refused: a target not of its kind's shape, an issue that does not exist, a link it " +
"already has.",
Input: schema([]string{"number", "kind", "target"}, map[string]any{
"number": number("the issue's number"),
"kind": map[string]any{"type": "string", "enum": LinkKinds, "description": "what the target is"},
@@ -647,98 +645,6 @@ func Tools(t *Tracker) []stdio.Tool {
}
return t.link(t.callerOf(m), ask)
}},
{Name: Seat + ".milestones",
Description: "Read the milestones of the designs: each milestone (a design's milestone, design/id such as 47/R6) with " +
"its outcome, its place in the design's table, its status, who made and moved it, and the issues linked to it " +
"with how many are done. Narrow by design or status, or read one milestone with its history.",
Input: schema(nil, map[string]any{
"design": number("only this design's milestones, such as 47"),
"status": map[string]any{"type": "string", "enum": MilestoneStatuses, "description": "only milestones with this status"},
"one": text("one milestone, design/id such as 47/R6, with its history"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("milestones", m, "design", "status", "one")
if err != nil {
return nil, err
}
var ask MilestonesAsk
d, err := a.number("design", false)
if err != nil {
return nil, fmt.Errorf("design is a design's number, such as 47")
}
ask.Design = int(d)
if ask.Status, err = a.str("status"); err != nil {
return nil, err
}
if ask.One, err = a.str("one"); err != nil {
return nil, err
}
return t.milestones(ask)
}},
{Name: Seat + ".milestone-set",
Description: "Make a milestone of a design (its id as the design's table writes it, and its outcome), or change its " +
"outcome or its place in the table; with announce, say it again on the bus unchanged. Its issues are linked " +
"with link, kind milestone. Refused: a design that is no number, an id that is no short name, a new milestone without an outcome.",
Input: schema([]string{"design", "id"}, map[string]any{
"design": number("the design's number, such as 47"),
"id": text("the milestone's id as the table writes it, such as R6 or 3 fix"),
"outcome": text("what the milestone delivers, in one line"),
"order": number("its place in the design's table, first 1"),
"announce": map[string]any{"type": "boolean", "description": "say the milestone again on the bus, as it is"},
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("milestone-set", m, "design", "id", "outcome", "order", "announce")
if err != nil {
return nil, err
}
var ask MilestoneSetAsk
d, err := a.number("design", true)
if err != nil {
return nil, fmt.Errorf("design is a design's number, such as 47")
}
o, err := a.number("order", false)
if err != nil {
return nil, fmt.Errorf("order is its place in the design's table, from 1")
}
ask.Design, ask.Order = int(d), int(o)
if ask.ID, err = a.str("id"); err != nil {
return nil, err
}
if ask.Outcome, err = a.str("outcome"); err != nil {
return nil, err
}
if ask.Announce, err = a.flag("announce"); err != nil {
return nil, err
}
return t.milestoneSet(t.callerOf(m), ask)
}},
{Name: Seat + ".milestone-move",
Description: "Open or close a milestone (design/id such as 47/R6), with the reason. Closing is refused while an " +
"issue linked to it is neither resolved nor wontfix, naming each; a closed milestone takes no new issue.",
Input: schema([]string{"milestone", "status", "reason"}, map[string]any{
"milestone": text("the milestone, design/id such as 47/R6"),
"status": map[string]any{"type": "string", "enum": MilestoneStatuses, "description": "its new status"},
"reason": text("why, in one line"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("milestone-move", m, "milestone", "status", "reason")
if err != nil {
return nil, err
}
ref, err := a.str("milestone")
if err != nil {
return nil, err
}
status, err := a.str("status")
if err != nil {
return nil, err
}
reason, err := a.str("reason")
if err != nil {
return nil, err
}
return t.milestoneMove(t.callerOf(m), ref, status, reason)
}},
{Name: Seat + ".import",
Description: "Import hq's issue folders (04-ISSUES/NNN-slug/00-report.md), read through the records module at " +
"the commit its checkout stands at, each as an issue under its own number with the status, owners, fix " +
@@ -746,39 +652,16 @@ func Tools(t *Tracker) []stdio.Tool {
"the tracker holds without a status; it never overwrites an issue, never advances the counter, refuses a " +
"folder numbered past the counter or past the tracker's first number without an issue, and run again " +
"changes nothing and says so. With dry-run it writes nothing and says what it would do; with commit it " +
"refuses a checkout standing elsewhere. The answer is a row per folder and the counts. With milestones and a " +
"design's number it seeds that design's milestones from its milestone table instead: makes each milestone not " +
"made yet, links each issue the table names that is in no milestone, refuses (and names) an issue two rows name " +
"or another milestone holds, closes a milestone whose issues are all done, and run again changes nothing.",
"refuses a checkout standing elsewhere. The answer is a row per folder and the counts.",
Input: schema(nil, map[string]any{
"dry-run": map[string]any{"type": "boolean", "description": "read everything, write nothing, say what would be done"},
"milestones": number("seed this design's milestones from its milestone table, such as 47"),
"commit": text("the commit of hq the record must stand at (a prefix is enough); refused when it stands elsewhere"),
"dry-run": map[string]any{"type": "boolean", "description": "read everything, write nothing, say what would be done"},
"commit": text("the commit of hq the record must stand at (a prefix is enough); refused when it stands elsewhere"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("import", m, "dry-run", "commit", "milestones")
a, err := readArgs("import", m, "dry-run", "commit")
if err != nil {
return nil, err
}
design, err := a.number("milestones", false)
if err != nil {
return nil, fmt.Errorf("milestones is a design's number, such as 47")
}
if design != 0 {
dry, err := a.flag("dry-run")
if err != nil {
return nil, err
}
commit, err := a.str("commit")
if err != nil {
return nil, err
}
records := t.Records
if records == nil {
records = busRecords{}
}
return t.importMilestones(t.callerOf(m), MilestoneImportAsk{Design: int(design), DryRun: dry, Commit: commit}, records)
}
var ask ImportAsk
if ask.DryRun, err = a.flag("dry-run"); err != nil {
return nil, err
@@ -86,18 +86,18 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) {
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
}
if !reflect.DeepEqual(m.Emits, []string{EventOpened, EventMoved, EventNoted, EventLinked, EventImported, EventSet,
EventTriaged, EventReady, EventReadyToVerify, EventReserved, EventMilestoneSet, EventMilestoneMoved}) {
EventTriaged, EventReady, EventReadyToVerify, EventReserved}) {
t.Fatalf("emits %v", m.Emits)
}
// The record is the log `changes` (ADR 0300 §1). Four states, none expiring: issues the fold of the log (§3),
// still keeping its older values until the carry has read them (they go to one value in a follow-up), the
// counters one value each, the backlog's pass of the duplicate check one value (ADR 0278 §6), each reserved
// number one value (issue 424), and the milestones (issue 1781) a short history each, their trail beside their events.
// number one value (issue 424).
if !reflect.DeepEqual(m.Logs, []string{ChangesLog}) {
t.Fatalf("logs %v", m.Logs)
}
d := m.stateDeclarations(t)
if len(d) != 5 || d[4].Name != MilestonesState || d[4].History < 2 || d[4].TTLSeconds != 0 || d[0].Name != IssuesState || d[0].History < 2 || d[0].TTLSeconds != 0 ||
if len(d) != 4 || d[0].Name != IssuesState || d[0].History < 2 || d[0].TTLSeconds != 0 ||
d[1].Name != CountersState || d[1].History > 1 || d[1].TTLSeconds != 0 ||
d[2].Name != GroomingState || d[2].History > 1 || d[2].TTLSeconds != 0 ||
d[3].Name != ReservationsState || d[3].History > 1 || d[3].TTLSeconds != 0 {
@@ -28,7 +28,7 @@ type memory struct {
}
func newMemory() *memory {
return &memory{data: map[string]map[string]Entry{IssuesState: {}, CountersState: {}, GroomingState: {}, ReservationsState: {}, MilestonesState: {}}, fail: map[string]error{},
return &memory{data: map[string]map[string]Entry{IssuesState: {}, CountersState: {}, GroomingState: {}, ReservationsState: {}}, fail: map[string]error{},
hist: map[string]map[string][]Entry{}}
}
@@ -1,441 +0,0 @@
package main
// Milestones (novox/hq ADR 0493 §1 to §4, issue 1781, to-be 53 §13): each row of a design's milestone table is a record
// of the tracker, kept in the state `milestones` under its design's number and its id. An issue's milestone is the
// issue's own link of kind `milestone`, written in the issue's log like any link, so "an issue belongs to at most one
// milestone" (§2) is checked inside that issue's one write. A milestone's issues are read from the issues kept.
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
const (
// MilestonesState keeps one milestone per key, named by MilestoneKey.
MilestonesState = "milestones"
// EventMilestoneSet is said when a milestone is made or changed, or said again on request.
EventMilestoneSet = "milestone-set"
// EventMilestoneMoved is said when a milestone's status changes.
EventMilestoneMoved = "milestone-moved"
MilestoneOpen = "open"
MilestoneClosed = "closed"
// MostOutcome is the longest outcome a milestone keeps.
MostOutcome = 2048
// MostMilestones is the most milestones one list answers.
MostMilestones = 500
)
// MilestoneStatuses is every status a milestone may have. Released is added by the one ask per milestone (issue 1783).
var MilestoneStatuses = []string{MilestoneOpen, MilestoneClosed}
var (
milestoneID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$`)
milestoneRef = regexp.MustCompile(`^([1-9][0-9]{0,2})/(.+)$`)
commitRef = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}@[0-9a-f]{40}$`)
)
// Stamp is who did something and when.
type Stamp struct {
By string `json:"by"`
At time.Time `json:"at"`
}
// MilestoneSource is where a seeded milestone was read: hq's commit and the design's path.
type MilestoneSource struct {
Commit string `json:"commit"`
Path string `json:"path"`
}
// Milestone is one milestone as the tracker keeps it.
type Milestone struct {
Design int `json:"design"`
ID string `json:"id"`
Outcome string `json:"outcome"`
// Order is its place in its design's table, first 1.
Order int `json:"order"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Made Stamp `json:"made"`
Moved *Stamp `json:"moved,omitempty"`
Imported *MilestoneSource `json:"imported,omitempty"`
}
// Ref is the milestone as a link names it: design/id, such as 47/R6.
func (m *Milestone) Ref() string { return MilestoneRef(m.Design, m.ID) }
// MilestoneRef is design/id.
func MilestoneRef(design int, id string) string { return fmt.Sprintf("%d/%s", design, id) }
// MilestoneKey is a milestone's key in `milestones`: design.id, such as 47.R6.
func MilestoneKey(design int, id string) string { return fmt.Sprintf("%d.%s", design, id) }
// canonicalID is a milestone's id as kept: the table's words joined by hyphens ("3 fix" is 3-fix).
func canonicalID(id string) (string, error) {
c := strings.Join(strings.Fields(id), "-")
if !milestoneID.MatchString(c) {
return "", fmt.Errorf("a milestone's id is a short name of letters, digits, - and _, such as R6 or 3-fix, not %q", id)
}
return c, nil
}
// parseMilestoneRef reads design/id, the id said the one way it is kept.
func parseMilestoneRef(s string) (int, string, error) {
m := milestoneRef.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return 0, "", fmt.Errorf("a milestone is named design/id, such as 47/R6, not %q", s)
}
d, _ := strconv.Atoi(m[1])
id, err := canonicalID(m[2])
if err != nil {
return 0, "", err
}
return d, id, nil
}
// checkMilestoneTarget is a milestone link's target, said the one way it is kept.
func checkMilestoneTarget(s string) (string, bool) {
d, id, err := parseMilestoneRef(s)
if err != nil {
return "", false
}
return MilestoneRef(d, id), true
}
// readMilestone is a milestone and the revision it was read at; nil when there is none.
func (t *Tracker) readMilestone(design int, id string) (*Milestone, uint64, error) {
e, err := t.Store.Get(MilestonesState, MilestoneKey(design, id))
if err != nil {
return nil, 0, fmt.Errorf("milestone %s cannot be read: %v", MilestoneRef(design, id), err)
}
if e == nil {
return nil, 0, nil
}
var m Milestone
if err := json.Unmarshal(e.Value, &m); err != nil {
return nil, 0, fmt.Errorf("milestone %s is kept in a shape the tracker cannot read: %v", MilestoneRef(design, id), err)
}
return &m, e.Revision, nil
}
// milestoneDone says an issue is done for its milestone: wontfix, or resolved with every live fix delivered.
func milestoneDone(is *Issue) bool {
switch is.Status {
case StatusWontfix:
return true
case StatusResolved:
return len(liveFixes(is)) == 0 || allDelivered(is)
}
return false
}
// milestoneOf is the milestone an issue is linked to, or "".
func milestoneOf(is *Issue) string {
for _, l := range is.Links {
if l.Kind == LinkMilestone {
return l.Target
}
}
return ""
}
// MilestoneSetAsk is what milestone-set carries.
type MilestoneSetAsk struct {
Design int
ID string
Outcome string
Order int
Announce bool
imported *MilestoneSource
}
// MilestoneSaid is the answer to milestone-set and milestone-move.
type MilestoneSaid struct {
Milestone Milestone `json:"milestone"`
Made bool `json:"made,omitempty"`
Changed []string `json:"changed,omitempty"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
// milestoneSet makes a milestone, or changes its outcome or order, at the revision read; with Announce it says the
// record again.
func (t *Tracker) milestoneSet(by string, a MilestoneSetAsk) (*MilestoneSaid, error) {
if a.Design < 1 || a.Design > 999 {
return nil, fmt.Errorf("design is a design's number, 1 to 999, not %d", a.Design)
}
id, err := canonicalID(a.ID)
if err != nil {
return nil, err
}
outcome := strings.TrimSpace(a.Outcome)
if outcome != "" {
if outcome, err = singleLine("outcome", outcome, MostOutcome); err != nil {
return nil, err
}
}
if a.Order < 0 {
return nil, fmt.Errorf("order is its place in its design's table, from 1, not %d", a.Order)
}
for attempt := 0; attempt < issueAttempts; attempt++ {
m, rev, err := t.readMilestone(a.Design, id)
if err != nil {
return nil, err
}
at := t.now()
out := &MilestoneSaid{}
if m == nil {
if outcome == "" {
return nil, fmt.Errorf("milestone %s does not exist; making it needs its outcome", MilestoneRef(a.Design, id))
}
m = &Milestone{Design: a.Design, ID: id, Outcome: outcome, Order: a.Order, Status: MilestoneOpen,
Made: Stamp{By: by, At: at}, Imported: a.imported}
if _, err := t.Store.Create(MilestonesState, MilestoneKey(a.Design, id), m); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return nil, fmt.Errorf("milestone %s could not be written (%v); nothing was made", m.Ref(), err)
}
out.Made = true
out.Said = fmt.Sprintf("Made milestone %s: %s.", m.Ref(), clipText(outcome, 120))
} else {
if outcome != "" && outcome != m.Outcome {
m.Outcome = outcome
out.Changed = append(out.Changed, "outcome")
}
if a.Order != 0 && a.Order != m.Order {
m.Order = a.Order
out.Changed = append(out.Changed, "order")
}
if len(out.Changed) == 0 && !a.Announce {
out.Milestone = *m
out.Said = fmt.Sprintf("Milestone %s already holds that; nothing changed.", m.Ref())
return out, nil
}
if len(out.Changed) > 0 {
m.Moved = &Stamp{By: by, At: at}
if _, err := t.Store.Update(MilestonesState, MilestoneKey(a.Design, id), m, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return nil, fmt.Errorf("milestone %s could not be written (%v); nothing changed", m.Ref(), err)
}
out.Said = fmt.Sprintf("Changed the %s of milestone %s.", strings.Join(out.Changed, " and "), m.Ref())
} else {
out.Said = fmt.Sprintf("Said milestone %s again, unchanged.", m.Ref())
}
}
out.Milestone = *m
out.Announced, out.Warning = t.emit(EventMilestoneSet, map[string]any{"milestone": m.Ref(), "record": m,
"made": out.Made, "changed": out.Changed, "by": by, "at": at})
return out, nil
}
return nil, fmt.Errorf("milestone %s was changed by others %d times while this was written; nothing changed. Ask again",
MilestoneRef(a.Design, id), issueAttempts)
}
// milestoneMove opens or closes a milestone, with a reason. Closing is refused while an issue linked to it is neither
// resolved nor wontfix.
func (t *Tracker) milestoneMove(by, ref, status, reason string) (*MilestoneSaid, error) {
design, id, err := parseMilestoneRef(ref)
if err != nil {
return nil, err
}
if status, err = oneOf("status", status, MilestoneStatuses); err != nil {
return nil, err
}
if reason, err = singleLine("reason", reason, MostTitle); err != nil {
return nil, fmt.Errorf("a milestone is moved with its reason: %v", err)
}
if status == MilestoneClosed {
all, err := t.allIssues()
if err != nil {
return nil, err
}
var open []string
for _, n := range sortedNumbers(all) {
is := all[n]
if milestoneOf(is) == MilestoneRef(design, id) && is.Status != StatusResolved && is.Status != StatusWontfix {
open = append(open, fmt.Sprintf("%d (%s)", n, is.Status))
}
}
if len(open) > 0 {
return nil, fmt.Errorf("milestone %s keeps issues neither resolved nor wontfix: %s. Resolve them, or move them "+
"to another milestone, before it is closed", MilestoneRef(design, id), strings.Join(open, ", "))
}
}
for attempt := 0; attempt < issueAttempts; attempt++ {
m, rev, err := t.readMilestone(design, id)
if err != nil {
return nil, err
}
if m == nil {
return nil, fmt.Errorf("there is no milestone %s", MilestoneRef(design, id))
}
if m.Status == status {
return &MilestoneSaid{Milestone: *m, Said: fmt.Sprintf("Milestone %s is %s already; nothing changed.", m.Ref(), status)}, nil
}
from, at := m.Status, t.now()
m.Status, m.Reason, m.Moved = status, reason, &Stamp{By: by, At: at}
if _, err := t.Store.Update(MilestonesState, MilestoneKey(design, id), m, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return nil, fmt.Errorf("milestone %s could not be written (%v); nothing changed", m.Ref(), err)
}
out := &MilestoneSaid{Milestone: *m, Changed: []string{"status"},
Said: fmt.Sprintf("Milestone %s moved from %s to %s: %s.", m.Ref(), from, status, reason)}
out.Announced, out.Warning = t.emit(EventMilestoneMoved, map[string]any{"milestone": m.Ref(), "from": from,
"to": status, "reason": reason, "by": by, "at": at})
return out, nil
}
return nil, fmt.Errorf("milestone %s was changed by others %d times while this was written; nothing changed. Ask again",
MilestoneRef(design, id), issueAttempts)
}
// MilestonesAsk narrows the milestones read.
type MilestonesAsk struct {
Design int
Status string
// One is one milestone, design/id: answered with its history.
One string
}
// MilestoneRow is one milestone with its issues.
type MilestoneRow struct {
Milestone
Ref string `json:"ref"`
Issues []uint64 `json:"issues"`
ByStatus map[string]int `json:"by-status,omitempty"`
// Done is how many of its issues are wontfix, or resolved with their fixes delivered.
Done int `json:"done"`
}
// MilestonesRead is the answer to milestones.
type MilestonesRead struct {
Milestones []MilestoneRow `json:"milestones"`
// History is every value one milestone's key keeps, oldest first, when one was asked.
History []json.RawMessage `json:"history,omitempty"`
Said string `json:"said"`
}
// milestones reads the milestones, ordered by design and their place in its table, each with its issues.
func (t *Tracker) milestones(a MilestonesAsk) (*MilestonesRead, error) {
status := strings.TrimSpace(a.Status)
if status != "" {
var err error
if status, err = oneOf("status", status, MilestoneStatuses); err != nil {
return nil, err
}
}
var ms []*Milestone
out := &MilestonesRead{Milestones: []MilestoneRow{}}
if strings.TrimSpace(a.One) != "" {
d, id, err := parseMilestoneRef(a.One)
if err != nil {
return nil, err
}
m, _, err := t.readMilestone(d, id)
if err != nil {
return nil, err
}
if m == nil {
return nil, fmt.Errorf("there is no milestone %s", MilestoneRef(d, id))
}
ms = append(ms, m)
hist, err := t.Store.History(MilestonesState, MilestoneKey(d, id))
if err != nil {
return nil, fmt.Errorf("milestone %s's history cannot be read: %v", m.Ref(), err)
}
for _, h := range hist {
out.History = append(out.History, h.Value)
}
} else {
keys, err := t.Store.Keys(MilestonesState)
if err != nil {
return nil, fmt.Errorf("the milestones cannot be listed: %v", err)
}
for _, k := range keys {
ds, id, ok := strings.Cut(k, ".")
d, err := strconv.Atoi(ds)
if !ok || err != nil || (a.Design != 0 && d != a.Design) {
continue
}
m, _, err := t.readMilestone(d, id)
if err != nil {
return nil, err
}
if m == nil || (status != "" && m.Status != status) {
continue
}
ms = append(ms, m)
}
}
sort.Slice(ms, func(i, j int) bool {
if ms[i].Design != ms[j].Design {
return ms[i].Design < ms[j].Design
}
if ms[i].Order != ms[j].Order {
return ms[i].Order < ms[j].Order
}
return ms[i].ID < ms[j].ID
})
if len(ms) > MostMilestones {
ms = ms[:MostMilestones]
}
all, err := t.allIssues()
if err != nil {
return nil, err
}
rows := map[string]*MilestoneRow{}
for _, m := range ms {
out.Milestones = append(out.Milestones, MilestoneRow{Milestone: *m, Ref: m.Ref(), Issues: []uint64{}, ByStatus: map[string]int{}})
}
for i := range out.Milestones {
rows[out.Milestones[i].Ref] = &out.Milestones[i]
}
for _, n := range sortedNumbers(all) {
is := all[n]
r := rows[milestoneOf(is)]
if r == nil {
continue
}
r.Issues = append(r.Issues, n)
r.ByStatus[is.Status]++
if milestoneDone(is) {
r.Done++
}
}
out.Said = fmt.Sprintf("%d milestones.", len(out.Milestones))
if len(ms) == MostMilestones {
out.Said += fmt.Sprintf(" At most %d are answered: narrow by design.", MostMilestones)
}
return out, nil
}
// sortedNumbers is the issues' numbers, lowest first.
func sortedNumbers(all map[uint64]*Issue) []uint64 {
ns := make([]uint64, 0, len(all))
for n := range all {
ns = append(ns, n)
}
sort.Slice(ns, func(i, j int) bool { return ns[i] < ns[j] })
return ns
}
func clipText(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
@@ -1,84 +0,0 @@
//go:build integration
package main
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
)
// Issue 1781 (ADR 0493 §1, §2, §4), the module whole over a real bus: a milestone made by its verb is kept in the
// state the manifest declares, and two instances linking one issue to two milestones at the same moment leave it in
// exactly one; the other is refused naming the first, and the milestones and the list say the same.
func TestAnIssueRaceIntoTwoMilestonesOverTheBusEndsInOne(t *testing.T) {
b := newBus(t)
var events []event
var evMu sync.Mutex
settings := writeSettings(t, 365)
one, two := b.start(t, settings, &events, &evMu), b.start(t, settings, &events, &evMu)
must := func(raw json.RawMessage, err error) json.RawMessage {
t.Helper()
if err != nil {
t.Fatal(err)
}
return raw
}
must(one.call("open", map[string]any{"title": "a milestone's issue", "report": "seen"}))
must(one.call("milestone-set", map[string]any{"design": 47, "id": "R6", "outcome": "nodes apply their wants", "order": 1}))
must(two.call("milestone-set", map[string]any{"design": 47, "id": "3 fix", "outcome": "a group merges in one pass", "order": 2}))
errs := make([]error, 2)
var wg sync.WaitGroup
for k, c := range []struct {
i *instance
target string
}{{one, "47/R6"}, {two, "47/3-fix"}} {
wg.Add(1)
go func() {
defer wg.Done()
_, errs[k] = c.i.call("link", map[string]any{"number": 365, "kind": "milestone", "target": c.target})
}()
}
wg.Wait()
if (errs[0] == nil) == (errs[1] == nil) {
t.Fatalf("both or neither linked: %v", errs)
}
refused := errs[0]
if refused == nil {
refused = errs[1]
}
if !strings.Contains(refused.Error(), "is in milestone") {
t.Fatalf("refused with %v", refused)
}
var read MilestonesRead
_ = json.Unmarshal(must(two.call("milestones", map[string]any{"design": 47})), &read)
if len(read.Milestones) != 2 || read.Milestones[0].ID != "R6" || read.Milestones[1].ID != "3-fix" ||
len(read.Milestones[0].Issues)+len(read.Milestones[1].Issues) != 1 {
t.Fatalf("%+v", read.Milestones)
}
in := "47/R6"
if len(read.Milestones[1].Issues) == 1 {
in = "47/3-fix"
}
var l Listed
_ = json.Unmarshal(must(one.call("list", map[string]any{"linked": "milestone:" + in})), &l)
if len(l.Rows) != 1 || l.Rows[0].Number != 365 {
t.Fatalf("listed %+v", l)
}
var hist MilestonesRead
_ = json.Unmarshal(must(one.call("milestones", map[string]any{"one": "47/R6"})), &hist)
if len(hist.History) != 1 {
t.Fatalf("history %d values", len(hist.History))
}
evMu.Lock()
defer evMu.Unlock()
var kinds []string
for _, e := range events {
kinds = append(kinds, e.Key)
}
if fmt.Sprint(kinds) != "[opened milestone-set milestone-set linked]" {
t.Fatalf("events %v", kinds)
}
}
@@ -1,187 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// Issue 1781 (ADR 0493 §1 to §4): a milestone is a record made by its verb; an issue is linked to at most one, only to
// an open one, and only by the verb; the triage links no milestone, pull request or commit.
func TestAMilestoneIsMadeByItsVerbAndAnIssueBelongsToAtMostOne(t *testing.T) {
h := newHarness(t)
a, b := h.open(t, "a"), h.open(t, "b")
for _, c := range []struct {
ask MilestoneSetAsk
says string
}{
{MilestoneSetAsk{Design: 0, ID: "R6", Outcome: "x"}, "design's number"},
{MilestoneSetAsk{Design: 47, ID: "R6/x", Outcome: "x"}, "short name"},
{MilestoneSetAsk{Design: 47, ID: "R6"}, "needs its outcome"},
{MilestoneSetAsk{Design: 47, ID: "R6", Outcome: "nodes apply their wants", Order: 2}, ""},
{MilestoneSetAsk{Design: 47, ID: "3 fix", Outcome: "a group merges in one pass", Order: 1}, ""},
{MilestoneSetAsk{Design: 47, ID: "R6", Outcome: "nodes apply their wants"}, "nothing changed"},
} {
out, err := h.milestoneSet("lead@shanks", c.ask)
if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) &&
(out == nil || !strings.Contains(out.Said, c.says)) {
t.Fatalf("%+v: want %q, got %+v %v", c.ask, c.says, out, err)
}
}
links := []struct {
l LinkAsk
says string
}{
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/R6"}, ""},
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/3 fix"}, "in milestone 47/R6 already"},
{LinkAsk{Number: b, Kind: LinkMilestone, Target: "47/R9"}, "there is no milestone 47/R9"},
{LinkAsk{Number: b, Kind: LinkMilestone, Target: "R6"}, "design/id"},
{LinkAsk{Number: b, Kind: LinkCommit, Target: "mesh-catalog@" + strings.Repeat("a", 40)}, ""},
{LinkAsk{Number: b, Kind: LinkCommit, Target: "mesh-catalog@abc123"}, "full 40-character"},
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/R6", Remove: true}, ""},
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/3-fix"}, ""},
{LinkAsk{Number: b, Kind: LinkMilestone, Target: "47/R6"}, ""},
}
for _, c := range links {
_, err := h.link("lead@shanks", c.l)
if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) {
t.Fatalf("%+v: want %q, got %v", c.l, c.says, err)
}
}
for _, kind := range madeByPeople {
target := map[string]string{LinkPullRequest: "mesh-catalog#1", LinkMilestone: "47/R6", LinkCommit: "mesh-catalog@" + strings.Repeat("b", 40)}[kind]
if _, err := h.link(GroomBy, LinkAsk{Number: a, Kind: kind, Target: target}); err == nil || !strings.Contains(err.Error(), "triage links no") {
t.Fatalf("the triage linked %s: %v", kind, err)
}
}
read, err := h.milestones(MilestonesAsk{Design: 47})
if err != nil {
t.Fatal(err)
}
if len(read.Milestones) != 2 || read.Milestones[0].Ref != "47/3-fix" || read.Milestones[1].Ref != "47/R6" ||
fmt.Sprint(read.Milestones[0].Issues) != fmt.Sprint([]uint64{a}) || fmt.Sprint(read.Milestones[1].Issues) != fmt.Sprint([]uint64{b}) {
t.Fatalf("%+v", read.Milestones)
}
one, err := h.milestones(MilestonesAsk{One: "47/R6"})
if err != nil || len(one.Milestones) != 1 || len(one.History) == 0 || one.Milestones[0].Made.By != "lead@shanks" {
t.Fatalf("%+v %v", one, err)
}
// Closing waits for its issues; a closed milestone takes no new one; each change is said.
if _, err := h.milestoneMove("lead@shanks", "47/R6", MilestoneClosed, "delivered"); err == nil || !strings.Contains(err.Error(), fmt.Sprint(b)) {
t.Fatalf("closed over an open issue: %v", err)
}
if _, err := h.Move(MoveAsk{Number: b, Status: StatusWontfix, Reason: "not needed"}); err != nil {
t.Fatal(err)
}
if _, err := h.milestoneMove("lead@shanks", "47/R6", MilestoneClosed, "delivered"); err != nil {
t.Fatal(err)
}
if _, err := h.link("lead@shanks", LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/R6", Remove: true}); err == nil {
t.Fatal("a removed a link it does not have")
}
c := h.open(t, "c")
if _, err := h.link("lead@shanks", LinkAsk{Number: c, Kind: LinkMilestone, Target: "47/R6"}); err == nil || !strings.Contains(err.Error(), "is closed") {
t.Fatalf("linked to a closed milestone: %v", err)
}
if out, err := h.milestoneSet("lead@shanks", MilestoneSetAsk{Design: 47, ID: "R6", Announce: true}); err != nil || !out.Announced {
t.Fatalf("announce: %+v %v", out, err)
}
var kinds []string
for _, e := range h.said() {
if strings.HasPrefix(e.Event, "milestone") {
kinds = append(kinds, e.Event+":"+fmt.Sprint(e.Body["milestone"]))
}
}
if strings.Join(kinds, " ") != "milestone-set:47/R6 milestone-set:47/3-fix milestone-moved:47/R6 milestone-set:47/R6" {
t.Fatalf("said %v", kinds)
}
}
// listRecords is a record holding one design document.
type listRecords struct {
path, content string
}
func (r listRecords) Standing() (string, error) {
return "0123456789abcdef0123456789abcdef01234567", nil
}
func (r listRecords) List(string) ([]string, []string, error) {
return nil, []string{"12-other.md", filepath.Base(r.path)}, nil
}
func (r listRecords) Read(string) (string, string, error) { return r.content, "", nil }
// Issue 1781: the seed makes each row's milestone, links what one row names, refuses what two rows name or another
// milestone holds, closes a milestone whose issues are done, and run again changes nothing.
func TestTheSeedTakesADesignsTableOnce(t *testing.T) {
h := newHarness(t)
a, b, c, d := h.open(t, "a"), h.open(t, "b"), h.open(t, "c"), h.open(t, "d")
if _, err := h.Move(MoveAsk{Number: d, Status: StatusWontfix, Reason: "folded"}); err != nil {
t.Fatal(err)
}
table := fmt.Sprintf("# 47 — Delivery\n\n## Milestones\n\n| # | Outcome | Issues |\n|---|---|---|\n"+
"| 1 | the first | %d, %d (ADR 0493 §2) |\n| 3 fix | a fix | %d, %d, 999 |\n| R4d | none yet | opened when it is prepared |\n"+
"| 6 | done | %d |\n\n## Next\n\n| a | Issues |\n|---|---|\n| x | %d |\n", a, b, c, b, d, a)
rec := listRecords{path: "03-DESIGN/01-to-be/47-delivery.md", content: table}
dry, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 47, DryRun: true}, rec)
if err != nil {
t.Fatal(err)
}
if dry.Made != 4 || dry.Linked != 3 || dry.Refused != 2 || dry.Closed != 1 || len(dry.Rows[1].Missing) != 1 {
t.Fatalf("dry run %+v", dry)
}
if read, _ := h.milestones(MilestonesAsk{}); len(read.Milestones) != 0 {
t.Fatalf("a dry run wrote %+v", read.Milestones)
}
got, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 47}, rec)
if err != nil {
t.Fatal(err)
}
if fmt.Sprintf("%d %d %d %d", got.Made, got.Linked, got.Refused, got.Closed) != "4 3 2 1" {
t.Fatalf("%+v", got)
}
read, _ := h.milestones(MilestonesAsk{Design: 47})
var say []string
for _, m := range read.Milestones {
say = append(say, fmt.Sprintf("%s:%s:%v", m.ID, m.Status, m.Issues))
}
if want := fmt.Sprintf("1:open:[%d] 3-fix:open:[%d] R4d:open:[] 6:closed:[%d]", a, c, d); strings.Join(say, " ") != want {
t.Fatalf("got %s, want %s", strings.Join(say, " "), want)
}
if read.Milestones[3].Imported == nil || read.Milestones[3].Imported.Path != rec.path {
t.Fatalf("%+v", read.Milestones[3])
}
again, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 47}, rec)
if err != nil || again.Made+again.Linked+again.Closed != 0 || !strings.HasPrefix(again.Said, "Changed nothing") {
t.Fatalf("again %+v %v", again, err)
}
if _, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 48}, rec); err == nil {
t.Fatal("seeded a design the record does not hold")
}
}
// Issue 1781 S1: design 47's table, as hq's branch writes it, names every issue in one row and nothing in words the
// seed would guess.
func TestDesign47sTableNamesEachIssueOnce(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "milestones", "47-delivery-from-commit-to-delivered.md"))
if err != nil {
t.Fatal(err)
}
rows, warn := parseTable(string(raw))
if len(rows) != 33 || len(warn) != 0 {
t.Fatalf("%d rows, warnings %v", len(rows), warn)
}
seen := map[uint64]string{}
for _, r := range rows {
if _, err := canonicalID(r.ID); err != nil {
t.Error(err)
}
for _, n := range r.Issues {
if other, ok := seen[n]; ok {
t.Errorf("issue %d is in rows %s and %s", n, other, r.ID)
}
seen[n] = r.ID
}
}
}
@@ -1,313 +0,0 @@
package main
// The seed of a design's milestones (novox/hq issue 1781, to-be 53 §13): its `## Milestones` table, read once through
// the records module, made into milestone records with their issues linked. The table parse moved here from the
// knowledge base's progress (issue 1947), which reads the records since. The Issues cell is prose, so what the parse
// cannot read for certain it says, and an issue two rows name is refused, never placed by a guess (ADR 0493 §2).
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)
// DesignsFolder is where the designs being built are, in hq.
const DesignsFolder = "03-DESIGN/01-to-be"
// TableRow is one row of a design's milestone table.
type TableRow struct {
ID string
Outcome string
Issues []uint64
}
var (
designFile = regexp.MustCompile(`^([0-9]{2,3})-[a-z0-9-]+\.md$`)
// notAnIssue is a number the Issues cell names that is not an issue: a record, a clause, a test, a release, a pull
// request, a document.
notAnIssue = regexp.MustCompile(`(?i)(ADR|research|acceptance tests?|tests?|§|clauses?|release|design|to-be|as-is|playbook|version)\s*[0-9][0-9, and]*|[a-z-]*#[0-9]+|[0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]{1,2}:[0-9]{2}`)
rangeWords = regexp.MustCompile(`\b([0-9]{3,5}) to ([0-9]{3,5})\b`)
butOne = regexp.MustCompile(`\bbut ([0-9]{3,5})\b`)
issueNum = regexp.MustCompile(`\b[0-9]{3,5}\b`)
)
// MostRange is the widest range in words the parse expands; a wider one is said and not expanded.
const MostRange = 60
// parseTable reads a design's milestone table: its rows in order, and what it could not read for certain.
func parseTable(content string) ([]TableRow, []string) {
var rows []TableRow
var warn []string
lines := strings.Split(content, "\n")
inMilestones, header := false, -1
issueCol, outcomeCol := -1, -1
for i, raw := range lines {
l := strings.TrimSpace(raw)
if strings.HasPrefix(l, "## ") {
inMilestones = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(l, "## ")), "Milestones")
header = -1
continue
}
if !inMilestones || !strings.HasPrefix(l, "|") {
continue
}
cells := cellsOf(l)
if header < 0 {
header = i
issueCol, outcomeCol = -1, -1
for c, h := range cells {
switch strings.ToLower(h) {
case "issues", "issue":
issueCol = c
case "outcome":
outcomeCol = c
}
}
continue
}
if i == header+1 && strings.Trim(l, "|-: ") == "" {
continue
}
if issueCol < 0 || issueCol >= len(cells) || len(cells) == 0 {
continue
}
r := TableRow{ID: cells[0]}
if outcomeCol >= 0 && outcomeCol < len(cells) {
r.Outcome = cells[outcomeCol]
}
var w []string
r.Issues, w = issuesOf(cells[issueCol])
for _, x := range w {
warn = append(warn, fmt.Sprintf("milestone %s: %s", r.ID, x))
}
rows = append(rows, r)
}
return rows, warn
}
// issuesOf is the issue numbers a table's Issues cell names, in order, and what it could not read for certain.
func issuesOf(cell string) ([]uint64, []string) {
var warn []string
s := notAnIssue.ReplaceAllString(cell, " ")
var nums []uint64
for _, m := range rangeWords.FindAllStringSubmatch(s, -1) {
lo, _ := strconv.Atoi(m[1])
hi, _ := strconv.Atoi(m[2])
if hi < lo || hi-lo > MostRange {
warn = append(warn, fmt.Sprintf("%q is a range in words the reader does not expand", m[0]))
continue
}
warn = append(warn, fmt.Sprintf("%q is a range in words, read as the %d issues %d to %d", m[0], hi-lo+1, lo, hi))
for n := lo; n <= hi; n++ {
nums = append(nums, uint64(n))
}
}
but := map[uint64]bool{}
for _, m := range butOne.FindAllStringSubmatch(s, -1) {
n, _ := strconv.Atoi(m[1])
but[uint64(n)] = true
}
s = rangeWords.ReplaceAllString(s, " ")
s = butOne.ReplaceAllString(s, " ")
for _, m := range issueNum.FindAllString(s, -1) {
n, _ := strconv.Atoi(m)
nums = append(nums, uint64(n))
}
out, have := []uint64{}, map[uint64]bool{}
for _, n := range nums {
if !have[n] && !but[n] {
have[n] = true
out = append(out, n)
}
}
return out, warn
}
func cellsOf(row string) []string {
row = strings.TrimSpace(row)
row = strings.TrimPrefix(row, "|")
row = strings.TrimSuffix(row, "|")
parts := strings.Split(row, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
// MilestoneImportAsk is what a seed of one design's milestones carries.
type MilestoneImportAsk struct {
Design int
DryRun bool
Commit string
}
// MilestoneImportRow is what the seed did, or would do, for one row of the table.
type MilestoneImportRow struct {
Milestone string `json:"milestone"`
Made bool `json:"made,omitempty"`
Linked []uint64 `json:"linked,omitempty"`
// Kept is the issues linked to it already.
Kept []uint64 `json:"kept,omitempty"`
// Refused is each issue not linked, and why.
Refused []string `json:"refused,omitempty"`
// Missing is the numbers the table names that are no issue.
Missing []uint64 `json:"missing,omitempty"`
Closed bool `json:"closed,omitempty"`
}
// MilestonesImported is the answer to a seed.
type MilestonesImported struct {
DryRun bool `json:"dry-run"`
Design int `json:"design"`
Path string `json:"path"`
Commit string `json:"commit"`
Rows []MilestoneImportRow `json:"rows"`
Made int `json:"made"`
Linked int `json:"linked"`
Closed int `json:"closed"`
Refused int `json:"refused"`
Warnings []string `json:"warnings,omitempty"`
Said string `json:"said"`
}
// importMilestones seeds one design's milestones from its table.
func (t *Tracker) importMilestones(by string, a MilestoneImportAsk, records Records) (*MilestonesImported, error) {
if a.Design < 1 || a.Design > 999 {
return nil, fmt.Errorf("milestones is a design's number, 1 to 999, not %d", a.Design)
}
commit, err := records.Standing()
if err != nil {
return nil, fmt.Errorf("the record cannot be read (%v); nothing was imported", err)
}
want := strings.TrimSpace(a.Commit)
if want != "" && !commitPrefix.MatchString(want) {
return nil, errors.New("commit is a commit's id or a prefix of it, 7 to 40 hex digits; nothing was imported")
}
if want != "" && !strings.HasPrefix(commit, want) {
return nil, fmt.Errorf("the record stands at hq commit %s, not %s: bring it up to date (records_sync), or "+
"name the commit it stands at. Nothing was imported", short(commit), want)
}
_, docs, err := records.List(DesignsFolder)
if err != nil {
return nil, fmt.Errorf("the record's %s cannot be listed (%v); nothing was imported", DesignsFolder, err)
}
path := ""
for _, d := range docs {
if m := designFile.FindStringSubmatch(d); m != nil {
if n, _ := strconv.Atoi(m[1]); n == a.Design {
path = DesignsFolder + "/" + d
}
}
}
if path == "" {
return nil, fmt.Errorf("no design %d is in %s at hq %s; nothing was imported", a.Design, DesignsFolder, short(commit))
}
content, _, err := records.Read(path)
if err != nil {
return nil, fmt.Errorf("%s cannot be read (%v); nothing was imported", path, err)
}
table, warn := parseTable(content)
if len(table) == 0 {
return nil, fmt.Errorf("%s has no milestone table with an Issues column; nothing was imported", path)
}
out := &MilestonesImported{DryRun: a.DryRun, Design: a.Design, Path: path, Commit: commit, Rows: []MilestoneImportRow{}, Warnings: warn}
// An issue two rows name is placed by nobody's guess (ADR 0493 §2).
rowsOf := map[uint64][]string{}
for _, r := range table {
for _, n := range r.Issues {
rowsOf[n] = append(rowsOf[n], r.ID)
}
}
all, err := t.allIssues()
if err != nil {
return nil, err
}
for i, r := range table {
id, err := canonicalID(r.ID)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("row %d: %v; skipped", i+1, err))
continue
}
ref := MilestoneRef(a.Design, id)
row := MilestoneImportRow{Milestone: ref}
m, _, err := t.readMilestone(a.Design, id)
if err != nil {
return nil, err
}
if m == nil {
row.Made = true
out.Made++
if !a.DryRun {
outcome := r.Outcome
if outcome == "" {
outcome = "(the table gives no outcome)"
}
if _, err := t.milestoneSet(by, MilestoneSetAsk{Design: a.Design, ID: id, Outcome: clipText(outcome, MostOutcome),
Order: i + 1, imported: &MilestoneSource{Commit: commit, Path: path}}); err != nil {
return nil, fmt.Errorf("%v; the rows before %s are kept, and an import run again goes on from there", err, ref)
}
}
m = &Milestone{Design: a.Design, ID: id, Status: MilestoneOpen}
}
var members []*Issue
for _, n := range r.Issues {
is := all[n]
switch {
case is == nil:
row.Missing = append(row.Missing, n)
case len(rowsOf[n]) > 1:
row.Refused = append(row.Refused, fmt.Sprintf("%d: rows %s all name it; an issue belongs to at most one", n, strings.Join(rowsOf[n], ", ")))
case milestoneOf(is) == ref:
row.Kept = append(row.Kept, n)
members = append(members, is)
case milestoneOf(is) != "":
row.Refused = append(row.Refused, fmt.Sprintf("%d: in milestone %s already", n, milestoneOf(is)))
case m.Status != MilestoneOpen:
row.Refused = append(row.Refused, fmt.Sprintf("%d: the milestone is %s", n, m.Status))
default:
if !a.DryRun {
if _, err := t.link(by, LinkAsk{Number: n, Kind: LinkMilestone, Target: ref}); err != nil {
row.Refused = append(row.Refused, fmt.Sprintf("%d: %v", n, err))
continue
}
}
row.Linked = append(row.Linked, n)
members = append(members, is)
}
}
out.Linked += len(row.Linked)
out.Refused += len(row.Refused)
if m.Status == MilestoneOpen && len(members) > 0 && len(row.Refused) == 0 {
done := true
for _, is := range members {
done = done && milestoneDone(is)
}
if done {
row.Closed = true
out.Closed++
if !a.DryRun {
if _, err := t.milestoneMove(by, ref, MilestoneClosed,
fmt.Sprintf("seeded closed: every issue done (hq %s)", short(commit))); err != nil {
return nil, err
}
}
}
}
out.Rows = append(out.Rows, row)
}
verb := map[bool]string{true: "Would make", false: "Made"}[a.DryRun]
if out.Made+out.Linked+out.Closed == 0 {
out.Said = fmt.Sprintf("Changed nothing: design %d's %d milestones hold what its table names", a.Design, len(out.Rows))
} else {
out.Said = fmt.Sprintf("%s %d milestones of design %d, linked %d issues and closed %d, from %s at hq %s",
verb, out.Made, a.Design, out.Linked, out.Closed, path, short(commit))
}
if out.Refused > 0 {
out.Said += fmt.Sprintf("; refused %d issues, each named in its row", out.Refused)
}
out.Said += "."
return out, nil
}
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"math/big"
"slices"
"sort"
"strings"
"sync"
@@ -651,22 +650,6 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) {
}
}
}
if by == GroomBy && slices.Contains(madeByPeople, kind) {
return nil, fmt.Errorf("the triage links no %s: that link is made by the one who did the work (ADR 0493 §4)", kind)
}
if kind == LinkMilestone && !a.Remove {
d, id, _ := parseMilestoneRef(target)
m, _, err := t.readMilestone(d, id)
if err != nil {
return nil, err
}
if m == nil {
return nil, fmt.Errorf("there is no milestone %s: it is made from its design's table (milestone-set, or import with milestones)", target)
}
if m.Status != MilestoneOpen {
return nil, fmt.Errorf("milestone %s is %s: a fix found on the way joins its design's open milestone, or a milestone of its own (ADR 0493 §3)", target, m.Status)
}
}
at := t.now()
is, err := t.change(a.Number, Act{Op: EventLinked, By: by}, func(is *Issue) error {
for i, l := range is.Links {
@@ -682,12 +665,6 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) {
if a.Remove {
return fmt.Errorf("issue %d has no %s link to %s to remove", is.Number, kind, target)
}
if kind == LinkMilestone {
if in := milestoneOf(is); in != "" {
return fmt.Errorf("issue %d is in milestone %s already, and an issue belongs to at most one (ADR 0493 §2): "+
"remove that link first to move it", is.Number, in)
}
}
if len(is.Links) >= MostLinks {
return fmt.Errorf("issue %d has %d links, the most one issue keeps", is.Number, MostLinks)
}
+2 -14
View File
@@ -13,9 +13,6 @@
"move",
"note",
"link",
"milestones",
"milestone-set",
"milestone-move",
"import",
"groom",
"set",
@@ -36,9 +33,6 @@
"move",
"note",
"link",
"milestones",
"milestone-set",
"milestone-move",
"import",
"groom",
"set",
@@ -58,9 +52,7 @@
"triaged",
"ready",
"ready-to-verify",
"reserved",
"milestone-set",
"milestone-moved"
"reserved"
],
"consumes": [
"mesh-issues.opened",
@@ -89,11 +81,7 @@
},
"counters",
"grooming",
"reservations",
{
"name": "milestones",
"history": 5
}
"reservations"
],
"config": {
"first-number": {
+1 -1
View File
@@ -27,7 +27,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
| `withdraw` | `number`, `kind`, `why` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn |
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound` | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`) |
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound` | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. Milestones are read from the design's `## Milestones` table through the records module, cached by hq's commit, until the issue tracker holds milestone records (issue 1781); what the table does not say for certain is said in `warnings` |
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
@@ -1,9 +1,15 @@
package main
// A design as the progress verb reads it (novox/hq issue 1947): its title, its state as its frontmatter says it and
// the decisions it names, read through the records module until ADR 0423 makes designs records of the knowledge base
// (issue 428). Its milestones are the issue tracker's records (ADR 0493 §1, issue 1781), read in milestones.go: the
// table parse that guessed them from prose moved to the tracker's seed and is gone from here.
// A design as the progress verb reads it (novox/hq issue 1947): its title, its state as its frontmatter says it, the
// decisions it names, and its milestones from its `## Milestones` table. Designs are still documents in hq, read
// through the records module, until ADR 0423 makes them records of the knowledge base (issue 428); then this reads
// those records instead.
//
// Milestones are read from the design's table until the issue tracker holds milestone records (ADR 0493 §1, §4,
// built under issue 1781); when 1781 is delivered, the table reader is replaced by a read of those records and this
// parse is deleted (the lead of design 47, 2026-10-26). The Issues cell is prose, so what the parse cannot read
// for certain it says as a warning, never hides: an issue in two milestones (against ADR 0493 §2), a range written
// in words, a row naming no issue.
import (
"fmt"
@@ -24,24 +30,31 @@ type Design struct {
Status string
Decisions []string
Milestones []Milestone
Warnings []string
}
// Milestone is one milestone of a design, as the issue tracker keeps it.
// Milestone is one row of a design's milestone table.
type Milestone struct {
ID string
Outcome string
Issues []int
// Status is open or closed, with its Reason; a closed milestone holding no issue is done.
Status string
Reason string
}
var (
designFile = regexp.MustCompile(`^([0-9]{2,3})-[a-z0-9-]+\.md$`)
titleLine = regexp.MustCompile(`^#\s+[0-9]+\s+[—-]\s+(.+)$`)
decisionItem = regexp.MustCompile(`ADR ([0-9]{4})`)
// notAnIssue is a number the Issues cell names that is not an issue: a record, a clause, a test, a release, a pull
// request, a document.
notAnIssue = regexp.MustCompile(`(?i)(ADR|research|acceptance tests?|tests?|§|clauses?|release|design|to-be|as-is|playbook|version)\s*[0-9][0-9, and]*|[a-z-]*#[0-9]+|[0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]{1,2}:[0-9]{2}`)
rangeWords = regexp.MustCompile(`\b([0-9]{3,5}) to ([0-9]{3,5})\b`)
butOne = regexp.MustCompile(`\bbut ([0-9]{3,5})\b`)
issueNum = regexp.MustCompile(`\b[0-9]{3,5}\b`)
)
// MostRange is the widest range in words the parse expands; a wider one is said and not expanded.
const MostRange = 60
// parseDesign reads a design document.
func parseDesign(path, content string) *Design {
d := &Design{Path: path}
@@ -65,14 +78,117 @@ func parseDesign(path, content string) *Design {
}
}
}
for i := body; i < len(lines) && d.Title == ""; i++ {
if m := titleLine.FindStringSubmatch(strings.TrimSpace(lines[i])); m != nil {
d.Title = strings.TrimSpace(m[1])
inMilestones, header := false, -1
issueCol, outcomeCol := -1, -1
seen := map[int]string{}
for i := body; i < len(lines); i++ {
l := strings.TrimSpace(lines[i])
if d.Title == "" {
if m := titleLine.FindStringSubmatch(l); m != nil {
d.Title = strings.TrimSpace(m[1])
}
}
if strings.HasPrefix(l, "## ") {
inMilestones = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(l, "## ")), "Milestones")
header = -1
continue
}
if !inMilestones || !strings.HasPrefix(l, "|") {
continue
}
cells := cellsOf(l)
if header < 0 {
header = i
issueCol, outcomeCol = -1, -1
for c, h := range cells {
switch strings.ToLower(h) {
case "issues", "issue":
issueCol = c
case "outcome":
outcomeCol = c
}
}
continue
}
if i == header+1 && strings.Trim(l, "|-: ") == "" {
continue
}
if issueCol < 0 || issueCol >= len(cells) || len(cells) == 0 {
continue
}
m := Milestone{ID: cells[0]}
if outcomeCol >= 0 && outcomeCol < len(cells) {
m.Outcome = cells[outcomeCol]
}
var warn []string
m.Issues, warn = issuesOf(cells[issueCol])
for _, w := range warn {
d.Warnings = append(d.Warnings, fmt.Sprintf("milestone %s: %s", m.ID, w))
}
if len(m.Issues) == 0 {
d.Warnings = append(d.Warnings, fmt.Sprintf("milestone %s names no issue", m.ID))
}
for _, n := range m.Issues {
if other, ok := seen[n]; ok && other != m.ID {
d.Warnings = append(d.Warnings, fmt.Sprintf("issue %d is in milestones %s and %s; an issue belongs to at most one (ADR 0493 §2)", n, other, m.ID))
continue
}
seen[n] = m.ID
}
d.Milestones = append(d.Milestones, m)
}
return d
}
// issuesOf is the issue numbers a table's Issues cell names, in order, and what it could not read for certain.
func issuesOf(cell string) ([]int, []string) {
var warn []string
s := notAnIssue.ReplaceAllString(cell, " ")
var nums []int
for _, m := range rangeWords.FindAllStringSubmatch(s, -1) {
lo, _ := strconv.Atoi(m[1])
hi, _ := strconv.Atoi(m[2])
if hi < lo || hi-lo > MostRange {
warn = append(warn, fmt.Sprintf("%q is a range in words the reader does not expand", m[0]))
continue
}
warn = append(warn, fmt.Sprintf("%q is a range in words, read as the %d issues %d to %d", m[0], hi-lo+1, lo, hi))
for n := lo; n <= hi; n++ {
nums = append(nums, n)
}
}
but := map[int]bool{}
for _, m := range butOne.FindAllStringSubmatch(s, -1) {
n, _ := strconv.Atoi(m[1])
but[n] = true
}
s = rangeWords.ReplaceAllString(s, " ")
s = butOne.ReplaceAllString(s, " ")
for _, m := range issueNum.FindAllString(s, -1) {
n, _ := strconv.Atoi(m)
nums = append(nums, n)
}
out, have := []int{}, map[int]bool{}
for _, n := range nums {
if !have[n] && !but[n] {
have[n] = true
out = append(out, n)
}
}
return out, warn
}
func cellsOf(row string) []string {
row = strings.TrimSpace(row)
row = strings.TrimPrefix(row, "|")
row = strings.TrimSuffix(row, "|")
parts := strings.Split(row, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
func lastPart(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[i+1:]
@@ -80,12 +196,11 @@ func lastPart(p string) string {
return p
}
// milestone finds a milestone by its id, without regard to case, or to spaces against hyphens.
// milestone finds a milestone by its id, as the table writes it, without regard to case or spaces.
func (d *Design) milestone(id string) *Milestone {
// The tracker keeps an id's words joined by hyphens (3 fix is 3-fix); either is taken.
want := strings.ToLower(strings.Join(strings.Fields(id), "-"))
want := strings.ToLower(strings.Join(strings.Fields(id), " "))
for i := range d.Milestones {
if strings.ToLower(strings.Join(strings.Fields(d.Milestones[i].ID), "-")) == want {
if strings.ToLower(strings.Join(strings.Fields(d.Milestones[i].ID), " ")) == want {
return &d.Milestones[i]
}
}
@@ -1129,8 +1129,9 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
"and never written by anyone, with when it last moved. detail minimal (the default) gives one line per " +
"milestone, cheap enough for a report every five minutes; detailed gives the report's tree: each " +
"milestone not done with its issues, their pull requests, the groups holding them and what each waits " +
"for, for about every thirty. A design's milestones and their issues are the issue tracker's milestone " +
"records (its milestones); a design with none is refused, naming the seed. Within a bound it states, 4 KiB unless asked for more, saying what it cut.",
"for, for about every thirty. Milestones are read from the design's milestone table until the issue " +
"tracker holds milestone records (issue 1781); what the table does not say for certain is said in " +
"warnings. Within a bound it states, 4 KiB unless asked for more, saying what it cut.",
Input: schema([]string{"of"}, map[string]any{
"of": text("what: mesh, design 47, milestone 47/R6, issue 1945, decision 493 (or ADR 0493) or research 72"),
"detail": map[string]any{"type": "string", "enum": []string{DetailMinimal, DetailDetailed},
@@ -80,11 +80,11 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
}
if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status",
"records.records_list", "records.records_read", "seat:operator-channel.notify", "seat:issue-tracker.list", "seat:issue-tracker.get",
"seat:issue-tracker.milestones", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
"seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
t.Fatalf("invokes %v", m.Invokes)
}
src := ""
for _, f := range []string{"main.go", "hq.go", "progress.go", "milestones.go"} {
for _, f := range []string{"main.go", "hq.go", "progress.go"} {
src += readFile(t, f)
}
for _, inv := range m.Invokes {
@@ -1,53 +0,0 @@
package main
// A design's milestones are records of the issue tracker (novox/hq ADR 0493 §1, issue 1781): progress asks
// `issue-tracker.milestones` once per answer, on the bus, for each milestone with its issues, instead of reading the
// design's table through the records module.
import (
"encoding/json"
"fmt"
)
// MilestonesVerb is the tracker's verb that answers the milestones.
const MilestonesVerb = "seat:issue-tracker.milestones"
// trackerMilestone is a row of `issue-tracker.milestones`.
type trackerMilestone struct {
Design int `json:"design"`
ID string `json:"id"`
Outcome string `json:"outcome"`
Status string `json:"status"`
Reason string `json:"reason"`
Issues []int `json:"issues"`
}
// withMilestones is each design with its milestones as the tracker holds them, in their table's order; design 0 asks
// every design's. The designs are copies: the cache keeps what hq says, never what the tracker said.
func (k *KB) withMilestones(ds []*Design, design int) ([]*Design, error) {
body := map[string]any{}
if design != 0 {
body["design"] = design
}
raw, err := k.Seats(MilestonesVerb, body)
if err != nil {
return nil, fmt.Errorf("the issue tracker does not answer its milestones: %v", err)
}
var l struct {
Milestones []trackerMilestone `json:"milestones"`
}
if err := json.Unmarshal(raw, &l); err != nil {
return nil, fmt.Errorf("issue-tracker.milestones answered something else: %v", err)
}
by := map[int][]Milestone{}
for _, m := range l.Milestones {
by[m.Design] = append(by[m.Design], Milestone{ID: m.ID, Outcome: m.Outcome, Issues: m.Issues, Status: m.Status, Reason: m.Reason})
}
out := make([]*Design, 0, len(ds))
for _, d := range ds {
c := *d
c.Milestones = by[d.Number]
out = append(out, &c)
}
return out, nil
}
@@ -5,9 +5,8 @@ package main
// level, in two forms: minimal, one line per child, cheap enough for every five minutes; detailed, the tree of a
// lead's report with the pull requests, the held groups and what waits on whom, for about every thirty.
//
// It keeps no state of its own. It reads, each a request and reply over the bus: the designs' titles and states through
// the records module (design.go, cached by hq's commit), their milestones from the issue tracker (milestones.go,
// issue 1781), the issues from the issue tracker (`issue-tracker.list`, whose rows
// It keeps no state of its own. It reads, each a request and reply over the bus: the designs through the records
// module (design.go, cached by hq's commit), the issues from the issue tracker (`issue-tracker.list`, whose rows
// say each issue's delivery and when it last moved), and the deliveries and groups from mesh-delivery.
import (
@@ -168,6 +167,8 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
if err != nil {
return nil, err
}
// A cut takes the warnings from the last: the calls that failed are said before the answers that did not parse.
callsFirst(p.Warnings)
fitProgress(p, boundOf(a.Bound))
return p, nil
}
@@ -288,9 +289,6 @@ func milestoneItems(d *Design, rows map[int]trackerRow, missing map[int]bool) ([
}
}
rollUp(&mi, kids)
if m.Status == "closed" && len(kids) == 0 {
mi.State, mi.Why = StateDone, "closed: "+m.Reason
}
mi.Children = kids
items = append(items, mi)
}
@@ -307,15 +305,9 @@ func (k *KB) progressDesign(p *Progress, number int, milestone string) error {
if err != nil {
return err
}
if ds, err = k.withMilestones(ds, number); err != nil {
return err
}
d := ds[0]
if len(d.Milestones) == 0 {
return fmt.Errorf("design %d has no milestone records in the issue tracker: seed them from its table with "+
"issue-tracker.import milestones %d", number, number)
}
p.Source = fmt.Sprintf("the issue tracker's milestone records of design %d, and %s at hq %s", number, d.Path, short(commit))
p.Source = fmt.Sprintf("the milestone table of %s at hq %s (until the tracker's milestone records, issue 1781)", d.Path, short(commit))
p.Warnings = append(p.Warnings, d.Warnings...)
ms := d
if milestone != "" {
m := d.milestone(milestone)
@@ -364,10 +356,7 @@ func (k *KB) progressMesh(p *Progress) error {
if err != nil {
return err
}
if ds, err = k.withMilestones(ds, 0); err != nil {
return err
}
p.Source = fmt.Sprintf("the issue tracker's milestone records, and the designs in %s at hq %s", DesignsFolder, short(commit))
p.Source = fmt.Sprintf("the designs in %s at hq %s", DesignsFolder, short(commit))
var with []*Design
var without []string
for _, d := range ds {
@@ -835,8 +824,21 @@ func movedSay(t *time.Time) string {
return " · moved " + when(t)
}
// callsFirst puts the warnings of calls that failed before those of answers that did not parse, in place.
func callsFirst(w []string) {
sort.SliceStable(w, func(i, j int) bool {
return !strings.Contains(w[i], " answered something else") && strings.Contains(w[j], " answered something else")
})
}
// fitProgress keeps an answer within its bound: the items go first, then the tree's last lines, saying what was cut.
// A minimal answer never carries the items: its tree says the same, one line per milestone, so leaving them out is how
// the minimal form is made (the items of design 47 alone passed 8192 bytes) and no cut. A detailed answer's items, and
// everything else that goes, are said in cut.
func fitProgress(p *Progress, bound int) {
if p.Detail == DetailMinimal && p.Tree != "" {
p.Items = nil
}
if size(p) <= bound {
return
}
@@ -12,12 +12,12 @@ import (
)
// 1947/AC1: the knowledge base running whole, as the runtime launches it, answers progress over the bus: it reads
// design 47 through the records module, its milestones from the issue tracker (issue 1781), and asks the issue tracker and mesh-delivery on their seats' subjects,
// design 47 through the records module and asks the issue tracker and mesh-delivery on their seats' subjects,
// played here from what the live seats answered (testdata/progress).
func TestProgressOverTheBus(t *testing.T) {
w := newBusWorld(t)
seats := newSeatsWorld(t)
for _, key := range []string{"seat:issue-tracker.list", "seat:issue-tracker.get", MilestonesVerb, "seat:mesh-delivery.deliveries",
for _, key := range []string{"seat:issue-tracker.list", "seat:issue-tracker.get", "seat:mesh-delivery.deliveries",
"seat:mesh-delivery.groups", "seat:mesh-delivery.show"} {
key := key
if _, err := w.nc.Subscribe("ask."+key, func(m *nats.Msg) {
@@ -45,7 +45,7 @@ func TestProgressOverTheBus(t *testing.T) {
}
lines := strings.Split(p.Tree, "\n")
if len(raw) > DefaultBound || p.Detail != DetailMinimal || !strings.HasPrefix(lines[0], "✗ design 47 · Delivery: from a commit to delivered — ") ||
!strings.Contains(p.Tree, "✗ R6 4/7 1945 fixing since") || !strings.Contains(p.Source, "the issue tracker's milestone records of design 47") {
!strings.Contains(p.Tree, "✗ R6 4/7 1945 fixing since") || !strings.Contains(strings.Join(p.Warnings, "\n"), "issue 1209 is in milestones R3b and R3c") {
t.Fatalf("%d bytes:\n%s\n%v", len(raw), p.Tree, p.Warnings)
}
raw, err = b.call(Seat+".progress", map[string]any{"of": "milestone 47/R6", "detail": "detailed"})
@@ -63,7 +63,7 @@ func TestProgressOverTheBus(t *testing.T) {
}
seats.mu.Lock()
defer seats.mu.Unlock()
if seats.asked["seat:issue-tracker.list"] != 2 || seats.asked["seat:mesh-delivery.groups"] != 1 || seats.asked[MilestonesVerb] != 2 {
if seats.asked["seat:issue-tracker.list"] != 2 || seats.asked["seat:mesh-delivery.groups"] != 1 {
t.Fatalf("asked %v", seats.asked)
}
}
@@ -45,10 +45,8 @@ type seatsWorld struct {
mu sync.Mutex
rows map[int]json.RawMessage
linked map[string][]int
// milestones is the tracker's milestone records (issue 1781).
milestones json.RawMessage
asked map[string]int
files map[string]json.RawMessage
asked map[string]int
files map[string]json.RawMessage
}
func newSeatsWorld(t *testing.T) *seatsWorld {
@@ -63,7 +61,6 @@ func newSeatsWorld(t *testing.T) *seatsWorld {
_ = json.Unmarshal(r, &n)
s.rows[n.Number] = r
}
s.milestones = readFixture(t, "tracker-milestones-47.json")
for _, f := range []string{"deliveries.json", "groups.json", "show-car-hunter-2.json"} {
s.files[f] = readFixture(t, f)
}
@@ -89,15 +86,9 @@ func (s *seatsWorld) ask(key string, body any) (json.RawMessage, error) {
Linked string `json:"linked"`
Number int `json:"number"`
ID string `json:"id"`
Design int `json:"design"`
}
_ = json.Unmarshal(raw, &a)
switch key {
case MilestonesVerb:
if a.Design != 0 && a.Design != 47 {
return json.RawMessage(`{"milestones": []}`), nil
}
return s.milestones, nil
case "seat:issue-tracker.list":
numbers := a.Numbers
if a.Linked != "" {
@@ -140,37 +131,31 @@ func (c *countingRecords) Read(p string) (string, string, error) {
return content, c.commit, err
}
// 1947/AC2, as issue 1781 changed it: a design's title, state and decisions are hq's; its milestones are the issue
// tracker's records, ordered as its table, an id found with spaces or hyphens, and a closed one holding no issue done.
func TestADesignsMilestonesAreTheTrackersRecords(t *testing.T) {
// 1947/AC2: the milestone table of design 47, as hq holds it, read into milestones with their issues, and what the
// prose does not say for certain said as warnings.
func TestTheDesignReaderReadsTheMilestoneTable(t *testing.T) {
content, _, err := progressRecords().Read(DesignsFolder + "/47-delivery-from-commit-to-delivered.md")
if err != nil {
t.Fatal(err)
}
d := parseDesign(DesignsFolder+"/47-delivery-from-commit-to-delivered.md", content)
if d.Number != 47 || d.Title != "Delivery: from a commit to delivered" || d.Status != "in-progress" || !contains(d.Decisions, "ADR 0276") ||
len(d.Milestones) != 0 {
t.Fatalf("design %d %q %q %v, %d milestones read from hq", d.Number, d.Title, d.Status, d.Decisions, len(d.Milestones))
if d.Number != 47 || d.Title != "Delivery: from a commit to delivered" || d.Status != "in-progress" || !contains(d.Decisions, "ADR 0276") {
t.Fatalf("design %d %q %q %v", d.Number, d.Title, d.Status, d.Decisions)
}
seats := newSeatsWorld(t)
k := &KB{Seats: seats.ask}
ds, err := k.withMilestones([]*Design{d}, 47)
if err != nil {
t.Fatal(err)
}
if len(ds[0].Milestones) != 29 || len(d.Milestones) != 0 || seats.asked[MilestonesVerb] != 1 {
t.Fatalf("%d milestones; the cached design holds %d", len(ds[0].Milestones), len(d.Milestones))
if len(d.Milestones) != 29 {
t.Fatalf("%d milestones", len(d.Milestones))
}
for _, c := range []struct {
id string
want []int
}{
{"1", []int{973, 525}},
{"2b", []int{1025, 1019}},
{"R6", []int{1942, 1945, 1943, 1944, 1946, 1906, 1130}},
{"r4d", []int{}},
{"3 fix", []int{1082, 1049, 1047, 1083, 1079, 1084}},
} {
m := ds[0].milestone(c.id)
m := d.milestone(c.id)
if m == nil {
t.Fatalf("no milestone %s", c.id)
}
@@ -178,10 +163,25 @@ func TestADesignsMilestonesAreTheTrackersRecords(t *testing.T) {
t.Errorf("milestone %s: %v, want %v", c.id, m.Issues, c.want)
}
}
closed := &Design{Milestones: []Milestone{{ID: "6", Status: "closed", Reason: "folded into release 4"}, {ID: "R4d", Status: "open"}}}
items, _ := milestoneItems(closed, nil, nil)
if items[0].State != StateDone || items[1].State != StateWaiting {
t.Fatalf("%+v", items)
if r4a := d.milestone("R4a"); len(r4a.Issues) != 14 || !containsInt(r4a.Issues, 1360) {
t.Errorf("R4a's range 1353 to 1364 read as %v", r4a.Issues)
}
warned := strings.Join(d.Warnings, "\n")
for _, w := range []string{"issue 1209 is in milestones R3b and R3c", "issue 971 is in milestones 6 and R4c",
`"1353 to 1364" is a range in words, read as the 12 issues`, "milestone R4d names no issue"} {
if !strings.Contains(warned, w) {
t.Errorf("no warning %q in\n%s", w, warned)
}
}
// Design 57's range with an exception, and a design with no milestone table.
content, _, _ = progressRecords().Read(DesignsFolder + "/57-building-config-and-provisioning.md")
d57 := parseDesign(DesignsFolder+"/57-building-config-and-provisioning.md", content)
if m := d57.milestone("1"); m == nil || containsInt(m.Issues, 659) || !containsInt(m.Issues, 678) || !containsInt(m.Issues, 685) {
t.Errorf("design 57 milestone 1: %+v", d57.Milestones)
}
content, _, _ = progressRecords().Read(DesignsFolder + "/53-the-issue-tracker.md")
if d53 := parseDesign(DesignsFolder+"/53-the-issue-tracker.md", content); len(d53.Milestones) != 0 || d53.Status != "in-progress" {
t.Errorf("design 53: %+v", d53.Milestones)
}
}
@@ -207,8 +207,8 @@ func TestDesignsAreReadAgainOnlyWhenHqMoves(t *testing.T) {
if rec.reads != 3 {
t.Fatalf("read %d documents for the mesh, want 3", rec.reads)
}
if !strings.Contains(p.Tree, "3 designs in progress, 1 with milestones") || !strings.Contains(p.Tree, "design 47 · Delivery: from a commit to delivered") ||
!strings.Contains(p.Tree, "2 designs in progress name no milestones (issue 845): 53, 57") {
if !strings.Contains(p.Tree, "3 designs in progress, 2 with milestones") || !strings.Contains(p.Tree, "design 47 · Delivery: from a commit to delivered") ||
!strings.Contains(p.Tree, "1 designs in progress name no milestones (issue 845): 53") {
t.Fatalf("mesh:\n%s", p.Tree)
}
for range 2 {
@@ -303,20 +303,31 @@ func TestProgressOfADesignMinimalAndDetailed(t *testing.T) {
t.Fatal(err)
}
raw, _ := json.Marshal(p)
if len(raw) > DefaultBound || p.Detail != DetailMinimal || !strings.Contains(p.Source, "the issue tracker's milestone records of design 47") {
if len(raw) > DefaultBound || p.Detail != DetailMinimal || !strings.Contains(p.Source, "issue 1781") {
t.Fatalf("%d bytes, %s, %s", len(raw), p.Detail, p.Source)
}
if p.Cut != "" || len(p.Items) != 0 {
t.Errorf("a minimal answer leaves the items to its tree and is not a cut: cut %q, %d items", p.Cut, len(p.Items))
}
warns := []string{"mesh-delivery.show a answered something else: x", "issue-tracker.get 1: refused", "the tracker did not name 2", "issue-tracker.get 3 answered something else: y"}
callsFirst(warns)
if strings.Join(warns, "|") != "issue-tracker.get 1: refused|the tracker did not name 2|mesh-delivery.show a answered something else: x|issue-tracker.get 3 answered something else: y" {
t.Errorf("call failures do not come first: %q", warns)
}
if seats.asked["seat:issue-tracker.list"] != 1 || seats.asked["seat:mesh-delivery.deliveries"] != 0 || seats.asked["seat:issue-tracker.get"] != 0 {
t.Fatalf("minimal asked %v", seats.asked)
}
if w := strings.Join(p.Warnings, "\n"); !strings.Contains(w, "the tracker neither answered nor named missing") || !strings.Contains(w, "973, 525") {
t.Errorf("a row the tracker did not answer is not said: %v", p.Warnings)
}
if big, _ := k.Progress(ProgressAsk{Of: "design 47", Bound: 8192}); big.Cut != "" || big.Tree != p.Tree {
t.Errorf("a minimal answer at bound 8192 is cut: %q", big.Cut)
}
lines := strings.Split(p.Tree, "\n")
if !strings.HasPrefix(lines[0], "✗ design 47 · Delivery: from a commit to delivered — ") {
t.Fatalf("first line %q", lines[0])
}
for _, want := range []string{"✗ R6 4/7 1945 fixing since", "✗ 3-fix 0/1 1049 fixing, worker fix-chain-cat@shanks stale since"} {
for _, want := range []string{"✗ R6 4/7 1945 fixing since", "✗ 3 fix 0/1 1049 fixing, worker fix-chain-cat@shanks stale since"} {
if !strings.Contains(p.Tree, want) {
t.Errorf("no %q in\n%s", want, p.Tree)
}
@@ -346,7 +357,7 @@ func TestProgressOfADesignMinimalAndDetailed(t *testing.T) {
if raw, _ := json.Marshal(small); len(raw) > LeastBound || !strings.Contains(small.Cut, "raise bound") {
t.Fatalf("%d bytes within %d; cut %q", len(raw), LeastBound, small.Cut)
}
for _, bad := range []ProgressAsk{{Of: "design"}, {Of: "milestone 47/R99"}, {Of: "design 47", Detail: "full"}, {Of: "design 57"}} {
for _, bad := range []ProgressAsk{{Of: "design"}, {Of: "milestone 47/R99"}, {Of: "design 47", Detail: "full"}} {
if _, err := k.Progress(bad); err == nil {
t.Errorf("%+v was answered", bad)
}
@@ -5,6 +5,3 @@ Captured for the progress verb's tests (novox/hq issue 1947), never written by h
2026-10-26 16:2x (before rows said `moved`).
- `deliveries.json`, `groups.json`, `show-car-hunter-2.json`: parts of `mesh-delivery.deliveries`, `.groups` and
`.show` as the live delivery seat answered them on 2026-10-26 16:2x, cut to the members named here.
- `tracker-milestones-47.json`: the answer of `issue-tracker.milestones` for design 47 (issue 1781), made by running
the tracker's own table parse (mesh-issues `parseTable`, its seed) over `hq/`'s design 47, each issue two rows name
left in neither, as the seed refuses it; the tracker held no milestone records yet to capture from.
@@ -1,418 +0,0 @@
{
"milestones": [
{
"design": 47,
"id": "1",
"issues": [
973,
525
],
"order": 1,
"outcome": "A merge during a running delivery is never dropped and needs no person's release; the merge window lives on the bus; a rollout that shares nothing with an open one runs beside it",
"ref": "47/1",
"status": "open"
},
{
"design": 47,
"id": "2",
"issues": [
974,
975,
976
],
"order": 2,
"outcome": "A ready mark is acted on within seconds, and each pull request has at most one waiting check",
"ref": "47/2",
"status": "open"
},
{
"design": 47,
"id": "2b",
"issues": [
1025,
1019
],
"order": 3,
"outcome": "build-agent and route-proxy live whole in mesh-controller (ADR 0420, accepted 2026-10-17): the controller accepts a reviewed move, mesh-delivery announces each merge with its Approve, and the two modules move; built as two releases before the rule that a milestone is one release",
"ref": "47/2b",
"status": "open"
},
{
"design": 47,
"id": "3",
"issues": [
977,
978,
1022,
895,
1046,
605,
1021
],
"order": 4,
"outcome": "A feature with a new seat verb across the controller and the catalogue is one group, one batch, one delivery plan and one Approve: no order lines, no ready mark, each module built and sent once",
"ref": "47/3",
"status": "open"
},
{
"design": 47,
"id": "4",
"issues": [
980,
1065,
1066,
1067,
1068
],
"order": 5,
"outcome": "A delivery backs up the data it moves first, and a failed backup holds it and is said",
"ref": "47/4",
"status": "open"
},
{
"design": 47,
"id": "3-fix",
"issues": [
1082,
1049,
1047,
1083,
1079,
1084
],
"order": 6,
"outcome": "A group approved together merges in one pass and goes out in one delivery plan, also when its members' tests read each other's repositories",
"ref": "47/3-fix",
"status": "open"
},
{
"design": 47,
"id": "5",
"issues": [
981
],
"order": 7,
"outcome": "One read of a module on a node names the commit, build and rollout that put it there",
"ref": "47/5",
"status": "open"
},
{
"design": 47,
"id": "5b",
"issues": [
1117,
1124,
1128
],
"order": 8,
"outcome": "Decided: building and delivering leave the controller for parts that watch and write keys in the repository `mesh-delivery`, landing in six releases (ADR 0429); the forge module makes that repository from its config, protected before any team reaches it (ADR 0428)",
"ref": "47/5b",
"status": "open"
},
{
"design": 47,
"id": "6",
"issues": [],
"order": 9,
"outcome": "Folded into release 4 by ADR 0429 §10 (the fetch over SSH in R4c, the cut-over in R4e): installed toolchains, builders fetching over SSH with their own key, and an SDK change shipping with its users are built in the builder's new home, so the builder is changed once",
"ref": "47/6",
"status": "open"
},
{
"design": 47,
"id": "R1a",
"issues": [
1131
],
"order": 10,
"outcome": "The node-engine, the controller and the parts can read one layout of the have, presence, pulse and stuck keys: mesh-sdk ships it before its users",
"ref": "47/R1a",
"status": "open"
},
{
"design": 47,
"id": "R1b",
"issues": [
1132,
1142,
1136,
1145,
1146,
1147,
1160,
1162
],
"order": 11,
"outcome": "Every node writes what it runs of each module, on its own keys and nobody else's, and the bus says to one part when a connection opens and closes",
"ref": "47/R1b",
"status": "open"
},
{
"design": 47,
"id": "R1c",
"issues": [
1134,
1137
],
"order": 12,
"outcome": "The operator sees every module on every node, with its build, phase and health, in `mesh-delivery-cli` (aliased `nox-delivery`) and its web view",
"ref": "47/R1c",
"status": "open"
},
{
"design": 47,
"id": "R2a",
"issues": [
1153
],
"order": 13,
"outcome": "The watchman and the controller can read one layout of the stuck keys: mesh-sdk ships it before its users",
"ref": "47/R2a",
"status": "open"
},
{
"design": 47,
"id": "R2b",
"issues": [
1135,
1154,
1155,
1156,
1157,
1159
],
"order": 14,
"outcome": "A part, node or check that stops moving is raised as a condition within two minutes, with no timer in a part",
"ref": "47/R2b",
"status": "open"
},
{
"design": 47,
"id": "R2c",
"issues": [
1183
],
"order": 15,
"outcome": "The controller says when it last heard each machine from presence alone, and no longer hears the alive message",
"ref": "47/R2c",
"status": "open"
},
{
"design": 47,
"id": "R2d",
"issues": [
1158
],
"order": 16,
"outcome": "The node-engine sends no alive message",
"ref": "47/R2d",
"status": "open"
},
{
"design": 47,
"id": "R3a",
"issues": [
1200,
1201,
1202,
1203,
1204,
1208
],
"order": 17,
"outcome": "The gate, the bridge, the indexer and the teller can read one layout of the source revision, candidates, modules, merges, asks, answers, owed acts, forge messages and conditions, and one function computes the source fingerprint: mesh-sdk ships them before their users (ADR 0437)",
"ref": "47/R3a",
"status": "open"
},
{
"design": 47,
"id": "R3b",
"issues": [
1210,
1211,
1212,
1213,
1214,
1215,
1216,
1217,
1218
],
"order": 18,
"outcome": "The controller finds the delivery seat's holder by its claim and records each build's fingerprint inputs; the router takes conditions on their one subject; the operator sees no change (ADR 0437)",
"ref": "47/R3b",
"status": "open"
},
{
"design": 47,
"id": "R3c",
"issues": [
1235,
1236,
1237,
1238,
1239,
1240,
1241,
1234,
1223,
1233,
1242
],
"order": 19,
"outcome": "The forge bridge, the indexer and the teller run beside today's mesh-delivery and write their keys; the view shows modules against the controller's source fingerprints; nothing is merged by them yet (ADR 0437)",
"ref": "47/R3c",
"status": "open"
},
{
"design": 47,
"id": "R3d",
"issues": [
1321,
1322,
1323,
1324,
1325,
1326,
1317,
1314,
1315,
1316
],
"order": 20,
"outcome": "The gate's keys can carry what the forge needs and the controller answers a what-if as data, so the gate has what it reads before it runs; a failed delivery plan asks the operator Retry on the phone, and a seat whose holder is assigned nowhere passes to its only claimant; the operator sees only the Retry notice (ADR 0450, ADR 0449)",
"ref": "47/R3d",
"status": "open"
},
{
"design": 47,
"id": "R3e",
"issues": [],
"order": 21,
"outcome": "A group reaches the mesh as one source revision written by the gate; the merge's Approve is asked by the teller and answered on the phone until issue 953; the view shows the open asks; today's module mesh-delivery is carried over into the gate when it first holds the seat and ends; no step needs the operator's terminal (ADR 0437, ADR 0450)",
"ref": "47/R3e",
"status": "open"
},
{
"design": 47,
"id": "R4a",
"issues": [
1353,
1354,
1355,
1356,
1357,
1358,
1359,
1360,
1361,
1362,
1363,
1364,
1298,
1308
],
"order": 22,
"outcome": "The shared layouts of release 4 are on mesh-sdk's main before their users pin them: the catalogue, the validator and the composition over a plain input, the fingerprint's new form, and the key layouts of the builder, the checker, the kept verdicts, the manifests and the candidates; the node-engine reports the user namespaces capability (ADR 0453 clauses 1, 2, 3)",
"ref": "47/R4a",
"status": "open"
},
{
"design": 47,
"id": "R4b",
"issues": [
1375,
1337
],
"order": 23,
"outcome": "mesh-controller and the node-engine use the catalogue, the validator and the composition from mesh-sdk and delete their copies; the controller composes every node both ways on the live facts and the two agree before this is delivered; it writes the grants and credential bindings the assigner reads; its unit level meets its bound (ADR 0453 clauses 1, 3)",
"ref": "47/R4b",
"status": "open"
},
{
"design": 47,
"id": "R4c",
"issues": [
1309,
1287,
1288,
1289,
1290,
1291,
1292,
1251,
1293
],
"order": 24,
"outcome": "The new parts run dark beside today's: the indexer writes the manifests by digest and the fingerprints in the new form beside today's (its own, first delivery), then the build modules (the Go build module, the Node and TypeScript build module: toolchain, sandbox, fetch, digest, test, build), the builder, the checker writing `mesh/check` as a status the forge does not require, and the assigner's composition check writing into the checker's report; the build seat's accounts are decided by a record before the build modules' pull requests are opened (ADR 0453 clauses 4, 6, 7, 8, 11, 12, 16; ADR 0465 §4); the builder is ADR 0478's. It is delivered in ADR 0465 §5's order: mesh-sdk's layouts; the forge bridge and the indexer with the controller's facts in format 2; the build modules; the builder; the checker; the bus user list's composer moved into mesh-sdk; the assigner's composition check; the fetch and run steps. The build modules each run as an account of their own with a forge key known read-only on one forge user (ADR 0466); each builds with the node's installed toolchain at the agreed version and does not refuse on a node with another (ADR 0477, issue 1572), answers a long step with its id and finishes it on a key, is called on a named machine, and runs on the four nodes that run the build agent today (ADR 0469)",
"ref": "47/R4c",
"status": "open"
},
{
"design": 47,
"id": "R4d",
"issues": [],
"order": 25,
"outcome": "Four modules stop being built from a Dockerfile: route-proxy, nats, n8n and mail's autoconfiguration module (automx, replaced by a Go program), each rework its own release step, nats in a delivery of its own; free between R4a and R4e and never in the same delivery as another release (ADR 0452, ADR 0453 clauses 13, 14, 16)",
"ref": "47/R4d",
"status": "open"
},
{
"design": 47,
"id": "R4e",
"issues": [
1304
],
"order": 26,
"outcome": "The controller asks no build: a build is keyed by its fingerprint, the new form becomes the only one as one group, and a merge builds nothing its check built; builders fetch over SSH, so an SDK change ships with its users; the vendored copies leave mesh-controller and mesh-sdk; delivered only once the builder's artifact digests equal build-agent's (ADR 0453 clauses 2, 4, 5, 6)",
"ref": "47/R4e",
"status": "open"
},
{
"design": 47,
"id": "R4f",
"issues": [
709,
1300,
1301,
1302
],
"order": 27,
"outcome": "A pull request carries one required status that says what it proved and how long each part took; the controller's composition check and its replays are deleted with the two old statuses; the controller's deletions R3e makes possible go; each level's times are measured, and a bound fails a repository from the first check that meets it (ADR 0447, ADR 0450 §8, ADR 0453 clauses 4, 9, 10)",
"ref": "47/R4f",
"status": "open"
},
{
"design": 47,
"id": "R4g",
"issues": [
1887,
1295,
1296,
1303
],
"order": 28,
"outcome": "The last milestone of release 4 and release 5 as one, with one switch-over and no rehearsal (ADR 0524 §1): builds and checks are asks on streams that each build node pulls when it has a free slot, deliveries first, with nothing holding checks and no controller queue (ADR 0525); the assigner is made with its composition check, writes each node's wants compared with what the controller sends and every difference, and the view answers the asks that need no proof of who answered (ADR 0528, ADR 0437 §10); no module's fingerprint takes the builder (ADR 0530); the scripts of the repositories a module is built from, build-agent with its socket and Dockerfile code, the toolchain images and the controller's build code go, and the builder delivers its own module, the build modules and the controller from its keys (ADR 0453 clauses 5, 8, 10; ADR 0524 §5, §7); the ways to deliver without the builder are recorded (ADR 0526); hq's four checks are Go tests (ADR 0527); each repository's script goes (ADR 0529); the word for the composition check's predecessor is struck. Its pull requests are opened only after five readings, the revert path read, not shown on a test merge (ADR 0524 §3)",
"ref": "47/R4g",
"status": "open"
},
{
"design": 47,
"id": "R6",
"issues": [
1942,
1945,
1943,
1944,
1946,
1906,
1130
],
"order": 29,
"outcome": "Each node applies from its wants the compositions its own node agent composes (ADR 0547 §2; as released it applied the controller's signed ones), a new version reaches the module's canary first and the rest once it is healthy there, a move a person must allow is asked before its want, the view says what is moving, settled, stuck and delivered, and the plans are deleted from the code; switched over by release 5's last plan (ADR 0539)",
"ref": "47/R6",
"status": "open"
}
],
"said": "generated"
}
-1
View File
@@ -87,7 +87,6 @@
"seat:operator-channel.notify",
"seat:issue-tracker.list",
"seat:issue-tracker.get",
"seat:issue-tracker.milestones",
"seat:mesh-delivery.deliveries",
"seat:mesh-delivery.show",
"seat:mesh-delivery.groups"
@@ -5,8 +5,6 @@ package main
// key (or a first one) raises `secret-changed` for the machine's core secrets, the same key says nothing.
import (
"git.novox.be/novox/mesh-sdk/go/seal"
"encoding/json"
"os"
"path/filepath"
@@ -102,17 +100,14 @@ authorization {
users = [
{ user: "novox.mesh-vault", password: "pw", permissions: {
publish: { deny: [">"] }
subscribe: { allow: ["mesh.seat.mesh-vault.call.node-key.*", "mesh.seat.mesh-vault.call.own.*"] }
subscribe: { allow: ["mesh.seat.mesh-vault.call.node-key.*"] }
allow_responses: true } }
{ user: "node.ace", password: "pw", permissions: {
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_ace", "mesh.seat.mesh-vault.call.own.node_ace"] }
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_ace"] }
subscribe: { allow: ["_INBOX.node.ace.>"] } } }
{ user: "node.g14", password: "pw", permissions: {
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_g14", "mesh.seat.mesh-vault.call.own.node_g14"] }
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_g14"] }
subscribe: { allow: ["_INBOX.node.g14.>"] } } }
{ user: "controller", password: "pw", permissions: {
publish: { allow: ["mesh.seat.mesh-vault.call.own.controller"] }
subscribe: { allow: ["_INBOX.controller.>"] } } }
]
}
`
@@ -140,48 +135,39 @@ func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) {
t.Cleanup(s.Shutdown)
v := newVault(t, nil)
v.Askers = Declared{Read: assignments{"ace.web": `{"node":"ace","module":"web"}`}.read, Store: v.Store}
tools := map[string]toolDef{"node-key": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key"),
"own": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".own")}
tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key")
vaultConn := connectAs(t, s.ClientURL(), "novox.mesh-vault")
serve := func(m *nats.Msg) {
if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.node-key.*", func(m *nats.Msg) {
parts := strings.Split(m.Subject, ".")
var args map[string]any
_ = json.Unmarshal(m.Data, &args)
answer := busAnswer{}
if got, err := tools[parts[len(parts)-2]].runAs(untoken(parts[len(parts)-1]), args); err != nil {
if got, err := tool.runAs(untoken(parts[len(parts)-1]), args); err != nil {
answer.Error = err.Error()
} else {
answer.Result, _ = json.Marshal(got)
}
raw, _ := json.Marshal(answer)
_ = m.Respond(raw)
}
for _, verb := range []string{"node-key", "own"} {
if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call."+verb+".*", serve); err != nil {
t.Fatal(err)
}
}); err != nil {
t.Fatal(err)
}
_ = vaultConn.Flush()
call := func(user, subject string, args any) (json.RawMessage, error) {
raw, _ := json.Marshal(args)
say := func(user, subject, key string) (string, error) {
raw, _ := json.Marshal(map[string]string{"key": key})
msg, err := connectAs(t, s.ClientURL(), user).Request(subject, raw, 3*time.Second)
if err != nil {
return nil, err
return "", err
}
var a busAnswer
_ = json.Unmarshal(msg.Data, &a)
if a.Error != "" {
return nil, errString(a.Error)
return a.Error, errString(a.Error)
}
return a.Result, nil
return "", nil
}
say := func(user, subject, key string) (string, error) {
_, err := call(user, subject, map[string]string{"key": key})
return "", err
}
aceKey, acePriv := aSealingKey(t)
aceKey, _ := aSealingKey(t)
g14Key, _ := aSealingKey(t)
if _, err := say("node.ace", "mesh.seat.mesh-vault.call.node-key.node_ace", aceKey); err != nil {
t.Fatal(err)
@@ -202,32 +188,4 @@ func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) {
if got, held, _ := v.Store.NodeKey("g14"); !held || got != g14Key {
t.Errorf("g14's key is %q, %v", got, held)
}
// Issue 1126 (ADR 0547 §2, ADR 0550 §5): `own` on the same bus. node.ace asks its own and gets its module's secret
// sealed to the key it stated, opening with its key alone; g14 cannot ask in ace's name, and asking in its own gets
// none of ace's; the controller is refused, as no node-engine.
if _, err := moveIn(t, v, "node.ace", MoveOf{Node: "ace", Module: "web", Name: "password", Origin: "made", Generation: "g-1",
SealingKey: aceKey}, "ace-web-pw"); err != nil {
t.Fatal(err)
}
raw, err := call("node.ace", "mesh.seat.mesh-vault.call.own.node_ace", map[string]any{})
var own OwnAnswer
if err != nil || json.Unmarshal(raw, &own) != nil || own.Node != "ace" || len(own.Own) != 1 {
t.Fatalf("ace's own over the bus: %s, %v", raw, err)
}
if value, err := seal.Open(own.Own[0].Sealed, acePriv); err != nil || string(value) != "ace-web-pw" {
t.Errorf("ace's secret does not open with ace's key: %q, %v", value, err)
}
if strings.Contains(string(raw), "ace-web-pw") {
t.Error("ace's secret crossed the bus in the clear")
}
if _, err := call("node.g14", "mesh.seat.mesh-vault.call.own.node_ace", map[string]any{}); err == nil {
t.Error("node.g14 asked ace's own in ace's name")
}
if raw, err := call("node.g14", "mesh.seat.mesh-vault.call.own.node_g14", map[string]any{}); err != nil || strings.Contains(string(raw), "web") {
t.Errorf("g14 asking its own was answered %s, %v", raw, err)
}
if _, err := call("controller", "mesh.seat.mesh-vault.call.own.controller", map[string]any{}); err == nil || !strings.Contains(err.Error(), "not a node-engine") {
t.Errorf("the controller asking own: %v", err)
}
}
@@ -1,288 +0,0 @@
package main
// A machine's node-engine composes its own modules (novox/hq ADR 0547 §2, issue 1126), and asks the vault for their own
// secrets sealed to its own key (ADR 0349 §11, ADR 0550 §5): the verb `own`, answered to the node-engine of a machine
// alone, about that machine alone (the machine is the caller's, as the bus proved it, never an argument). Each value is
// sealed to the sealing key the machine's engine stated (`node-key`), so what crosses the bus opens on that machine only,
// and the composition carries it sealed (no composition carries a secret in the clear). A module handed to the
// provisioner is left out: its client fills its secrets from `get` (ADR 0349 §17).
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// NodeOwnSecret is one own secret of a module on the asking machine, sealed to the machine's key.
type NodeOwnSecret struct {
Module string `json:"module"`
Name string `json:"name"`
Origin string `json:"origin"`
Generation int `json:"generation"`
Sealed seal.Box `json:"sealed"`
}
// OwnAnswer is what `own` answers: the machine, the sealing key every value is sealed to (raw X25519, base64, as its
// engine stated it), each own secret of its modules, and each pair credential it asked for that the vault holds.
type OwnAnswer struct {
Node string `json:"node"`
Key string `json:"key"`
Own []NodeOwnSecret `json:"own"`
Credentials []NodeCredential `json:"credentials,omitempty"`
Missing []CredentialAsked `json:"missing,omitempty"`
}
// CredentialAsked names one pair credential by its ends, as a node composing its own asks for it (mesh-sdk
// compose.CredentialKey): Role is `consumer` when the asking machine consumes it, `provider` when it grants it.
type CredentialAsked struct {
Role string `json:"role"`
Provision string `json:"provision"`
Consumer string `json:"consumer"`
ConsumerModule string `json:"consumer_module"`
Provider string `json:"provider"`
Local string `json:"local,omitempty"`
}
// NodeCredential is one pair credential answered, sealed to the asking machine's key, with the name the vault keeps it
// under (`pair/...` per pair, `provision/<provider>/<provision>/<node>/<module>` issued for one holder, or the provision's
// one shared value `provision/<provider>/<provision>`).
type NodeCredential struct {
CredentialAsked
Name string `json:"name"`
Generation int `json:"generation"`
Sealed seal.Box `json:"sealed"`
}
// credentialNames are the names a pair credential may be kept under, the most particular first.
func credentialNames(c CredentialAsked) []string {
pair := "pair/" + c.Provider + "/" + c.Provision + "/" + c.Consumer + "/" + c.ConsumerModule
if c.Local != "" {
pair += "/" + c.Local
}
return []string{pair, "provision/" + c.Provider + "/" + c.Provision + "/" + c.Consumer + "/" + c.ConsumerModule,
"provision/" + c.Provider + "/" + c.Provision}
}
// credentialRefusal says why node may not have c, or "": a consumer's credential to the machine its consuming module
// stands on, a provider's to the providing machine, and nothing to a machine that is neither.
func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
for _, part := range []string{c.Provision, c.Consumer, c.ConsumerModule, c.Provider} {
if !namePart.MatchString(part) {
return fmt.Sprintf("%q is not a name the mesh writes", part)
}
}
if c.Local != "" && !namePart.MatchString(c.Local) {
return fmt.Sprintf("%q is not a name the mesh writes", c.Local)
}
switch c.Role {
case "consumer":
if c.Consumer != node {
return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer)
}
// The consuming module stands on the machine: read, never assumed (an assignment that cannot be read refuses).
read, why := v.assignmentReader(node)
if why != "" {
return why
}
raw, err := read(node + "." + c.ConsumerModule)
if err != nil {
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
}
if raw == nil {
return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node)
}
return ""
case "provider":
if c.Provider != node {
return fmt.Sprintf("%s asks as the provider %s, and is not that machine", node, c.Provider)
}
return ""
}
return fmt.Sprintf("the role %q is not consumer or provider", c.Role)
}
// assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them,
// and without them it refuses rather than hands (a rule that cannot be read is no rule).
func (v *Vault) assignmentReader(node string) (AssignmentReader, string) {
d, ok := v.Askers.(Declared)
if !ok || d.Read == nil {
return nil, "the vault cannot read the assignments, so it cannot tell what " + node + "'s modules are; nothing is handed"
}
return standing(d.Read), ""
}
// sharedRefusal says why node may not have the provision's one shared value `provision/<provider>/<provision>` for c,
// or "": the vault's record of who holds it names the consumer's module among its consumers, or the machine as its
// provider. Without that record nothing is handed: the shared value is not one any machine may have by naming a
// provider (ADR 0550 §5).
func (v *Vault) sharedRefusal(node string, c CredentialAsked) (string, error) {
name := "provision/" + c.Provider + "/" + c.Provision
sh, ok, err := v.Store.ReadShared(name)
if err != nil {
return "", err
}
if !ok {
return fmt.Sprintf("the vault keeps no record of who holds %s", name), nil
}
switch c.Role {
case "consumer":
want := consumerOf(c.Consumer, c.ConsumerModule, c.Local)
for _, held := range sh.Consumers {
if held == want {
return "", nil
}
}
return fmt.Sprintf("%s is held by %s, not by %s", name, strings.Join(sh.Consumers, ", "), want), nil
case "provider":
if sh.Provider == node {
return "", nil
}
return fmt.Sprintf("%s is provided from %s, not %s", name, sh.Provider, node), nil
}
return fmt.Sprintf("the role %q is not consumer or provider", c.Role), nil
}
// composeOrigin is the vault's origin of a value as the composition reads it: made by the mesh, or given (accepted at a
// terminal, typed at a desk, issued by a provider's software).
func composeOrigin(origin string) string {
if origin == "" || origin == OriginMade {
return OriginMade
}
return OriginAccepted
}
// OwnOfNode answers the node-engine of a machine the own secrets of its modules, each sealed to the machine's stated key.
func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, error) {
node, ok := nodeEngineOf(caller)
if !ok {
if caller == "" || caller == stdio.Unproven {
caller = "an unproven caller"
}
return OwnAnswer{}, fmt.Errorf("own answers a machine's node-engine alone, about its own machine; %s is not a node-engine", caller)
}
key, had, err := v.Store.NodeKey(node)
if err != nil {
return OwnAnswer{}, err
}
if !had {
return OwnAnswer{}, fmt.Errorf("the vault holds no secret named own/%s yet: %s's node-engine has stated no sealing key "+
"(node-key), so there is no key of its own to seal to", node, node)
}
to, err := publicPEM(key)
if err != nil {
return OwnAnswer{}, fmt.Errorf("%s's sealing key as recorded is not usable: %w", node, err)
}
all, err := v.Store.List()
if err != nil {
return OwnAnswer{}, err
}
out := OwnAnswer{Node: node, Key: key}
for _, r := range all {
parts := strings.Split(r.Name, "/")
if len(parts) != 4 || parts[0] != "own" || parts[1] != node {
continue
}
handed, err := v.handed(node, parts[2])
if err != nil {
return OwnAnswer{}, err
}
if handed {
continue
}
value, err := seal.Open(r.AtRest, v.Own.PrivateKey)
if err != nil {
return OwnAnswer{}, fmt.Errorf("%s does not open with the vault's key: %w", r.Name, err)
}
sealed, err := seal.Seal(value, to)
if err != nil {
return OwnAnswer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"}); err != nil {
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", r.Name, err)
}
out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin),
Generation: r.Generation, Sealed: sealed})
}
sort.Slice(out.Own, func(i, j int) bool {
if out.Own[i].Module != out.Own[j].Module {
return out.Own[i].Module < out.Own[j].Module
}
return out.Own[i].Name < out.Own[j].Name
})
for _, c := range asked {
if why := v.credentialRefusal(node, c); why != "" {
return OwnAnswer{}, fmt.Errorf("%s may not ask for the credential of %s from %s to %s on %s: %s", caller,
c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why)
}
found := false
names := credentialNames(c)
for i, name := range names {
r, held, err := v.Store.Read(name)
if err != nil {
return OwnAnswer{}, err
}
if !held {
continue
}
if i == len(names)-1 {
// The provision's one shared value: handed only to a machine the record of its holders names.
why, err := v.sharedRefusal(node, c)
if err != nil {
return OwnAnswer{}, err
}
if why != "" {
v.log("%s is not handed %s: %s", node, name, why)
continue
}
}
value, err := seal.Open(r.AtRest, v.Own.PrivateKey)
if err != nil {
return OwnAnswer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
}
sealed, err := seal.Seal(value, to)
if err != nil {
return OwnAnswer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"}); err != nil {
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
}
out.Credentials = append(out.Credentials, NodeCredential{CredentialAsked: c, Name: name, Generation: r.Generation, Sealed: sealed})
found = true
break
}
if !found {
out.Missing = append(out.Missing, c)
}
}
v.log("handed %s's node-engine the %d own secrets of its modules and %d credentials (%d not held), sealed to its key",
node, len(out.Own), len(out.Credentials), len(out.Missing))
return out, nil
}
// handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed.
// A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner
// holds.
func (v *Vault) handed(node, module string) (bool, error) {
read, why := v.assignmentReader(node)
if why != "" {
return false, errors.New(why)
}
raw, err := read(node + "." + module)
if err != nil {
return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+
"provisioner is not known: %w", node, module, err)
}
if raw == nil {
return false, nil
}
var a plannedSecrets
if err := json.Unmarshal(raw, &a); err != nil {
return false, fmt.Errorf("the assignment %s.%s is not the controller's: %w", node, module, err)
}
return a.Provisioning != nil, nil
}
@@ -1,121 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// Issue 1126 (ADR 0547 §2, ADR 0550 §5): `own` answers a machine's node-engine the own secrets of its modules, sealed to
// the key that machine's engine stated, about that machine alone; another caller, another machine's secrets and a
// machine with no stated key get nothing.
func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) {
plans := assignments{"anchor.web": `{"node":"anchor","module":"web"}`, "laptop.web": `{"node":"laptop","module":"web"}`}
v := newVault(t, nil)
v.Askers = Declared{Read: plans.read, Store: v.Store}
anchorPub, anchorPriv := aSealingKey(t)
laptopPub, _ := aSealingKey(t)
for _, m := range []struct {
caller string
of MoveOf
value string
}{
{"node.anchor", MoveOf{Node: "anchor", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: anchorPub}, "anchor-web-pw"},
{"node.anchor", MoveOf{Node: "anchor", Module: "web", Name: "api-key", Origin: "accepted", Generation: "g-1", SealingKey: anchorPub}, "anchor-web-key"},
{"node.laptop", MoveOf{Node: "laptop", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: laptopPub}, "laptop-web-pw"},
} {
if _, err := moveIn(t, v, m.caller, m.of, m.value); err != nil {
t.Fatal(err)
}
}
got, err := v.OwnOfNode("node.anchor")
if err != nil {
t.Fatal(err)
}
if got.Node != "anchor" || got.Key != anchorPub || len(got.Own) != 2 {
t.Fatalf("anchor was answered %+v", got)
}
want := map[string][2]string{"api-key": {"accepted", "anchor-web-key"}, "password": {"made", "anchor-web-pw"}}
for _, s := range got.Own {
w, ok := want[s.Name]
if !ok || s.Module != "web" || s.Origin != w[0] || s.Generation < 1 {
t.Errorf("anchor's %s: %+v", s.Name, s)
continue
}
value, err := seal.Open(s.Sealed, anchorPriv)
if err != nil || string(value) != w[1] {
t.Errorf("anchor's %s does not open to its value with anchor's key: %q, %v", s.Name, value, err)
}
}
for caller, says := range map[string]string{
"controller": "is not a node-engine",
"anchor.web": "is not a node-engine",
"": "an unproven caller",
"node.unknown": "no secret named own/unknown yet",
} {
if _, err := v.OwnOfNode(caller); err == nil || !strings.Contains(err.Error(), says) {
t.Errorf("%q: %v, want a refusal saying %q", caller, err, says)
}
}
if laptop, err := v.OwnOfNode("node.laptop"); err != nil || len(laptop.Own) != 1 || laptop.Own[0].Name != "password" {
t.Errorf("laptop was answered %+v, %v; want its one secret alone", laptop, err)
}
// A pair credential: laptop's web consumes anchor's postgres. Each end is answered the one value sealed to its own key;
// a credential the vault holds none of is named missing; a machine at neither end is refused.
if _, err := moveIn(t, v, "node.laptop", MoveOf{Provider: "anchor", Provision: "postgres", Consumer: "laptop",
ConsumerModule: "web", Origin: "made", Generation: "g-1", SealingKey: laptopPub}, "pg-pair"); err != nil {
t.Fatal(err)
}
pair := CredentialAsked{Provision: "postgres", Consumer: "laptop", ConsumerModule: "web", Provider: "anchor"}
asConsumer, asProvider := pair, pair
asConsumer.Role, asProvider.Role = "consumer", "provider"
none := CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "laptop", ConsumerModule: "web", Provider: "anchor"}
laptop, err := v.OwnOfNode("node.laptop", asConsumer, none)
if err != nil || len(laptop.Credentials) != 1 || len(laptop.Missing) != 1 || laptop.Missing[0] != none {
t.Fatalf("laptop as consumer: %+v, %v", laptop, err)
}
if !strings.HasPrefix(laptop.Credentials[0].Name, "pair/anchor/postgres/laptop/web") {
t.Errorf("laptop's credential is kept as %s", laptop.Credentials[0].Name)
}
anchor, err := v.OwnOfNode("node.anchor", asProvider)
if err != nil || len(anchor.Credentials) != 1 {
t.Fatalf("anchor as provider: %+v, %v", anchor, err)
}
if value, err := seal.Open(anchor.Credentials[0].Sealed, anchorPriv); err != nil || string(value) != "pg-pair" {
t.Errorf("anchor's copy does not open to the value with its key: %q, %v", value, err)
}
for who, c := range map[string]CredentialAsked{"node.anchor": asConsumer, "node.laptop": asProvider} {
if _, err := v.OwnOfNode(who, c); err == nil || !strings.Contains(err.Error(), "is not that machine") {
t.Errorf("%s asking %+v: %v", who, c, err)
}
}
// Review of L2 (C1, C2): `own` fails closed. A provision's one shared value is handed only to a consumer the record
// of its holders names, never to a machine that names its provider; assignments that cannot be read, or a vault
// that reads none, refuse rather than hand.
if _, err := v.keep("provision/anchor/redis", "shared-redis", "test", "", OriginMade); err != nil {
t.Fatal(err)
}
if err := v.Store.WriteShared(Shared{Name: "provision/anchor/redis", Provider: "anchor", Provision: "redis",
Consumers: []string{"anchor/web"}}); err != nil {
t.Fatal(err)
}
if got, err := v.OwnOfNode("node.laptop", none); err != nil || len(got.Credentials) != 0 || len(got.Missing) != 1 {
t.Errorf("laptop, whose web the shared value's record does not name, got %+v, %v", got, err)
}
anchorRedis := CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"}
if got, err := v.OwnOfNode("node.anchor", anchorRedis); err != nil || len(got.Credentials) != 1 {
t.Errorf("anchor's web, a holder the record names, got %+v, %v", got, err)
}
v.Askers = Declared{Read: func(string) ([]byte, error) { return nil, errors.New("the bucket did not answer") }, Store: v.Store}
if _, err := v.OwnOfNode("node.anchor"); err == nil || !strings.Contains(err.Error(), "could not be read") {
t.Errorf("own with the assignments unread: %v", err)
}
v.Askers = nil
if _, err := v.OwnOfNode("node.anchor", anchorRedis); err == nil || !strings.Contains(err.Error(), "cannot read the assignments") {
t.Errorf("own with no reader of the assignments: %v", err)
}
}
@@ -251,34 +251,6 @@ func definitions(env *Env) []toolDef {
// clause 9, adopt.go): a public half, never the private one.
return map[string]any{"recorded": true, "vault_key": v.Own.PublicKey}, nil
}},
{Name: Seat + ".own",
Description: "The own secrets of this machine's modules, each sealed to the machine's sealing key (novox/hq ADR 0547 §2, " +
"ADR 0550 §5, issue 1126): from the node-engine of the machine alone, about its own machine (the bus proves which), " +
"for the compositions it makes itself. Answers the machine, the key each value is sealed to, and per module and " +
"name the origin, the generation and the sealed value; a module handed to the provisioner is left out. With credentials, " +
"also each pair credential the machine consumes or grants, sealed the same way. Never a value in the clear.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"credentials": map[string]any{"type": "array", "description": "the pair credentials the machine's composition " +
"reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local}: answered " +
"sealed to the machine's key when the machine is that consumer's or that provider, and named missing when " +
"the vault holds none", "items": map[string]any{"type": "object"}}}},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
var asked []CredentialAsked
if raw, ok := a["credentials"]; ok && raw != nil {
b, err := json.Marshal(raw)
if err == nil {
err = json.Unmarshal(b, &asked)
}
if err != nil {
return nil, fmt.Errorf("credentials is a list of {role, provision, consumer, consumer_module, provider, local}: %w", err)
}
}
return v.OwnOfNode(caller, asked...)
}},
{Name: Seat + ".move-asks",
Description: "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once " +
"(design 57's V3): from the controller alone, at most 500 at a time. No value is in any of them.",
+1 -2
View File
@@ -177,8 +177,7 @@
"rotate",
"move-in",
"node-key",
"move-asks",
"own"
"move-asks"
]
}
]
Binary file not shown.