Merge feat/modules-name-no-node into bulk-a
This commit is contained in:
@@ -86,7 +86,7 @@ login.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
| | node-b | node-d |
|
||||
|---|---|---|
|
||||
| packages | none (all present) | the same |
|
||||
| GTK settings, `portals.conf` | the found files kept once, then the module's: adds the cursor and Inter, keeps Adwaita dark; `portals.conf` adds the Secret line | the same |
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
|
||||
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: node-b\n\tAlias: node-b\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
|
||||
|
||||
func headphones(connected bool) string {
|
||||
c := "no"
|
||||
@@ -34,7 +34,7 @@ func headphones(connected bool) string {
|
||||
func TestControllerReadsShow(t *testing.T) {
|
||||
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
|
||||
c, err := Controller()
|
||||
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
|
||||
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "node-b" || !c.Powered || c.Discoverable || !c.Pairable {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
|
||||
|
||||
@@ -160,7 +160,7 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
|
||||
"path": str("an absolute path of a file on the machine node names"),
|
||||
"node": str("the machine path is on, such as g14"),
|
||||
"node": str("the machine path is on, such as node-b"),
|
||||
"url": str("an http or https address of a document or a page"),
|
||||
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
|
||||
"copies": num("1 to 99 (default 1)"),
|
||||
|
||||
@@ -813,7 +813,7 @@ func (m *MemoryCarrier) unwritten(key, project, path string, err error) {
|
||||
}
|
||||
|
||||
// OlderBeside is where the older of two versions too large to share one file is kept: beside the file, named for
|
||||
// the node it was changed on ("MEMORY.md" from shanks: "MEMORY.older-from-shanks.md").
|
||||
// the node it was changed on ("MEMORY.md" from node-d: "MEMORY.older-from-node-d.md").
|
||||
func OlderBeside(path, node string) string {
|
||||
ext := filepath.Ext(path)
|
||||
return strings.TrimSuffix(path, ext) + ".older-from-" + node + ext
|
||||
@@ -900,7 +900,7 @@ func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uin
|
||||
}
|
||||
// Only noted here, never applied: the runtime hands the watch's values over one at a time and gives the whole
|
||||
// current set ten seconds, so a watch whose handler reads the state, waits for a look or writes a file never
|
||||
// starts on a node with a few hundred memory files (issue 367, seen on shanks on 2026-10-13). Drain applies
|
||||
// starts on a node with a few hundred memory files (issue 367, seen on node-d on 2026-10-13). Drain applies
|
||||
// what was noted, the newest revision of each key.
|
||||
m.queueMu.Lock()
|
||||
if m.queue == nil {
|
||||
|
||||
@@ -117,7 +117,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
||||
Input: map[string]any{
|
||||
"scope": str("mesh (every node) or node (the nodes given, or this one)"),
|
||||
"nodes": str("for scope node: a comma-separated list of nodes; absent is this node"),
|
||||
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-g14"),
|
||||
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-node-b"),
|
||||
"text": str("the section's text, markdown, as every session will read it"),
|
||||
"remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"},
|
||||
},
|
||||
@@ -173,7 +173,7 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
||||
}, "required": []string{"label", "means"}}},
|
||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@node-b"),
|
||||
"expires_minutes": map[string]any{"type": "integer",
|
||||
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
|
||||
},
|
||||
|
||||
@@ -351,7 +351,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
by = who
|
||||
}
|
||||
if by == "" {
|
||||
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
|
||||
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@node-b")
|
||||
}
|
||||
life := in.Expires
|
||||
if life == 0 {
|
||||
|
||||
@@ -32,7 +32,7 @@ func archiveOf(t *testing.T, h tar.Header, body string) string {
|
||||
// Issue 348: route-proxy asked 10.10.0.1 alone while the machine listed both mesh resolvers; its copy
|
||||
// of the file could not be read by anything on the mesh.
|
||||
func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachines(t *testing.T) {
|
||||
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch novox.internal\noptions timeout:1 attempts:2\n"
|
||||
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch node-c.internal\noptions timeout:1 attempts:2\n"
|
||||
f := (&fake{}).on("docker cp route-proxy:/etc/resolv.conf -",
|
||||
Ran{Stdout: archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, inside)})
|
||||
c := client(f, 1000)
|
||||
@@ -47,7 +47,7 @@ func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachine
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := got["resolv_conf"].(ResolvConf)
|
||||
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "novox.internal" ||
|
||||
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "node-c.internal" ||
|
||||
strings.Join(rc.Options, ",") != "timeout:1,attempts:2" || rc.Raw != inside {
|
||||
t.Fatalf("parsed: %+v", rc)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const (
|
||||
// the operator's own addresses. fail2ban keeps a file entry in its own form, not as written: it reads
|
||||
// `file:/var/lib/…` and prints it back as `file://var/lib/…` (server/ipdns.py, FileIPAddrSet.__repr__, in 1.1),
|
||||
// in `get <jail> ignoreip` and in its `Ignore … by …` lines alike. So the path is compared, with fail2ban's own
|
||||
// reading of the slashes after `file:`, never the text (seen on ace on 2026-10-13: every jail held the list
|
||||
// reading of the slashes after `file:`, never the text (seen on node-a on 2026-10-13: every jail held the list
|
||||
// and the watch said none did).
|
||||
func isOurList(word string) bool {
|
||||
path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:")
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// asPrinted is the list's entry as fail2ban 1.1 prints it back, in `get <jail> ignoreip` and in its log: it reads
|
||||
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as ace's daemon printed it on
|
||||
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as node-a's daemon printed it on
|
||||
// 2026-10-13.
|
||||
const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
MESH_LAB_FORGE=${config:forge}
|
||||
MESH_LAB_FORGE_OWNER=${config:forge-owner}
|
||||
MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json
|
||||
MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
"description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"forge-owner": {
|
||||
"type": "string",
|
||||
"description": "The owner on the forge the mesh's repositories and npm packages are under, such as the organisation that holds them: the operator's, with no default (novox/hq ADR 0507 §9).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
|
||||
@@ -60,8 +60,8 @@ test("no run starts while the base image is rebuilt", () => {
|
||||
});
|
||||
|
||||
test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => {
|
||||
const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials");
|
||||
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/);
|
||||
const s = baseScript("/work/base-x", "https://forge.invalid", "mesh-org", "/work/.base-x.git-credentials");
|
||||
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/mesh-org\/mesh-lab.git'/);
|
||||
assert.match(s, /src\/cli.ts base build/);
|
||||
assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// The forge's owner differs between meshes (novox/hq ADR 0507 §9, issue 1838): the lab takes it as a config value
|
||||
// the operator gives, with no default, and no file of the lab names it.
|
||||
// 1838/AC4
|
||||
|
||||
const here = join(import.meta.dirname, "..");
|
||||
|
||||
test("the forge's owner is a required config value with no default, and reaches the lab's env-file", () => {
|
||||
const manifest = JSON.parse(readFileSync(join(here, "module.json"), "utf8")) as { config: Record<string, Record<string, unknown>> };
|
||||
const owner = manifest.config["forge-owner"];
|
||||
assert.ok(owner, "the manifest declares no forge-owner");
|
||||
assert.equal(owner.required, true);
|
||||
assert.ok(!("default" in owner), `forge-owner has the default ${JSON.stringify(owner.default)}`);
|
||||
assert.match(readFileSync(join(here, "files", "lab.env"), "utf8"), /^MESH_LAB_FORGE_OWNER=\$\{config:forge-owner\}$/m);
|
||||
});
|
||||
|
||||
test("no file of the lab's code names an owner on the forge", () => {
|
||||
for (const f of readdirSync(join(here, "tools")).filter((f) => f.endsWith(".ts"))) {
|
||||
const src = readFileSync(join(here, "tools", f), "utf8");
|
||||
assert.doesNotMatch(src, /\}\/[a-z][\w.-]*\/\$\{repo\}|\}\/[a-z][\w.-]*\/mesh-lab\.git|\/api\/packages\/[a-z][\w.-]*\/npm\//, f);
|
||||
}
|
||||
});
|
||||
@@ -92,8 +92,8 @@ test("a run fails at its setup, naming the checkout, when nothing of it can be i
|
||||
// checkouts are.
|
||||
test("a run checks out mesh-tools-go and says where its checkouts are", () => {
|
||||
assert.ok(REPOSITORIES.includes("mesh-tools-go"));
|
||||
const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
|
||||
assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
|
||||
const s = script("/work", "run-x", "http://forge", "mesh-org", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
|
||||
assert.match(s, /mesh-org\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
|
||||
assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/);
|
||||
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
|
||||
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
|
||||
@@ -113,22 +113,22 @@ function aBinding(as: string, secret: string): { binding: string; secret: string
|
||||
}
|
||||
|
||||
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
|
||||
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
|
||||
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000", b.binding, b.secret),
|
||||
"http://lab-node-d:s3cret-of-forty-characters@node-c.internal:20000");
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000/", b.binding, ""), undefined);
|
||||
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000", "/nowhere", b.secret), undefined);
|
||||
});
|
||||
|
||||
test("a run clones through git's credential store, and its script carries no secret", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
|
||||
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://node-c.internal:20000", b.binding, b.secret);
|
||||
const path = credentialPath("/work", "run-x");
|
||||
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
|
||||
const s = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, path);
|
||||
assert.ok(credential);
|
||||
assert.doesNotMatch(s, /s3cret/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/node-c\.internal:20000\/mesh-org\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
|
||||
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
|
||||
assert.equal(credentialArgs(undefined), "");
|
||||
@@ -139,10 +139,10 @@ test("a run clones through git's credential store, and its script carries no sec
|
||||
test("git presents the stored credential to the forge, and only when one is given", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
|
||||
const store = join(dir, "git-credentials");
|
||||
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
writeFileSync(store, "http://lab-node-d:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
assert.match(fill.stdout, /username=lab-shanks/);
|
||||
assert.match(fill.stdout, /username=lab-node-d/);
|
||||
assert.match(fill.stdout, /password=pw/);
|
||||
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
@@ -168,7 +168,7 @@ test("a stopped script removes the credential, whatever ends it", () => {
|
||||
});
|
||||
|
||||
test("the script removes the credential as soon as its clones are done", () => {
|
||||
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const s = script("/work", "run-x", "http://forge", "mesh-org", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const lastClone = s.lastIndexOf("clone --quiet");
|
||||
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
|
||||
const building = s.indexOf("npm ci");
|
||||
@@ -252,9 +252,9 @@ test("the lab's tools bundle names its bound credential and binding in its env",
|
||||
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
|
||||
});
|
||||
|
||||
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
|
||||
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on node-d): npm 12 refused
|
||||
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
|
||||
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
|
||||
// address (http://node-c.internal:20000/…), the lock file holds the address the forge writes every tarball
|
||||
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
|
||||
// A run names the registry by the address the registry names itself by.
|
||||
import { npmRegistry } from "../tools/runs.ts";
|
||||
@@ -270,54 +270,54 @@ function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fe
|
||||
|
||||
const packument = (tarball: string) => new Response(JSON.stringify({
|
||||
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
|
||||
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
||||
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/mesh-org/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
|
||||
test("a run names the package registry by the address the registry gives its tarballs", async () => {
|
||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
|
||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
|
||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", "mesh-org", r.get, "https://forge.example.org/api/packages/mesh-org/npm/");
|
||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/mesh-org/npm/" });
|
||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/mesh-org/npm/@novox%2fmesh-sdk"]);
|
||||
// The forge's own scheme is taken too, with its port.
|
||||
const plain = await npmRegistry("http://forge.example.org:3000",
|
||||
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
||||
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
|
||||
const plain = await npmRegistry("http://forge.example.org:3000", "mesh-org",
|
||||
aRegistry(() => packument("http://forge.example.org:3000/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
||||
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/mesh-org/npm/" });
|
||||
});
|
||||
|
||||
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
|
||||
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
|
||||
// it (review of mesh-catalog #275, ADR 0346).
|
||||
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
for (const [answer, why] of [
|
||||
[() => new Response("not found", { status: 404 }), /it answered 404/],
|
||||
[() => new Response("{}", { status: 200 }), /no tarball/],
|
||||
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
|
||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
|
||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/mesh-org\/npm\//],
|
||||
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
|
||||
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
|
||||
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
|
||||
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
|
||||
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
|
||||
[() => packument(`https://attacker.example.net/x?u=https://node-c.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`https://node-c.internal${sdk}?x=1`), /query or a fragment/],
|
||||
[() => packument(`https://node-c.internal${sdk}#frag`), /query or a fragment/],
|
||||
[() => packument(`https://attacker.example.net/#https://node-c.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`ftp://node-c.internal${sdk}`), /at ftp:\/\/node-c\.internal, which is neither/],
|
||||
[() => packument(`file:///api/packages/mesh-org/npm/x.tgz`), /which is neither/],
|
||||
[() => packument(`https://node-c.internal/api/packages/mesh-org/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||
[() => packument(`https://user:pw@node-c.internal${sdk}`), /user or password/],
|
||||
] as [() => Response, RegExp][]) {
|
||||
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
|
||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
|
||||
const got = await npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(answer).get);
|
||||
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", String(why));
|
||||
assert.match(got.problem ?? "", /did not give an address the lab takes/);
|
||||
assert.match(got.problem ?? "", why);
|
||||
}
|
||||
});
|
||||
|
||||
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
|
||||
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
|
||||
"https://forge.example.org/api/packages/novox/npm/");
|
||||
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
||||
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
|
||||
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
|
||||
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
|
||||
const given = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, undefined,
|
||||
"https://forge.example.org/api/packages/mesh-org/npm/");
|
||||
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/mesh-org\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
||||
assert.doesNotMatch(given, /@novox:registry=http:\/\/node-c\.internal/);
|
||||
const none = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {});
|
||||
assert.match(none, /'@novox:registry=http:\/\/node-c\.internal:20000\/api\/packages\/mesh-org\/npm\/'/);
|
||||
});
|
||||
|
||||
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
|
||||
@@ -325,20 +325,20 @@ test("a run's npm configuration names the registry it was given, and the forge's
|
||||
import { publicRootOf } from "../tools/runs.ts";
|
||||
|
||||
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const root = "https://git.example.org/api/packages/mesh-org/npm/";
|
||||
const at = (tarball: string, publicRoot?: string) =>
|
||||
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||
npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
|
||||
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
|
||||
const got = await at(tarball, root);
|
||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
|
||||
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", tarball);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
|
||||
}
|
||||
// Without a public root, the forge's public name is not trusted on its own word.
|
||||
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
|
||||
// And the forge's own address only at its own port.
|
||||
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||
assert.match((await at(`http://node-c.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
|
||||
@@ -346,39 +346,39 @@ test("the public root is read from the binding, and only an https root at the np
|
||||
const binding = (root: unknown) => {
|
||||
const f = join(dir, `b-${Math.random()}.json`);
|
||||
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
|
||||
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||
"npm-path": "/api/packages/mesh-org/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||
return f;
|
||||
};
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/mesh-org/npm/"), "mesh-org"),
|
||||
{ root: "https://git.example.org/api/packages/mesh-org/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/mesh-org/npm/"), "mesh-org"),
|
||||
{ root: "https://git.example.org:8443/api/packages/mesh-org/npm/" });
|
||||
for (const [root, why] of [
|
||||
[undefined, /serves no public-root/],
|
||||
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["http://git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
|
||||
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
|
||||
["https://git.example.org/api/packages/mesh-org/npm/?x=1", /is not https:\/\/<host>/],
|
||||
["https://u:p@git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/mesh-org/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||
["https://${bound:route:name-web}/api/packages/mesh-org/npm/", /not a URL|is not https/],
|
||||
] as [unknown, RegExp][]) {
|
||||
const got = publicRootOf(binding(root));
|
||||
const got = publicRootOf(binding(root), "mesh-org");
|
||||
assert.equal(got.root, undefined, String(root));
|
||||
assert.match(got.problem ?? "", why, String(root));
|
||||
}
|
||||
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
|
||||
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
|
||||
assert.match(publicRootOf(join(dir, "none.json"), "mesh-org").problem ?? "", /could not be read/);
|
||||
assert.match(publicRootOf(undefined, "mesh-org").problem ?? "", /no npm-package-registry binding/);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
|
||||
// is "null", as is a `file:` forge's, and the two must not match.
|
||||
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
|
||||
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
|
||||
const got = await npmRegistry("file:///srv/forge", "mesh-org", aRegistry(() => packument("file:///api/packages/mesh-org/npm/x.tgz")).get);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
|
||||
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
|
||||
const root = "https://git.example.org/api/packages/mesh-org/npm/";
|
||||
const viaRoot = await npmRegistry("ftp://node-c.internal", "mesh-org", aRegistry(() =>
|
||||
packument("ftp://node-c.internal/api/packages/mesh-org/npm/x.tgz")).get, root);
|
||||
assert.match(viaRoot.problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
@@ -401,7 +401,7 @@ test("every file takes its text from a template in files/", () => {
|
||||
});
|
||||
|
||||
// Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0,
|
||||
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it.
|
||||
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on node-d wrote it.
|
||||
const oldLog = [
|
||||
"STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.",
|
||||
"\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:",
|
||||
|
||||
+23
-13
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
checkDir, repositoryURL, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun,
|
||||
launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem,
|
||||
} from "./runs.js";
|
||||
@@ -19,13 +19,17 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||
// The owner on the forge the mesh's repositories and packages are under, the operator's too (novox/hq ADR 0507
|
||||
// §9), read the same way.
|
||||
const ownerOf = (): string => (env.MESH_LAB_FORGE_OWNER ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_OWNER")).trim();
|
||||
const noOwner = "the lab's forge owner is not set: config for lab, {\"forge-owner\": \"<owner>\"}";
|
||||
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
|
||||
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
|
||||
const bindingOf = (): string | undefined =>
|
||||
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
|
||||
const registryOf = async (forge: string) => {
|
||||
const root = publicRootOf(bindingOf());
|
||||
const registry = await npmRegistry(forge, fetch, root.root);
|
||||
const registryOf = async (forge: string, owner: string) => {
|
||||
const root = publicRootOf(bindingOf(), owner);
|
||||
const registry = await npmRegistry(forge, owner, fetch, root.root);
|
||||
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
|
||||
};
|
||||
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
|
||||
@@ -39,6 +43,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
run: async () => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { ok: false, output: noOwner };
|
||||
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
|
||||
// directory of its own either way, and removes it when it is done.
|
||||
const leftovers = clearChecks(work);
|
||||
@@ -46,13 +52,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
let check: ReturnType<typeof spawnSync>;
|
||||
let problems: string[];
|
||||
try {
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", repositoryURL(forge, owner, "mesh-lab"), dir], { encoding: "utf8" });
|
||||
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
|
||||
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
||||
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
||||
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
|
||||
problems = setupProblems(forge, owner, `${dir}/setup`, credentialOf(forge));
|
||||
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
|
||||
const registry = await registryOf(forge);
|
||||
const registry = await registryOf(forge, owner);
|
||||
if (registry.problem) problems.push(registry.problem);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -91,6 +97,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
run: async (args) => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { started: false, reason: noOwner };
|
||||
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
|
||||
let refs: Record<string, string> = {};
|
||||
@@ -105,8 +113,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
||||
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
|
||||
// review 256): the registry asked first, then the check and the start with nothing awaited between.
|
||||
const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() },
|
||||
(registry) => start(work, forge, tests, refs, credentialOf(forge), registry));
|
||||
const launched = await launchRun(work, { registry: () => registryOf(forge, owner), base: () => readBaseImage() },
|
||||
(registry) => start(work, forge, owner, tests, refs, credentialOf(forge), registry));
|
||||
if (!launched.started) return launched;
|
||||
return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry,
|
||||
...(launched.problem ? { problem: launched.problem } : {}) };
|
||||
@@ -134,10 +142,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
if (args.rebuild !== true) return reading;
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { ...reading, started: false, reason: noOwner };
|
||||
// Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with
|
||||
// nothing awaited between (review 256).
|
||||
const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before },
|
||||
(registry) => startBaseBuild(work, forge, credentialOf(forge), registry));
|
||||
const launched = await launchRebuild(work, { registry: () => registryOf(forge, owner), base: () => before },
|
||||
(registry) => startBaseBuild(work, forge, owner, credentialOf(forge), registry));
|
||||
if (launched.started === false) return { ...reading, started: false, reason: launched.reason };
|
||||
return { ...reading, started: true, build: launched.job };
|
||||
},
|
||||
@@ -174,7 +184,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
|
||||
* would go through.
|
||||
*/
|
||||
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
|
||||
export function setupProblems(forge: string, owner: string, dir: string, credential?: string): string[] {
|
||||
const problems: string[] = [];
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
|
||||
@@ -186,7 +196,7 @@ export function setupProblems(forge: string, dir: string, credential?: string):
|
||||
});
|
||||
try {
|
||||
for (const repo of REPOSITORIES) {
|
||||
const url = `${forge}/novox/${repo}.git`;
|
||||
const url = repositoryURL(forge, owner, repo);
|
||||
const remote = git(["ls-remote", "--heads", url, "main"]);
|
||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||
const said = remote.stderr.trim().split("\n")[0] ?? "";
|
||||
|
||||
+22
-13
@@ -60,8 +60,11 @@ export function installStep(root: string, build: boolean): string {
|
||||
fi`;
|
||||
}
|
||||
|
||||
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
|
||||
export const NPM_PATH = "/api/packages/novox/npm/";
|
||||
/**
|
||||
* Where the forge serves the mesh's npm packages, under its own address: gitea's npm registry for the owner the
|
||||
* mesh's repositories are under, the operator's config value (novox/hq ADR 0507 §9).
|
||||
*/
|
||||
export const npmPath = (owner: string): string => `/api/packages/${encodeURIComponent(owner)}/npm/`;
|
||||
|
||||
/**
|
||||
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
|
||||
@@ -70,7 +73,8 @@ export const NPM_PATH = "/api/packages/novox/npm/";
|
||||
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
|
||||
* machine with no public domain serves none (ADR 0346 §3).
|
||||
*/
|
||||
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
|
||||
export function publicRootOf(bindingFile: string | undefined, owner: string): { root?: string; problem?: string } {
|
||||
const NPM_PATH = npmPath(owner);
|
||||
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
|
||||
let said: unknown;
|
||||
try {
|
||||
@@ -112,8 +116,9 @@ export function publicRootOf(bindingFile: string | undefined): { root?: string;
|
||||
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
|
||||
* forge's own address and says why, so a run still starts and lab_check names it.
|
||||
*/
|
||||
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||
export async function npmRegistry(forge: string, owner: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||
Promise<{ registry: string; problem?: string }> {
|
||||
const NPM_PATH = npmPath(owner);
|
||||
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
|
||||
const url = `${asked}@novox%2fmesh-sdk`;
|
||||
const fallback = (why: string) => ({
|
||||
@@ -453,6 +458,10 @@ export function running(work: string): RunStatus | undefined {
|
||||
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
|
||||
}
|
||||
|
||||
/** A repository's clone address on the forge: under the owner the mesh's repositories are under (ADR 0507 §9). */
|
||||
export const repositoryURL = (forge: string, owner: string, repo: string): string =>
|
||||
`${forge}/${encodeURIComponent(owner)}/${repo}.git`;
|
||||
|
||||
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
/**
|
||||
@@ -461,8 +470,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
|
||||
* at this run's own tree, so what is built and claimed is exactly what was checked out.
|
||||
*/
|
||||
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||
export function script(work: string, id: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry = `${forge}${npmPath(owner)}`): string {
|
||||
const dir = runDir(work, id);
|
||||
const setState = (state: string) =>
|
||||
`node -e ${shellQuote(
|
||||
@@ -471,7 +480,7 @@ export function script(work: string, id: string, forge: string, tests: string[],
|
||||
const clones = REPOSITORIES.map((repo) => {
|
||||
const ref = refs[repo] ?? "main";
|
||||
return [
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(repositoryURL(forge, owner, repo))} ${shellQuote(join(dir, repo))}`,
|
||||
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
@@ -515,7 +524,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
|
||||
}
|
||||
|
||||
/** start begins a run and returns at once with its status. */
|
||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
export function start(work: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry?: string): RunStatus {
|
||||
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = runDir(work, id);
|
||||
@@ -525,7 +534,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
|
||||
// What a crashed or rebooted run left behind goes before this run writes its own.
|
||||
cleanCredentials(work);
|
||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, owner, tests, refs, credentials, registry), { mode: 0o700 });
|
||||
|
||||
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
|
||||
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
|
||||
@@ -715,13 +724,13 @@ export function baseBuildLog(work: string, id: string, lines = 40): string {
|
||||
* baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it,
|
||||
* then its `base build`, which replaces the image and reads back that it works before publishing it.
|
||||
*/
|
||||
export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||
export function baseScript(dir: string, forge: string, owner: string, credential?: string, registry = `${forge}${npmPath(owner)}`): string {
|
||||
const lab = join(dir, "mesh-lab");
|
||||
return `set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
${credentialGuard(credential)}
|
||||
cd ${shellQuote(dir)}
|
||||
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)}
|
||||
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(repositoryURL(forge, owner, "mesh-lab"))} ${shellQuote(lab)}
|
||||
${credential ? `rm -f ${shellQuote(credential)}` : ""}
|
||||
echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)"
|
||||
printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||
@@ -733,7 +742,7 @@ node --experimental-strip-types src/cli.ts base build
|
||||
}
|
||||
|
||||
/** startBaseBuild rebuilds the base image in the background and answers at once with the build. */
|
||||
export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild {
|
||||
export function startBaseBuild(work: string, forge: string, owner: string, credential?: string, registry?: string): BaseBuild {
|
||||
const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = join(work, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -742,7 +751,7 @@ export function startBaseBuild(work: string, forge: string, credential?: string,
|
||||
// What a crashed or rebooted run or rebuild left behind goes before this one writes its own.
|
||||
cleanCredentials(work);
|
||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 });
|
||||
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, owner, credentials, registry), { mode: 0o700 });
|
||||
const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$?
|
||||
node -e ${shellQuote(
|
||||
`const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||
|
||||
@@ -82,7 +82,7 @@ Steps 1, 2 and 3 are harmless in any order. Step 4 waits for `i3`.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
| | node-b | node-d |
|
||||
|---|---|---|
|
||||
| package | nothing until step 1 (`lemurs-git` reads as installed) | the same |
|
||||
| `/etc/lemurs/config.toml` | the found file kept once, then the module's written | the same |
|
||||
|
||||
@@ -7,10 +7,10 @@ import { BUILD_EVENTS, placeable } from "../heard.js";
|
||||
// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712.
|
||||
const walkBuild = {
|
||||
id: "build-1791997333800041716",
|
||||
repository: "http://novox.internal:20000/novox/mesh-catalog.git",
|
||||
repository: "http://node-c.internal:20000/node-c/mesh-catalog.git",
|
||||
path: "modules/mailu",
|
||||
ref: "main",
|
||||
on: "shanks",
|
||||
on: "node-d",
|
||||
trunk: "main",
|
||||
"on-trunk": true,
|
||||
branches: ["main"],
|
||||
|
||||
@@ -16,17 +16,17 @@ func effortAsk(n int, status string, docs ...Section) ProposeAsk {
|
||||
// novox/hq issue 844: an active research effort takes new versions freely, each reviewed as a proposal.
|
||||
func TestAnActiveEffortTakesANewVersionWithANewDocument(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
r, err := w.k.ReserveNumber("author@node-b", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview))
|
||||
w.accepted(t, "author@node-b", effortAsk(r.Number, StatusActive, overview))
|
||||
key := Key{KindResearch, r.Number}
|
||||
reviews := len(w.judge.reviews)
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."})
|
||||
next.Why = "findings added"
|
||||
if _, err := w.k.Propose("author@g14", next); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", next); err != nil {
|
||||
t.Fatalf("an active effort refused a new version: %v", err)
|
||||
}
|
||||
e := mustGet(t, w.k.Store, key)
|
||||
@@ -56,24 +56,24 @@ func TestAnActiveEffortTakesANewVersionWithANewDocument(t *testing.T) {
|
||||
|
||||
func TestAGraduatedEffortAndADecisionStillTakeOnlyAnInsight(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
r, err := w.k.ReserveNumber("author@node-b", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
grad := effortAsk(r.Number, StatusGraduated, overview)
|
||||
grad.Links = append(grad.Links, LinkAsk{Kind: LinkBecame, To: "0290"})
|
||||
w.accepted(t, "author@g14", grad)
|
||||
w.accepted(t, "author@node-b", grad)
|
||||
next := effortAsk(r.Number, StatusGraduated, overview, Section{Name: "01-findings.md", Text: "More."})
|
||||
next.Links = grad.Links
|
||||
if _, err := w.k.Propose("author@g14", next); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
if _, err := w.k.Propose("author@node-b", next); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
t.Fatalf("a graduated effort took a plain version: %v", err)
|
||||
}
|
||||
n := w.reserved(t, "author@g14")
|
||||
w.accepted(t, "author@g14", valid(n))
|
||||
n := w.reserved(t, "author@node-b")
|
||||
w.accepted(t, "author@node-b", valid(n))
|
||||
p := valid(n)
|
||||
p.Sections[2].Text = "Changed."
|
||||
if _, err := w.k.Propose("author@g14", p); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
if _, err := w.k.Propose("author@node-b", p); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
t.Fatalf("a decision took a plain version: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -82,34 +82,34 @@ func TestAGraduatedEffortAndADecisionStillTakeOnlyAnInsight(t *testing.T) {
|
||||
func TestANewVersionOfAnOldActiveEffortIsHeldToTodaysRules(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
w.clock = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an old effort")
|
||||
r, err := w.k.ReserveNumber("author@node-b", KindResearch, "an old effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
first := effortAsk(r.Number, StatusActive, overview)
|
||||
first.Date = "2026-10-01"
|
||||
w.accepted(t, "author@g14", first)
|
||||
w.accepted(t, "author@node-b", first)
|
||||
w.clock = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "The control plane wrote them."})
|
||||
next.Date = "2026-10-01"
|
||||
if _, err := w.k.Propose("author@g14", next); err == nil || !strings.Contains(err.Error(), "retired-word") {
|
||||
if _, err := w.k.Propose("author@node-b", next); err == nil || !strings.Contains(err.Error(), "retired-word") {
|
||||
t.Fatalf("a retired word in new text of an old effort: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChangesVerdictLeavesTheServedVersionServedAndGraduatingNeedsABecameLink(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
r, err := w.k.ReserveNumber("author@node-b", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview))
|
||||
w.accepted(t, "author@node-b", effortAsk(r.Number, StatusActive, overview))
|
||||
key := Key{KindResearch, r.Number}
|
||||
w.judge.review = `{"verdict":"changes","findings":["01-findings.md: say where the finding comes from"]}`
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."})
|
||||
if _, err := w.k.Propose("author@g14", next); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
@@ -119,7 +119,7 @@ func TestAChangesVerdictLeavesTheServedVersionServedAndGraduatingNeedsABecameLin
|
||||
t.Fatalf("after changes: pending %v, current %+v", e.Pending != nil, e.Current.Sections)
|
||||
}
|
||||
grad := effortAsk(r.Number, StatusGraduated, overview)
|
||||
if _, err := w.k.Propose("author@g14", grad); err == nil || !strings.Contains(err.Error(), "became") {
|
||||
if _, err := w.k.Propose("author@node-b", grad); err == nil || !strings.Contains(err.Error(), "became") {
|
||||
t.Fatalf("graduated without a became link: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -128,18 +128,18 @@ func TestAChangesVerdictLeavesTheServedVersionServedAndGraduatingNeedsABecameLin
|
||||
func TestAnOldActiveEffortWithAMarkedInsightTakesANewVersion(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
w.clock = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an old effort")
|
||||
r, err := w.k.ReserveNumber("author@node-b", KindResearch, "an old effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be.\n\n**Progressive insight — 2026-10-05.** A fact was corrected."}
|
||||
first := effortAsk(r.Number, StatusActive, overview)
|
||||
first.Date = "2026-10-01"
|
||||
w.accepted(t, "author@g14", first)
|
||||
w.accepted(t, "author@node-b", first)
|
||||
w.clock = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."})
|
||||
next.Date = "2026-10-01"
|
||||
if _, err := w.k.Propose("author@g14", next); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", next); err != nil {
|
||||
t.Fatalf("a new version with an earlier marked insight: %v", err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
|
||||
@@ -55,11 +55,11 @@ func whoCalled(t *testing.T, params map[string]any) string {
|
||||
// **Who called is the caller the runtime proved** (hq issue 365): read from the call's runtime-written `_meta`,
|
||||
// never from its arguments; Unproven where the runtime named none.
|
||||
func TestTheCallerIsTheOneTheRuntimeProved(t *testing.T) {
|
||||
if got := whoCalled(t, map[string]any{"name": "who", "arguments": map[string]any{"caller": "novox.node-tools"},
|
||||
"_meta": map[string]any{stdio.MetaCaller: "shanks.node-tools"}}); got != "shanks.node-tools" {
|
||||
if got := whoCalled(t, map[string]any{"name": "who", "arguments": map[string]any{"caller": "node-c.node-tools"},
|
||||
"_meta": map[string]any{stdio.MetaCaller: "node-d.node-tools"}}); got != "node-d.node-tools" {
|
||||
t.Errorf("callerOf answered %q, want the runtime's caller", got)
|
||||
}
|
||||
if got := whoCalled(t, map[string]any{"name": "who", "arguments": map[string]any{"caller": "novox.node-tools"}}); got != Unproven {
|
||||
if got := whoCalled(t, map[string]any{"name": "who", "arguments": map[string]any{"caller": "node-c.node-tools"}}); got != Unproven {
|
||||
t.Errorf("a call the runtime named no caller for answered %q, want %s", got, Unproven)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ func TestAnInsightToAnOlderRecordIsJudgedByTheRulesThatHeldIt(t *testing.T) {
|
||||
}
|
||||
p := insightTo(t, w, Key{KindDecision, 11})
|
||||
p.Sections[0].Text += "\n\nThe control plane wrote them."
|
||||
if _, err := w.k.Propose("corrector@ace", p); err != nil {
|
||||
if _, err := w.k.Propose("corrector@node-a", p); err != nil {
|
||||
t.Fatalf("an insight to ADR 0011 was refused by rules that never held it: %v", err)
|
||||
}
|
||||
// The same words in a record of today are refused.
|
||||
n := w.reserved(t, "author@g14")
|
||||
n := w.reserved(t, "author@node-b")
|
||||
q := valid(n)
|
||||
q.Sections[2].Text = "The control plane wrote them."
|
||||
if _, err := w.k.Propose("author@g14", q); err == nil || !strings.Contains(err.Error(), "- retired-word:") {
|
||||
if _, err := w.k.Propose("author@node-b", q); err == nil || !strings.Contains(err.Error(), "- retired-word:") {
|
||||
t.Fatalf("a retired word in a record of today: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,11 @@ func TestEachDatedRuleHoldsFromItsDay(t *testing.T) {
|
||||
// A new record cannot be dated back past the rules: it is of the day its number was reserved, or later.
|
||||
func TestANewRecordIsNotDatedBeforeItsReservation(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := w.reserved(t, "author@g14")
|
||||
n := w.reserved(t, "author@node-b")
|
||||
p := valid(n)
|
||||
p.Date = "2026-01-01"
|
||||
p.Sections[2].Text = "The control plane wrote them."
|
||||
_, err := w.k.Propose("author@g14", p)
|
||||
_, err := w.k.Propose("author@node-b", p)
|
||||
if err == nil || !strings.Contains(err.Error(), "- date:") {
|
||||
t.Fatalf("a new record dated before its reservation: %v", err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestAnInsightToADecisionOfOctoberKeepsItsWords(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []int{276, 240, 244, 272} {
|
||||
if _, err := w.k.Propose("corrector@ace", insightTo(t, w, Key{KindDecision, n})); err != nil {
|
||||
if _, err := w.k.Propose("corrector@node-a", insightTo(t, w, Key{KindDecision, n})); err != nil {
|
||||
t.Errorf("an insight to ADR %04d was refused: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +178,10 @@ func TestTheOperatorIsToldOncePerTopicAndReturnDraftsAgain(t *testing.T) {
|
||||
}
|
||||
told := len(w.told)
|
||||
k := Key{KindDecision, 240}
|
||||
if _, err := w.k.Return("operator@shanks", Key{KindDecision, 276}, "x"); err == nil {
|
||||
if _, err := w.k.Return("operator@node-d", Key{KindDecision, 276}, "x"); err == nil {
|
||||
t.Fatal("a record whose author wrote its clauses was returned")
|
||||
}
|
||||
if _, err := w.k.Return("operator@shanks", k, "clause 2 says more than the section"); err != nil {
|
||||
if _, err := w.k.Return("operator@node-d", k, "clause 2 says more than the section"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := mustGet(t, w.k.Store, k).Current
|
||||
|
||||
@@ -258,7 +258,7 @@ func TestARefreshRepairsOnlyRecordsNobodyChangedSinceTheImportAndChangesNothingT
|
||||
e85 := mustGet(t, s, Key{KindDecision, 85})
|
||||
d := e85.Current.Clone()
|
||||
d.Title = "A secret is a provision, corrected"
|
||||
if err := s.Save(ctx, Version{By: "someone@g14", At: at, Why: "a correction by hand", Record: d}, true, false, e85.Revision); err != nil {
|
||||
if err := s.Save(ctx, Version{By: "someone@node-b", At: at, Why: "a correction by hand", Record: d}, true, false, e85.Revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := countVersions(t, s)
|
||||
@@ -381,7 +381,7 @@ func TestARefreshRepairsARecordTheKnowledgeBaseDraftedAndKeepsItsClauses(t *test
|
||||
t.Fatalf("0004 after the refresh: %+v %+v", e4.Clauses, e4.Drafting)
|
||||
}
|
||||
// A record someone returned is someone's change: left alone.
|
||||
if _, err := w.k.Return("operator@shanks", Key{KindDecision, 2}, "clause 2 says more than the record"); err != nil {
|
||||
if _, err := w.k.Return("operator@node-d", Key{KindDecision, 2}, "clause 2 says more than the record"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _ := w.k.Import("import@test", ImportAsk{Refresh: true}, at487())
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func adr0343() *Record {
|
||||
r := &Record{Kind: KindDecision, Number: 343, Status: StatusProposed, Date: "2026-10-13", NeedsOperator: true,
|
||||
Title: "A session's memory follows the operator: the agent module carries each project's memory folder to every node, and a file changed on two keeps both",
|
||||
Author: "fix-367@g14",
|
||||
Author: "fix-367@node-b",
|
||||
Brief: &Brief{Headline: "Your agents' memory follows you to every machine",
|
||||
Summary: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
|
||||
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory " +
|
||||
@@ -62,7 +62,7 @@ func TestADecisionAskReadsAtAGlance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
details := strings.Split(a.Details, "\n")
|
||||
if len(details) != 2 || !strings.Contains(details[0], "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it.") ||
|
||||
if len(details) != 2 || !strings.Contains(details[0], "ADR 0343, proposed by fix-367@node-b; the knowledge base's own review approved it.") ||
|
||||
!strings.Contains(details[0], "Fingerprint "+fingerprint(r.Digest())+".") ||
|
||||
details[1] != "Read its 13 clauses with the knowledge base's get, number 343." {
|
||||
t.Errorf("the details:\n%s", a.Details)
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestTheHighestToolAnswersTheHighestNumberHeldPerKind(t *testing.T) {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
// A number reserved, and later withdrawn, is spent: it is the highest.
|
||||
w.reserved(t, "author@g14") // 300
|
||||
w.reserved(t, "author@node-b") // 300
|
||||
w.clock = w.clock.Add(DefaultReservedFor + time.Hour)
|
||||
if _, err := w.k.WithdrawStale(); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -12,14 +12,14 @@ import (
|
||||
func TestThreeVersionsAreKeptAndTheFirstAndThirdCompareExactly(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := w.reserved(t, "author@g14") // version 1: the reservation
|
||||
n := w.reserved(t, "author@node-b") // version 1: the reservation
|
||||
p := valid(n)
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil { // version 2
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil { // version 2
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Clauses[1] = "**A batch is delivered by one walk.** At most one commit per repository."
|
||||
p.Why = "the bound named"
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil { // version 3
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil { // version 3
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := w.k.History(Key{KindDecision, n}, 0, 0, 0)
|
||||
@@ -27,7 +27,7 @@ func TestThreeVersionsAreKeptAndTheFirstAndThirdCompareExactly(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(h.Versions) != 3 || h.Versions[0].Status != StatusReserved || h.Versions[1].Why != "proposed" ||
|
||||
h.Versions[2].Why != "the bound named" || !h.Versions[2].Served || h.Versions[0].By != "author@g14" {
|
||||
h.Versions[2].Why != "the bound named" || !h.Versions[2].Served || h.Versions[0].By != "author@node-b" {
|
||||
t.Fatalf("history: %+v", h.Versions)
|
||||
}
|
||||
d, err := w.k.History(Key{KindDecision, n}, 1, 3, 0)
|
||||
@@ -114,15 +114,15 @@ func TestDiffIsExactOnSmallTexts(t *testing.T) {
|
||||
// history is bounded like the other answers: its work, and its answer, with a cut notice (the review of #206).
|
||||
func TestHistoryIsBounded(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := w.reserved(t, "author@g14")
|
||||
n := w.reserved(t, "author@node-b")
|
||||
p := valid(n)
|
||||
big := strings.Repeat("A line of reasoning that is long enough to count.\n", 3000)
|
||||
p.Sections[0].Text = big
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Sections[0].Text = strings.ReplaceAll(big, "long enough", "longer than")
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := w.k.History(Key{KindDecision, n}, 2, 3, 0)
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestARecordWhoseClausesAreLinkedByNumberIsNeverReturned(t *testing.T) {
|
||||
}
|
||||
w.reviewAll(t)
|
||||
before := mustGet(t, s, k)
|
||||
_, err := w.k.Return("operator@shanks", k, "clause 1 says more than the section")
|
||||
_, err := w.k.Return("operator@node-d", k, "clause 1 says more than the section")
|
||||
if err == nil {
|
||||
t.Fatal("a record a proposed record links by clause was returned")
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestARecordWhoseClausesAreLinkedByNumberIsNeverReturned(t *testing.T) {
|
||||
p2 := valid(n2)
|
||||
p2.Links = append(p2.Links, LinkAsk{Kind: LinkChanges, To: "0240 §1", From: 2})
|
||||
w2.accepted(t, "author@test", p2)
|
||||
if _, err := w2.k.Return("operator@shanks", k2, "again"); err == nil || !strings.Contains(err.Error(), "§1 is changed by ADR "+itoa4(n2)+" §2") {
|
||||
if _, err := w2.k.Return("operator@node-d", k2, "again"); err == nil || !strings.Contains(err.Error(), "§1 is changed by ADR "+itoa4(n2)+" §2") {
|
||||
t.Fatalf("a record with a changed clause was returned, or the refusal does not name the change: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,15 +39,15 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||
return nil, errors.New("claude_code_judge takes question, schema and model; not within")
|
||||
}
|
||||
if _, long := b["within"]; long && a.pending > 0 {
|
||||
return json.RawMessage(`{"node":"novox","ticket":"t1","pending":true}`), nil
|
||||
return json.RawMessage(`{"node":"node-c","ticket":"t1","pending":true}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"answer":{"verdict":"approve","findings":[]},"model":"sonnet","node":"novox"}`), nil
|
||||
return json.RawMessage(`{"answer":{"verdict":"approve","findings":[]},"model":"sonnet","node":"node-c"}`), nil
|
||||
}
|
||||
if key == "claude-code.claude_code_judged@novox" && b["ticket"] == "t1" {
|
||||
if key == "claude-code.claude_code_judged@node-c" && b["ticket"] == "t1" {
|
||||
if a.pending--; a.pending > 0 {
|
||||
return json.RawMessage(`{"node":"novox","ticket":"t1","pending":true}`), nil
|
||||
return json.RawMessage(`{"node":"node-c","ticket":"t1","pending":true}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"answer":{"verdict":"approve","findings":[]},"model":"sonnet","node":"novox","ticket":"t1"}`), nil
|
||||
return json.RawMessage(`{"answer":{"verdict":"approve","findings":[]},"model":"sonnet","node":"node-c","ticket":"t1"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("nothing answers %s", key)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func withinOf(t *testing.T, b map[string]any) time.Duration {
|
||||
func TestALongRecordsReviewGetsTheLargerBound(t *testing.T) {
|
||||
short, long := ReviewQuestion(aRecord(2, 200), nil), ReviewQuestion(aRecord(12, 2000), nil)
|
||||
a := &asked{}
|
||||
j := judgementOver(a.ask, "novox")
|
||||
j := judgementOver(a.ask, "node-c")
|
||||
for _, q := range []string{short, long} {
|
||||
if _, _, err := j(q, reviewSchema, "sonnet"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -88,14 +88,14 @@ func TestALongRecordsReviewGetsTheLargerBound(t *testing.T) {
|
||||
// judgement's.
|
||||
func TestAPendingJudgementIsCollectedByItsTicket(t *testing.T) {
|
||||
a := &asked{pending: 3}
|
||||
answer, by, err := judgementOver(a.ask, "novox")(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet")
|
||||
answer, by, err := judgementOver(a.ask, "node-c")(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(answer) != `{"verdict":"approve","findings":[]}` || by != "the agent on novox (sonnet)" {
|
||||
if string(answer) != `{"verdict":"approve","findings":[]}` || by != "the agent on node-c (sonnet)" {
|
||||
t.Fatalf("%s by %s", answer, by)
|
||||
}
|
||||
if strings.Join(a.keys, " ") != "claude-code.claude_code_judge@novox claude-code.claude_code_judged@novox claude-code.claude_code_judged@novox claude-code.claude_code_judged@novox" {
|
||||
if strings.Join(a.keys, " ") != "claude-code.claude_code_judge@node-c claude-code.claude_code_judged@node-c claude-code.claude_code_judged@node-c claude-code.claude_code_judged@node-c" {
|
||||
t.Fatalf("asked %q", a.keys)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestAPendingJudgementIsCollectedByItsTicket(t *testing.T) {
|
||||
// it fits the old bound, and fails as before when it does not.
|
||||
func TestAnAgentModuleThatTakesNoWithinIsAskedWithoutIt(t *testing.T) {
|
||||
a := &asked{refuse: true}
|
||||
if _, _, err := judgementOver(a.ask, "novox")(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet"); err != nil {
|
||||
if _, _, err := judgementOver(a.ask, "node-c")(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.bodies) != 2 || withinOf(t, a.bodies[1]) != 0 {
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
|
||||
m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] == "" || !reflect.DeepEqual(m.Build.Artifacts[0].Loads, []string{Module}) {
|
||||
t.Fatalf("build %+v", m.Build)
|
||||
}
|
||||
for _, never := range []string{"/home/", "jochen", "shanks", "novox.be"} {
|
||||
for _, never := range []string{"/home/", "jochen", "node-d", "node-c.be"} {
|
||||
if strings.Contains(strings.ToLower(raw), never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
func TestAnAskThatCannotBeMadeIsTriedABoundedNumberOfTimes(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
w.k.Asks = nil
|
||||
n := proposed(t, w, "author@g14", true)
|
||||
n := proposed(t, w, "author@node-b", true)
|
||||
for i := 0; i < 6*48; i++ { // two days, every ten minutes
|
||||
w.reviewAll(t)
|
||||
w.clock = w.clock.Add(10 * time.Minute)
|
||||
@@ -45,8 +45,8 @@ func (f *failingSaves) Save(ctx context.Context, v Version, current, clearPendin
|
||||
func TestARecordWhoseReviewCannotBeKeptBlocksNoOtherAndIsTriedBoundedly(t *testing.T) {
|
||||
s := &failingSaves{memStore: newMemStore()}
|
||||
w := imported(t, s)
|
||||
a := proposed(t, w, "author@g14", false)
|
||||
b := proposed(t, w, "author@g14", false)
|
||||
a := proposed(t, w, "author@node-b", false)
|
||||
b := proposed(t, w, "author@node-b", false)
|
||||
s.key, s.fail = Key{KindDecision, a}, true
|
||||
errs := 0
|
||||
for i := 0; i < 6*48; i++ {
|
||||
@@ -78,7 +78,7 @@ func TestARecordWhoseReviewCannotBeKeptBlocksNoOtherAndIsTriedBoundedly(t *testi
|
||||
// sent is sent again at the next step, since health no longer stands for it.
|
||||
func TestARecordThatCouldNotBeReviewedLeavesTheModuleHealthyAndIsTold(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
w.judge.reviewFail = true
|
||||
notify := w.k.Notify
|
||||
failing := true
|
||||
@@ -109,18 +109,18 @@ func TestARecordThatCouldNotBeReviewedLeavesTheModuleHealthyAndIsTold(t *testing
|
||||
// A progressive insight waiting for its review is replaced only by its author, proven.
|
||||
func TestAPendingInsightIsReplacedOnlyByItsAuthor(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := w.reserved(t, "author@g14")
|
||||
w.accepted(t, "author@g14", valid(n))
|
||||
n := w.reserved(t, "author@node-b")
|
||||
w.accepted(t, "author@node-b", valid(n))
|
||||
p := valid(n)
|
||||
p.Sections[2].Text = "Merges land sooner, by about a minute."
|
||||
p.Insight = "it said only that merges land sooner"
|
||||
if _, err := w.k.Propose("corrector@ace", p); err != nil {
|
||||
if _, err := w.k.Propose("corrector@node-a", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := valid(n)
|
||||
q.Sections[2].Text = "Merges land later."
|
||||
q.Insight = "it said merges land sooner"
|
||||
for _, other := range []string{"author@g14", "other@novox", Unproven} {
|
||||
for _, other := range []string{"author@node-b", "other@node-c", Unproven} {
|
||||
if _, err := w.k.Propose(other, q); err == nil || !strings.Contains(err.Error(), "only its author") {
|
||||
t.Fatalf("a pending insight replaced by %s: %v", other, err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestAPendingInsightIsReplacedOnlyByItsAuthor(t *testing.T) {
|
||||
if e := mustGet(t, w.k.Store, Key{KindDecision, n}); e.Pending.Section("Consequences") != p.Sections[2].Text {
|
||||
t.Fatalf("the pending insight was replaced: %q", e.Pending.Section("Consequences"))
|
||||
}
|
||||
if _, err := w.k.Propose("corrector@ace", q); err != nil {
|
||||
if _, err := w.k.Propose("corrector@node-a", q); err != nil {
|
||||
t.Fatalf("its author's revision: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestAReviewOwedOutlivesARestartAndItsLastTryIsSaid(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
inZone(t)
|
||||
w := imported(t, s)
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
key := Key{KindDecision, n}
|
||||
lastWhy := func() string {
|
||||
vs, err := s.Versions(context.Background(), key)
|
||||
@@ -97,7 +97,7 @@ func TestAReviewOwedOutlivesARestartAndItsLastTryIsSaid(t *testing.T) {
|
||||
func TestProposedAgainAfterTheLastTryClearsTheNotice(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
key := Key{KindDecision, n}
|
||||
runOut := func(from int) {
|
||||
t.Helper()
|
||||
@@ -125,7 +125,7 @@ func TestProposedAgainAfterTheLastTryClearsTheNotice(t *testing.T) {
|
||||
t.Fatalf("a version written while it waits: told %v, cleared %v", w.toldKeys, w.cleared)
|
||||
}
|
||||
// Proposed again: the notice is cleared, saying so.
|
||||
if _, err := w.k.Propose("author@g14", valid(n)); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", valid(n)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.clock = w.clock.Add(time.Second)
|
||||
@@ -142,7 +142,7 @@ func TestProposedAgainAfterTheLastTryClearsTheNotice(t *testing.T) {
|
||||
// Proposed again and accepted at last: that notice is cleared too, and nothing is cleared twice, also after
|
||||
// a restart.
|
||||
w.judge.reviewFail = false
|
||||
w.accepted(t, "author@g14", valid(n))
|
||||
w.accepted(t, "author@node-b", valid(n))
|
||||
if len(w.cleared) != 2 || w.cleared[1][0] != w.toldKeys[1] || !strings.Contains(w.cleared[1][1], "proposed again") {
|
||||
t.Fatalf("accepted: cleared %v", w.cleared)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func inZone(t *testing.T) {
|
||||
func TestAClearThatFailsIsSentAgain(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
w.judge.reviewFail = true
|
||||
for try := 1; try <= MostReviewTries; try++ {
|
||||
w.reviewAll(t)
|
||||
@@ -184,7 +184,7 @@ func TestAClearThatFailsIsSentAgain(t *testing.T) {
|
||||
prefix := fmt.Sprintf("knowledge.unreviewed.decision-%d.", n)
|
||||
refused := 0
|
||||
w.k.Clear = func(key, resolved string) error { refused++; return errors.New("the channel is down") }
|
||||
if _, err := w.k.Propose("author@g14", valid(n)); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", valid(n)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
|
||||
@@ -30,11 +30,11 @@ func TestTheReviewIsShownEveryLinkKindProposeAccepts(t *testing.T) {
|
||||
// A research effort's own status is shown apart from its state in the knowledge base.
|
||||
func TestTheReviewIsShownAResearchEffortsStatusApartFromItsState(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "a vendor-neutral inference layer")
|
||||
r, err := w.k.ReserveNumber("author@node-b", KindResearch, "a vendor-neutral inference layer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.k.Propose("author@g14", ProposeAsk{Kind: KindResearch, Number: r.Number, Title: "A vendor-neutral inference layer",
|
||||
if _, err := w.k.Propose("author@node-b", ProposeAsk{Kind: KindResearch, Number: r.Number, Title: "A vendor-neutral inference layer",
|
||||
Date: "2026-10-11", Deciders: []string{"jochen"}, Status: StatusActive,
|
||||
Sections: []Section{{Name: "00-overview.md", Text: "What an inference layer the mesh controls would be."}},
|
||||
Links: []LinkAsk{{Kind: LinkTouches, To: "0290"}, {Kind: LinkTouches, To: "issue 365"}}}); err != nil {
|
||||
|
||||
@@ -31,7 +31,7 @@ func proposed(t *testing.T, w *world, author string, needsOperator bool) int {
|
||||
func TestAProposalIsReviewedByTheKnowledgeBaseItselfAndAcceptedOnApprove(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
// Proposed, waiting for its review: nothing of it holds yet.
|
||||
e := mustGet(t, s, Key{KindDecision, n})
|
||||
if e.Current.Status != StatusProposed || e.Current.Judging == nil || e.Current.Judging.State != JudgingDue || len(w.judge.reviews) != 0 {
|
||||
@@ -49,7 +49,7 @@ func TestAProposalIsReviewedByTheKnowledgeBaseItselfAndAcceptedOnApprove(t *test
|
||||
t.Errorf("the review's question lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"ADR 0131", "ADR 0011", "author@g14"} {
|
||||
for _, never := range []string{"ADR 0131", "ADR 0011", "author@node-b"} {
|
||||
if strings.Contains(q, never) {
|
||||
t.Errorf("the review's question holds %q, which the record does not link", never)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func itoaKey(n int) string { return Key{KindDecision, n}.String() }
|
||||
func TestOnChangesTheRecordStaysProposedAndItsAuthorsRevisionIsReviewedAgain(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
w.judge.review = `{"verdict":"changes","findings":["§2: name the bound","Consequences: say who waits"]}`
|
||||
w.reviewAll(t)
|
||||
e := mustGet(t, s, Key{KindDecision, n})
|
||||
@@ -108,11 +108,11 @@ func TestOnChangesTheRecordStaysProposedAndItsAuthorsRevisionIsReviewedAgain(t *
|
||||
}
|
||||
p := valid(n)
|
||||
p.Sections[2].Text = "Merges land sooner; nobody waits longer than ninety seconds."
|
||||
if _, err := w.k.Propose("reviewer@novox", p); err == nil || !strings.Contains(err.Error(), "only its author") {
|
||||
if _, err := w.k.Propose("reviewer@node-c", p); err == nil || !strings.Contains(err.Error(), "only its author") {
|
||||
t.Fatalf("a revision by someone else: %v", err)
|
||||
}
|
||||
w.judge.review = ""
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
@@ -137,7 +137,7 @@ func TestAJudgementThatCannotBeHadNeverApproves(t *testing.T) {
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
w.judge.review, w.judge.reviewFail = c.answer, c.answer == ""
|
||||
for try := 1; try <= MostReviewTries; try++ {
|
||||
if said := w.reviewAll(t); len(said) != 1 || !strings.Contains(said[0], "not reviewed") {
|
||||
@@ -174,7 +174,7 @@ func TestAJudgementThatCannotBeHadNeverApproves(t *testing.T) {
|
||||
}
|
||||
// Its author proposes it again, and that version is reviewed.
|
||||
w.judge.review, w.judge.reviewFail = "", false
|
||||
w.accepted(t, "author@g14", valid(n))
|
||||
w.accepted(t, "author@node-b", valid(n))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -182,11 +182,11 @@ func TestAJudgementThatCannotBeHadNeverApproves(t *testing.T) {
|
||||
// A record revised while its judgement was asked is not decided on the version judged: the newest is judged next.
|
||||
func TestARecordRevisedWhileItIsJudgedIsJudgedAgain(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
p := valid(n)
|
||||
p.Sections[2].Text = "Merges land sooner, and the walk is shorter."
|
||||
w.judge.during = func() {
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -224,11 +224,11 @@ func TestARecordProposedUnprovenIsNeverReviewed(t *testing.T) {
|
||||
// A record that supersedes another moves it and every clause of it to superseded.
|
||||
func TestASupersedingRecordSupersedesTheOlderOnAcceptance(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := w.reserved(t, "author@g14")
|
||||
n := w.reserved(t, "author@node-b")
|
||||
p := valid(n)
|
||||
p.Links = []LinkAsk{{Kind: LinkSupersedes, To: "0276"}}
|
||||
p.Sections[0].Text = "The merge window is replaced."
|
||||
w.accepted(t, "author@g14", p)
|
||||
w.accepted(t, "author@node-b", p)
|
||||
old := mustGet(t, w.k.Store, Key{KindDecision, 276}).Current
|
||||
if old.Status != StatusSuperseded || old.Clause(4).Status != ClauseSuperseded || old.Extra["superseded-by"] != itoaKey(n) {
|
||||
t.Fatalf("0276: %s, §4 %+v", old.Status, old.Clause(4))
|
||||
@@ -242,7 +242,7 @@ func TestASupersedingRecordSupersedesTheOlderOnAcceptance(t *testing.T) {
|
||||
func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := proposed(t, w, "author@g14", true)
|
||||
n := proposed(t, w, "author@node-b", true)
|
||||
w.reviewAll(t)
|
||||
if e := mustGet(t, s, Key{KindDecision, n}); e.Current.Status != StatusProposed || len(w.router.asked) != 1 {
|
||||
t.Fatalf("after the review: %s, asks %d", e.Current.Status, len(w.router.asked))
|
||||
@@ -293,7 +293,7 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
|
||||
func TestAnAskThatCannotBePublishedLeavesTheRecordProposed(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
w.k.Asks = nil
|
||||
n := proposed(t, w, "author@g14", true)
|
||||
n := proposed(t, w, "author@node-b", true)
|
||||
said := w.reviewAll(t)
|
||||
e := mustGet(t, w.k.Store, Key{KindDecision, n})
|
||||
if len(said) != 1 || !strings.Contains(said[0], "cannot be asked") || e.Current.Status != StatusProposed || e.Current.Ask != nil ||
|
||||
@@ -305,7 +305,7 @@ func TestAnAskThatCannotBePublishedLeavesTheRecordProposed(t *testing.T) {
|
||||
// A warrant for an ask whose record changed after it was asked does not hold: the act it bound is another version.
|
||||
func TestAWarrantForAnotherVersionDoesNotAccept(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := proposed(t, w, "author@g14", true)
|
||||
n := proposed(t, w, "author@node-b", true)
|
||||
w.reviewAll(t)
|
||||
// The record is changed behind the ask, keeping the ask: what the operator was shown is not what is held.
|
||||
ctx := context.Background()
|
||||
@@ -327,7 +327,7 @@ func TestAWarrantForAnotherVersionDoesNotAccept(t *testing.T) {
|
||||
|
||||
func TestTheOperatorsDeclineLeavesTheRecordProposed(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := proposed(t, w, "author@g14", true)
|
||||
n := proposed(t, w, "author@node-b", true)
|
||||
w.reviewAll(t)
|
||||
w.router.answer(w.router.asked[0].ID, OptionDecline, w.clock.Add(time.Minute))
|
||||
if _, err := w.k.Reconcile(); err != nil {
|
||||
@@ -343,16 +343,16 @@ func TestTheOperatorsDeclineLeavesTheRecordProposed(t *testing.T) {
|
||||
// version is served.
|
||||
func TestAnAcceptedRecordChangesOnlyByAnInsightReviewedLikeAProposal(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := w.reserved(t, "author@g14")
|
||||
w.accepted(t, "author@g14", valid(n))
|
||||
n := w.reserved(t, "author@node-b")
|
||||
w.accepted(t, "author@node-b", valid(n))
|
||||
p := valid(n)
|
||||
p.Sections[2].Text = "Merges land sooner, by about a minute."
|
||||
if _, err := w.k.Propose("author@g14", p); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
if _, err := w.k.Propose("author@node-b", p); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
t.Fatalf("an accepted record rewritten: %v", err)
|
||||
}
|
||||
p.Insight = "it said only that merges land sooner"
|
||||
w.judge.review = `{"verdict":"changes","findings":["Consequences: say what a minute is measured from"]}`
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
@@ -363,7 +363,7 @@ func TestAnAcceptedRecordChangesOnlyByAnInsightReviewedLikeAProposal(t *testing.
|
||||
}
|
||||
w.judge.review = ""
|
||||
p.Sections[2].Text = "Merges land sooner, by about a minute from the last merge."
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
@@ -394,7 +394,7 @@ func TestNoVerbTakesAReview(t *testing.T) {
|
||||
|
||||
func TestReserveAsksTheTrackerAndANumberNeverProposedIsWithdrawn(t *testing.T) {
|
||||
w := newWorld(t, newMemStore(), fixture())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "how a knowledge base is asked")
|
||||
r, err := w.k.ReserveNumber("author@node-b", KindResearch, "how a knowledge base is asked")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -402,7 +402,7 @@ func TestReserveAsksTheTrackerAndANumberNeverProposedIsWithdrawn(t *testing.T) {
|
||||
w.tracker.asked[0]["for"] != "how a knowledge base is asked" {
|
||||
t.Fatalf("%+v %v", r, w.tracker.asked)
|
||||
}
|
||||
if _, err := w.k.ReserveNumber("author@g14", "design", "x"); err == nil {
|
||||
if _, err := w.k.ReserveNumber("author@node-b", "design", "x"); err == nil {
|
||||
t.Fatal("a design number reserved")
|
||||
}
|
||||
w.clock = w.clock.Add(DefaultReservedFor + time.Hour)
|
||||
@@ -410,10 +410,10 @@ func TestReserveAsksTheTrackerAndANumberNeverProposedIsWithdrawn(t *testing.T) {
|
||||
t.Fatalf("withdrew %d (%v)", n, err)
|
||||
}
|
||||
e := mustGet(t, w.k.Store, Key{KindResearch, 50})
|
||||
if e.Current.Status != StatusWithdrawn || e.Current.ReservedBy != "author@g14" {
|
||||
if e.Current.Status != StatusWithdrawn || e.Current.ReservedBy != "author@node-b" {
|
||||
t.Fatalf("%+v", e.Current)
|
||||
}
|
||||
if _, err := w.k.Propose("author@g14", ProposeAsk{Kind: KindResearch, Number: 50, Title: "late", Status: StatusActive,
|
||||
if _, err := w.k.Propose("author@node-b", ProposeAsk{Kind: KindResearch, Number: 50, Title: "late", Status: StatusActive,
|
||||
Sections: []Section{{Name: "00-overview.md", Text: "x"}}}); err == nil {
|
||||
t.Fatal("a withdrawn number took a proposal")
|
||||
}
|
||||
@@ -423,7 +423,7 @@ func TestReserveAsksTheTrackerAndANumberNeverProposedIsWithdrawn(t *testing.T) {
|
||||
// for an answer nobody was shown (ADR 0397, issue 926); offered, it waits again; answered, it is decided as ever.
|
||||
func TestAnAskTheRouterHoldsIsShownHeldWithTheReason(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := proposed(t, w, "author@g14", true)
|
||||
n := proposed(t, w, "author@node-b", true)
|
||||
w.reviewAll(t)
|
||||
id := w.router.asked[0].ID
|
||||
key := Key{KindDecision, n}
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ would receive them whole.
|
||||
|
||||
1. **Keep the flat list; rely on the client to defer it.** Rejected: a property of one client, and it
|
||||
leaves the ambiguity and the stale list.
|
||||
2. **One flat tool per assignment** (`ace_postgres_list_databases`). Removes the ambiguity, multiplies
|
||||
2. **One flat tool per assignment** (`node-a_postgres_list_databases`). Removes the ambiguity, multiplies
|
||||
the list, and runs into the API's tool-name limit (letters, digits, `_`, `-`, 64 characters).
|
||||
3. **A small fixed set of tools that walk the mesh's own structure**, with the full address as an
|
||||
argument: the mesh's seats; a machine's node seats and assignments; a search; a description; a
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ connections lasted, and then the public name went down
|
||||
|
||||
**The same fault, four days later, undetected for five days.** One container had restarted 2286 times
|
||||
against a database it could no longer find, while the mesh reported the machine as doing what it was
|
||||
told. Inside it, `novox.internal` was an address that had not existed for five days
|
||||
told. Inside it, `node-c.internal` was an address that had not existed for five days
|
||||
([issue 135](../04-ISSUES/135-a-containers-mesh-names-are-not-compared/00-report.md)). Forty-eight
|
||||
other containers were current, none of them corrected — each had been recreated for some other
|
||||
reason and picked up the roster on the way.
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ catalogue along for a controller change; `plans stop`), 200. The first merge han
|
||||
machinery, at 18:56Z, was a controller change and produced the plan this record describes: tier 0
|
||||
the build machine; tier 1 the controller and the proxy that packages its source. The handler
|
||||
returned at once; the build machine was built, rolled, and the plan read *tier 0 built; waiting for
|
||||
builder on novox to be applied* until the machine reported; then tier 1 was asked, both built, and
|
||||
builder on node-c to be applied* until the machine reported; then tier 1 was asked, both built, and
|
||||
the plan read done — three minutes, read through the console with `plans`, the receive loop taking
|
||||
reports throughout. What the day between decision and proof taught is in issues
|
||||
[184](../04-ISSUES/184-a-merge-announcement-blocks-the-controllers-receive-loop/00-report.md),
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
func TestADecisionNothingWouldCiteIsNotAccepted(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := w.reserved(t, "author@g14")
|
||||
n := w.reserved(t, "author@node-b")
|
||||
p := valid(n)
|
||||
p.Homes = nil
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
judged := len(w.judge.reviews)
|
||||
@@ -38,7 +38,7 @@ func TestADecisionNothingWouldCiteIsNotAccepted(t *testing.T) {
|
||||
}
|
||||
// Named with its home, it is accepted, and the module stays healthy.
|
||||
p.Homes = []string{testHome}
|
||||
w.accepted(t, "author@g14", p)
|
||||
w.accepted(t, "author@node-b", p)
|
||||
if h := w.k.Health(nil); !h.Healthy {
|
||||
t.Fatalf("unhealthy after a decision with its home was accepted: %s", h.Why)
|
||||
}
|
||||
@@ -53,18 +53,18 @@ func TestADecisionNothingWouldCiteIsNotAccepted(t *testing.T) {
|
||||
func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLoud(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := w.reserved(t, "author@g14")
|
||||
n := w.reserved(t, "author@node-b")
|
||||
a := valid(n)
|
||||
a.Homes = nil
|
||||
if _, err := w.k.Propose("author@g14", a); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
m := w.reserved(t, "author@g14")
|
||||
m := w.reserved(t, "author@node-b")
|
||||
b := valid(m)
|
||||
b.Links = append(b.Links, LinkAsk{Kind: LinkCites, To: itoa4(n)})
|
||||
w.accepted(t, "author@g14", b)
|
||||
w.accepted(t, "author@g14", a) // its revision: an accepted record links it now
|
||||
w.accepted(t, "author@node-b", b)
|
||||
w.accepted(t, "author@node-b", a) // its revision: an accepted record links it now
|
||||
if h := w.k.Health(nil); !h.Healthy {
|
||||
t.Fatalf("unhealthy with both accepted, one citing the other: %s", h.Why)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLo
|
||||
// A correction of the citing record drops its link: the rule, run after the acceptance, says so.
|
||||
b.Links = b.Links[:len(b.Links)-1]
|
||||
b.Insight = "it cited " + itoaKey(n)
|
||||
w.accepted(t, "author@g14", b)
|
||||
w.accepted(t, "author@node-b", b)
|
||||
h := w.k.Health(nil)
|
||||
if !h.Healthy || !strings.Contains(h.Why, "the rule cited") || !strings.Contains(h.Why, itoaKey(n)) {
|
||||
t.Fatalf("a decision left cited by nothing: the health should stay healthy and name it, not %+v", h)
|
||||
@@ -99,10 +99,10 @@ func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLo
|
||||
t.Fatal("the acceptance was undone")
|
||||
}
|
||||
// A record that cites it heals it.
|
||||
k := w.reserved(t, "author@g14")
|
||||
k := w.reserved(t, "author@node-b")
|
||||
c := valid(k)
|
||||
c.Links = append(c.Links, LinkAsk{Kind: LinkCites, To: itoa4(n)})
|
||||
w.accepted(t, "author@g14", c)
|
||||
w.accepted(t, "author@node-b", c)
|
||||
if h := w.k.Health(nil); strings.Contains(h.Why, itoaKey(n)+" is accepted and nothing cites it") {
|
||||
t.Fatalf("still said after a record cited it: %s", h.Why)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestAReservedRecordIsWithdrawnAtOnce(t *testing.T) {
|
||||
w := imported(t, s)
|
||||
n := w.reserved(t, "author@test")
|
||||
key := Key{KindDecision, n}
|
||||
out, err := w.k.WithdrawRecord("operator@shanks", key, "never proposed")
|
||||
out, err := w.k.WithdrawRecord("operator@node-d", key, "never proposed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestAProposedRecordNeedingTheOperatorIsWithdrawnWithItsAskEndedAndItsReview
|
||||
t.Fatalf("not waiting for the operator: %+v", before.Current)
|
||||
}
|
||||
why := "replaced by research on moving designs into the knowledge base"
|
||||
out, err := w.k.WithdrawRecord("operator@shanks", key, why)
|
||||
out, err := w.k.WithdrawRecord("operator@node-d", key, why)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestAProposedRecordNeedingTheOperatorIsWithdrawnWithItsAskEndedAndItsReview
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := vs[len(vs)-1]
|
||||
if last.By != "operator@shanks" || !strings.Contains(last.Why, why) {
|
||||
if last.By != "operator@node-d" || !strings.Contains(last.Why, why) {
|
||||
t.Fatalf("the new version is not by the caller with the why: %+v", last)
|
||||
}
|
||||
})
|
||||
@@ -85,7 +85,7 @@ func TestAnAcceptedRecordRefusesWithdrawNamingWhy(t *testing.T) {
|
||||
n := w.reserved(t, "author@test")
|
||||
key := Key{KindDecision, n}
|
||||
w.accepted(t, "author@test", valid(n))
|
||||
if _, err := w.k.WithdrawRecord("operator@shanks", key, "no longer wanted"); err == nil || !strings.Contains(err.Error(), "accepted") {
|
||||
if _, err := w.k.WithdrawRecord("operator@node-d", key, "no longer wanted"); err == nil || !strings.Contains(err.Error(), "accepted") {
|
||||
t.Fatalf("err %v", err)
|
||||
}
|
||||
e := mustGet(t, w.k.Store, key)
|
||||
@@ -100,10 +100,10 @@ func TestAWithdrawnRecordRefusesWithdrawAgain(t *testing.T) {
|
||||
w := imported(t, s)
|
||||
n := w.reserved(t, "author@test")
|
||||
key := Key{KindDecision, n}
|
||||
if _, err := w.k.WithdrawRecord("operator@shanks", key, "first"); err != nil {
|
||||
if _, err := w.k.WithdrawRecord("operator@node-d", key, "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.k.WithdrawRecord("operator@shanks", key, "again"); err == nil || !strings.Contains(err.Error(), "withdrawn") {
|
||||
if _, err := w.k.WithdrawRecord("operator@node-d", key, "again"); err == nil || !strings.Contains(err.Error(), "withdrawn") {
|
||||
t.Fatalf("err %v", err)
|
||||
}
|
||||
})
|
||||
@@ -113,10 +113,10 @@ func TestWithdrawNeedsWhyAndRefusesARecordItHoldsNoneOf(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
n := w.reserved(t, "author@test")
|
||||
if _, err := w.k.WithdrawRecord("operator@shanks", Key{KindDecision, n}, " "); err == nil {
|
||||
if _, err := w.k.WithdrawRecord("operator@node-d", Key{KindDecision, n}, " "); err == nil {
|
||||
t.Fatal("an empty why was accepted")
|
||||
}
|
||||
if _, err := w.k.WithdrawRecord("operator@shanks", Key{KindDecision, 999999}, "why"); err == nil {
|
||||
if _, err := w.k.WithdrawRecord("operator@node-d", Key{KindDecision, 999999}, "why"); err == nil {
|
||||
t.Fatal("a record it holds none of was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
|
||||
func TestARevisionIsTakenOnlyFromTheRecordsAuthor(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := proposed(t, w, "author@g14", false)
|
||||
n := proposed(t, w, "author@node-b", false)
|
||||
p := valid(n)
|
||||
p.Sections[2].Text = "Merges land a little sooner."
|
||||
if _, err := w.k.Propose("other@novox", p); err == nil || !strings.Contains(err.Error(), "only its author") {
|
||||
if _, err := w.k.Propose("other@node-c", p); err == nil || !strings.Contains(err.Error(), "only its author") {
|
||||
t.Fatalf("a revision by someone else: %v", err)
|
||||
}
|
||||
if _, err := w.k.Propose(Unproven, p); err == nil || !strings.Contains(err.Error(), "only its author") {
|
||||
@@ -23,18 +23,18 @@ func TestARevisionIsTakenOnlyFromTheRecordsAuthor(t *testing.T) {
|
||||
if mustGet(t, w.k.Store, Key{KindDecision, n}).Versions != 2 {
|
||||
t.Fatal("a refused revision wrote a version")
|
||||
}
|
||||
if _, err := w.k.Propose("author@g14", p); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", p); err != nil {
|
||||
t.Fatalf("the author's revision: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReservedNumberIsProposedOnlyByWhoReservedIt(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
n := w.reserved(t, "author@g14")
|
||||
if _, err := w.k.Propose("other@novox", valid(n)); err == nil || !strings.Contains(err.Error(), "reserved by author@g14") {
|
||||
n := w.reserved(t, "author@node-b")
|
||||
if _, err := w.k.Propose("other@node-c", valid(n)); err == nil || !strings.Contains(err.Error(), "reserved by author@node-b") {
|
||||
t.Fatalf("a proposal under another's number: %v", err)
|
||||
}
|
||||
if _, err := w.k.Propose("author@g14", valid(n)); err != nil {
|
||||
if _, err := w.k.Propose("author@node-b", valid(n)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,17 +106,17 @@ what is said is decided by the state now and by when a thing happened, never by
|
||||
what arrives meanwhile goes out, coalesced, when the minute ends.
|
||||
|
||||
**A message reads in one glance** (novox/hq ADR 0253). Its title is the condition's headline — a few
|
||||
plain words such as "openrazer not working on g14" — prefixed "Urgent:", "Still open:" or "Now urgent:"
|
||||
plain words such as "openrazer not working on node-b" — prefixed "Urgent:", "Still open:" or "Now urgent:"
|
||||
where that applies; its body is the condition's explanation (what happened, what it means, whether the
|
||||
operator needs to act) and since when, in the operator's time zone. No key, id, commit, command or markup:
|
||||
those stay in the controller's `conditions` and in this module's history. A clearance is one line, the
|
||||
condition's resolved line and how long it was open: "Resolved: openrazer works again on g14, after 26 min".
|
||||
condition's resolved line and how long it was open: "Resolved: openrazer works again on node-b, after 26 min".
|
||||
A condition from a controller older than plain words, or a module's notice without a headline, is said from
|
||||
its summary, with code spans taken out (`cmd/messenger/words.go`).
|
||||
|
||||
**A condition's message says which issue tracks it** (novox/hq ADR 0285, issue 400). Below the explanation,
|
||||
one line from the issue tracker's `tracking` verb, asked outside the work at most once a minute and used for
|
||||
ten minutes: "Tracked as issue 386: fix-386 on g14 is fixing it since 21:10.", "…: nobody is working on it.",
|
||||
ten minutes: "Tracked as issue 386: fix-386 on node-b is fixing it since 21:10.", "…: nobody is working on it.",
|
||||
"…: its fix is merged and on its way.", "…: it waits for your answer.", up to three issues and then a count;
|
||||
"No issue tracks this yet." when no issue links the condition's key; "Whether an issue tracks this could not
|
||||
be checked." when the tracker gave no answer in ten minutes. "Nothing for you to do." becomes "Needs you:
|
||||
|
||||
@@ -100,7 +100,7 @@ func (c Condition) When(event string) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// SubjectWords is the subject as a person reads it: "machine ace", "plan 41", "provider keycloak".
|
||||
// SubjectWords is the subject as a person reads it: "machine node-a", "plan 41", "provider keycloak".
|
||||
func (c Condition) SubjectWords() string {
|
||||
parts := []string{}
|
||||
if c.Scope != "" {
|
||||
|
||||
@@ -145,7 +145,7 @@ func randomRun(text string) (Refusal, bool) {
|
||||
}
|
||||
|
||||
// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one
|
||||
// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only.
|
||||
// machine and one tool (`node-a/postgres.query`) and has one; a ratio ("3/4") has digits only.
|
||||
func isPath(w string) bool {
|
||||
if w == "" {
|
||||
return false
|
||||
@@ -162,7 +162,7 @@ func isPath(w string) bool {
|
||||
}
|
||||
|
||||
// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind
|
||||
// (`machine.ace.silent`), which none of these is.
|
||||
// (`machine.node-a.silent`), which none of these is.
|
||||
func isHostName(w string) bool {
|
||||
w = strings.ToLower(w)
|
||||
if w == "localhost" {
|
||||
|
||||
@@ -675,14 +675,14 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
"Roles and words only: an address, a path or a secret is refused. Deduplicated, capped and routed " +
|
||||
"like the controller's conditions.",
|
||||
Input: map[string]any{
|
||||
"key": str("what makes it the same message the next time, e.g. backup.ace.failed"),
|
||||
"key": str("what makes it the same message the next time, e.g. backup.node-a.failed"),
|
||||
"severity": map[string]any{"type": "string", "enum": []string{Urgent, Warning}},
|
||||
"summary": str("one line in the mesh's words"),
|
||||
"subject": str("what it is about: a machine's role, a module, a plan"),
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on ace: no ids, keys or commands"),
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on node-a: no ids, keys or commands"),
|
||||
"explanation": str("one or two plain sentences: what happened, what it means for the operator, and whether " +
|
||||
"they need to act"),
|
||||
"resolved": str("the one short line said when it is over, e.g. backups run again on ace"),
|
||||
"resolved": str("the one short line said when it is over, e.g. backups run again on node-a"),
|
||||
"clear": map[string]any{"type": "boolean", "description": "it is over: the message is edited to say so"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
@@ -696,7 +696,7 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
"words are, and offering Details like any message. Never escalated, never kept open, never reminded; " +
|
||||
"its own cap of 10 a channel an hour, apart from the conditions' and notify's.",
|
||||
Input: map[string]any{
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for ace"),
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for node-a"),
|
||||
"lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "one or two plain lines"},
|
||||
"subject": str("what it is about: a machine's role, a module, a plan"),
|
||||
},
|
||||
|
||||
@@ -326,7 +326,7 @@ func issueState(is TrackedIssue, now time.Time, loc *time.Location) string {
|
||||
return "nobody is working on it"
|
||||
}
|
||||
|
||||
// workerWords is who@node as said: "fix-400 on g14".
|
||||
// workerWords is who@node as said: "fix-400 on node-b".
|
||||
func workerWords(worker string) string {
|
||||
who, node, ok := strings.Cut(worker, "@")
|
||||
if !ok {
|
||||
|
||||
@@ -9,7 +9,7 @@ package main
|
||||
// controller where the condition is made, so every channel says the same thing. A condition from a
|
||||
// controller older than them, or a module's notice without a headline, is said from its summary,
|
||||
// with what the operator cannot read taken out (plainSummary).
|
||||
// - A clearance is one short line: "Resolved: openrazer works again on g14, after 26 min".
|
||||
// - A clearance is one short line: "Resolved: openrazer works again on node-b, after 26 min".
|
||||
// - A reminder, an escalation and a reopening say so in the title, before the same headline.
|
||||
// - Times are the operator's (the setting time-zone; this machine's zone when not given), and said as a
|
||||
// clock time today, a weekday this week, a date before that.
|
||||
@@ -168,7 +168,7 @@ func fallbackHeadline(r *Record) string {
|
||||
return capital(what) + " on " + about
|
||||
}
|
||||
|
||||
// machineOf is the machine a record's subject words name: "machine ace" or "x on ace".
|
||||
// machineOf is the machine a record's subject words name: "machine node-a" or "x on node-a".
|
||||
func machineOf(subject string) string {
|
||||
if _, on, ok := strings.Cut(subject, " on "); ok {
|
||||
return on
|
||||
|
||||
@@ -55,7 +55,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
}},
|
||||
{Name: "nats_connections",
|
||||
Description: "Every client connected to the bus, named by the mesh user it is (a machine's host `node.<machine>`, a machine's runtime `<machine>.node-tools`, the controller, a person): address, uptime, round trip, traffic and pending bytes. Narrowed by a user or connection name, sorted by out_bytes (default), in_bytes, pending, msgs or subscriptions.",
|
||||
Input: map[string]any{"user": str("part of a user or connection name, e.g. g14"), "sort": str("out_bytes, in_bytes, pending, msgs or subscriptions"), "limit": num("at most this many (default 50)")},
|
||||
Input: map[string]any{"user": str("part of a user or connection name, e.g. node-b"), "sort": str("out_bytes, in_bytes, pending, msgs or subscriptions"), "limit": num("at most this many (default 50)")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
connz, err := get("/connz?auth=1&limit=1024")
|
||||
if err != nil {
|
||||
@@ -115,7 +115,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
}},
|
||||
{Name: "nats_users",
|
||||
Description: "The bus's users as the controller composed them — never a password hash. Without a user, each with how many grants it holds; with part of a user's name, its publish and subscribe grants in full.",
|
||||
Input: map[string]any{"user": str("part of a user name, e.g. g14.node-tools or controller")},
|
||||
Input: map[string]any{"user": str("part of a user name, e.g. node-b.node-tools or controller")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
c, err := conf()
|
||||
if err != nil {
|
||||
@@ -125,7 +125,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
}},
|
||||
{Name: "nats_user_can",
|
||||
Description: "Whether a bus user may publish or subscribe to a subject, and the grant that allows it — the question behind a request that timed out because the bus refused it. Also whether the running server holds it: `in_force` is true only when the user list on the machine allows it and the server reloaded its configuration after that list was written (novox/hq issue 353: a list sent is not a list the bus enforces until it reloads).",
|
||||
Input: map[string]any{"user": str("the user exactly, e.g. g14.node-tools"), "action": str("publish or subscribe"), "subject": str("a concrete subject, e.g. $KV.claude-code_servers.all.x")},
|
||||
Input: map[string]any{"user": str("the user exactly, e.g. node-b.node-tools"), "action": str("publish or subscribe"), "subject": str("a concrete subject, e.g. $KV.claude-code_servers.all.x")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
c, err := conf()
|
||||
if err != nil {
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestTheDropInGrantsExactlyTheOperatorAccountAndParses(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skip("visudo is not installed here; the rendered drop-in is not checked")
|
||||
}
|
||||
for _, account := range []string{"operator", "ace", "jochen-s"} {
|
||||
for _, account := range []string{"operator", "node-a", "jochen-s"} {
|
||||
file := filepath.Join(t.TempDir(), "10-mesh-operator")
|
||||
rendered := strings.ReplaceAll(content, "${machine:account}", account)
|
||||
if err := os.WriteFile(file, []byte(rendered), 0o440); err != nil {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const listNovox = `Matching Defaults entries for operator on anchor:
|
||||
const listAnchor = `Matching Defaults entries for operator on anchor:
|
||||
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/bin
|
||||
|
||||
User operator may run the following commands on anchor:
|
||||
@@ -14,7 +14,7 @@ User operator may run the following commands on anchor:
|
||||
`
|
||||
|
||||
func TestSudoListIsParsedIntoDefaultsAndRules(t *testing.T) {
|
||||
r := ParseList(listNovox, "x")
|
||||
r := ParseList(listAnchor, "x")
|
||||
if r.Account != "operator" || r.Host != "anchor" {
|
||||
t.Fatalf("who: %+v", r)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ bindings. Those bindings belong to that machine's hardware module, not here.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 (laptop) | shanks (desktop) |
|
||||
| | node-b (laptop) | node-d (desktop) |
|
||||
|---|---|---|
|
||||
| packages | `autorandr` installed; the rest are there already | the same |
|
||||
| files | `~/.config/xorg/xresources` new; a block added at the start of `~/.xinitrc` | the same |
|
||||
|
||||
@@ -53,7 +53,7 @@ program.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
| | node-b | node-d |
|
||||
|---|---|---|
|
||||
| package | none (`xterm` present) | the same |
|
||||
| `xorg`'s resources file | gains this module's resources | the same |
|
||||
|
||||
Reference in New Issue
Block a user