Compare commits

..
Author SHA1 Message Date
jschoubben 9c9c8a5631 Merge main (#617, #618) into the registry milestone (issue 1966)
mesh-build-go keeps trunk's zero-version resolution on the group's mesh-delivery sdk types; mesh-build-node keeps per-path packages with the builder's version and seat.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 00:18:20 +01:00
mesh-tools bc4d43b508 Merge pull request 'The Go build module takes a repository's own unpublished modules from its tree (issue 1921)' (#618) from fix/1921-zero-pins-own-packages into main 2026-11-05 23:00:38 +00:00
jschoubben 7a5eef32e7 A zero-version requirement of the checked repository's own module is built from its fetched tree, and a failed latest says what failed (issue 1921)
mesh/delivery merged: source.main moved to novox/mesh-catalog@bc4d43b508cf
mesh/check being carried to 7a5eef32e772 on bc4d43b508cf | from df8168f4843f, mesh-delivery c0e5bc2834fe
mesh/delivery-group group fix/1921-zero-pins-own-packages merged: every member is merged
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:34:46 +01:00
jschoubben 5aaafd917f A replacement is refused like any section, and an expired one changes nothing: two cases of ADR 0642 as tests
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:19:56 +01:00
jschoubben aa7c9e8924 A removal of a replacement names both fingerprints; the two-texts refusal names the places and the way out
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:19:03 +01:00
jschoubben 80f992e367 A removal of a section removed already is refused: the case ADR 0642 names, as a test
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:17:22 +01:00
jschoubben b3fc5b4580 A removal of an approved replacement names the registered text's fingerprint; the cases ADR 0642 cites are tests
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:16:23 +01:00
jschoubben 6fb27549c9 No checked-out library copy or comment says placement; the claude-code fixture and seat-list test pass at the merge (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 22:37:09 +01:00
jschoubben d8227222a7 Merge branch 'reg/claude-code-replace' into feat/registry-milestone 2026-11-05 22:32:50 +01:00
jschoubben c9d8950fd0 Every module follows the assigner's new places: the contracts' go/ path, mesh-wants in the delivery contracts, the state and tool assignments (issue 1966)
The legacy key form placement.<node> is gone from the client, so the readers and their fixtures lose it. The check noplacement
now also refuses the assigner's retired names in a manifest.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 22:29:07 +01:00
mesh-tools df8168f484 Merge pull request 'The build modules build a folder module's packages at their paths and take a zero-version requirement from the ask (issue 1966)' (#617) from fix/1966-build-modules-folders into main 2026-11-05 21:21:10 +00:00
jschoubben 657cb2b6c8 A section registered before the controller's terminal took over, or set there, is replaced or removed by a proposal the operator approves
It could not be changed at all: propose refused a name set there and never replaced one. The ask now says plainly that
it replaces or removes the registered section and binds its text's digest; on Approve every node renders the new text
in the place of the old (a removal leaves it out); a replacement keeps the section's own heading form (issue 340 note 14).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 22:14:55 +01:00
jschoubben cbea2470d2 mesh-vault starts its passes once Serve holds the runtime's channel: started earlier every watch is refused and waits for the minute's pass (issue 774)
mesh/check being carried to cbea2470d225 on df8168f4843f | from 559939aa8ce1
mesh/delivery merged: source.main moved to novox/mesh-catalog@df8168f4843f
Checked by TestTheVaultWholeRotatesANodesLoginWithAStandInEngine: the race it met is what this closes.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 21:59:15 +01:00
jschoubben 2dc9e07e88 The generated TypeScript parser of a key form names its own parameter and locals apart from the form's parts
A key form such as <node>.<module>.<key> made the parser declare the parameter key and the part key together (TS2300 Duplicate identifier), which the per-package unit level now surfaces. The parameter and the locals are now _key, _rest and _at<n>; a test row with such a form fails on the old output. Issue 1966.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 21:24:07 +01:00
jschoubben 1feda1f533 mesh-build-go takes a zero-version requirement from the ask or the trunk, never from the pull request heads (ADR 0573 sections 2 and 4, issue 1966)
mesh/check failed: integration: mesh-vault/cmd/mesh-vault.Tes… | 1feda1f533b8 on 559939aa8ce1 | judged by the candidate's mesh-build-go at 87d30b709d68
mesh/delivery closed: a newer head of the same pull request
A go.mod requirement of a forge module at v0.0.0-00010101000000-000000000000 names no commit; the fetch looked for the commit 000000000000 among the pull request heads and failed. It is now taken from the commit the ask names for the package, else from the head of its repository among the asked versions, else from the latest published version.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 21:17:21 +01:00
jschoubben 4b9ea0bf11 mesh-build-node builds and tests each TypeScript package at its own path, with its own lock (ADR 0634 section 8, issue 1966)
A folder module (the contracts folders) declares several package artifacts under ts/seats/<seat>/, each with its own package.json and lock and none at the module directory; the fetch asked for a lock at the module directory and failed the group's check. The fetch installs, the build packs and the test level runs at the artifact's path; an artifact naming no usable path keeps the module directory. The module's .gitignore anchors the binary's name so the command directory is not ignored.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 21:14:56 +01:00
jschoubben 625d627794 No client names the holder of the seat mesh-provisioner: each reader keeps its own constant, held to its manifest (ADR 0625 section 4, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 19:36:21 +01:00
jschoubben d630635229 RemovedDeliveryVerbs leaves mesh-sdk for the seat mesh-delivery's client (ADR 0634 section 2, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:45:50 +01:00
jschoubben 29bcaf2be5 The wanted accounts' layout leaves mesh-sdk for the grants part's package grantsstate (ADR 0634 section 1, ADR 0635 section 4, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:43:21 +01:00
jschoubben 78a1b5648b No client names a holder: each reader keeps its own constant, held to its manifest (ADR 0625 section 4, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:37:28 +01:00
jschoubben 720efe6339 Require the external packages a module's build reaches (issue 1966)
Versions are the ones the workspace already resolves, with their go.sum lines.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:11:56 +01:00
jschoubben 76e25aeee0 mesh-mail: its go.mod test no longer reads a release out of the mesh-sdk version (the floor version, ADR 0573)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:04:55 +01:00
jschoubben fb6bf361e4 Require every mesh package of the group at the floor version (ADR 0573 sections 2 and 4, issue 1966)
A member's go.mod names the oldest version a module accepts; the check builds the group's own heads whatever it names. go.sum lines of mesh packages are left out.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:56:39 +00:00
jschoubben 8a8ad7713e Merge branch 'b9i/registry' into feat/registry-milestone 2026-11-05 17:39:18 +01:00
jschoubben 50acf7c1b4 artifact-store-tools: the sweep seats read the artifact store's form through the registry clients' forms too (S-8; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:38:04 +01:00
jschoubben a89aaf5644 nats and mesh-config: their event streams keep their history, the bus's predefined form of an event family, not an age-limited stream of their own (V1; ADR 0592 §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:30:39 +01:00
jschoubben 82d00aeb5e Builders, the artifact store's tools and distribution read and write the registry reference form through the registry clients' reference package, and name the sweep seat from its contract (ADR 0634 §4, S-8; issue 1966)
catalogue's SeatReference, InRegistrySeat, Routed, Recorded, InArtifactStore and ArtifactSweepSeat leave mesh-sdk;
each package gives the two registry seats' names from their contracts (or the build step's copies of them).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:26:32 +01:00
jschoubben 2978dd5bed mesh-healers and mesh-self-check use the conditions client's Observation, Finding encoding and Says, not copies of their own (ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:05:27 +01:00
jschoubben 1f3a293988 The nats keeper takes the removal grant from the grants part's published busgrants package and names it in packages (ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:03:28 +01:00
jschoubben bbcc95e66d Merge branch 'b5/registry' into feat/registry-milestone 2026-11-05 16:52:51 +01:00
jschoubben 37cdf41088 mesh-vault takes the internal name from mesh-wireguard's overlay package; the digest test no longer names mesh-sdk's buildstep, it takes the one package of its go list capture (B5l; ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:51:24 +01:00
jschoubben 6f2e27f59b mesh-hand-acts and mesh-healers take the hand-act names and the act from the seat mesh-hand-acts and its client, not mesh-sdk's handacts (B5l; ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:48:00 +01:00
jschoubben 8d3cffab9a gitea and mesh-provisioner tests name the finding key and the findings state through the conditions client, not mesh-sdk's health (B5l; ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:46:45 +01:00
jschoubben 73eb3bd11c mesh-self-check reads a node's presence through the watchdogs client: a part's key is no node, a refused version is an error (ADR 0634 section 4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:44:52 +01:00
jschoubben 75959dfa25 seatgen writes a scalar typed [T, null] as *T in Go and T | null in TypeScript, so a declared zero survives with its type (ADR 0634; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:43:19 +01:00
jschoubben 36da33c818 Merge branch 'b5i/registry' into feat/registry-milestone 2026-11-05 16:24:49 +01:00
jschoubben ef81dc2712 mesh-build-go reads a check's toolchain from the job, answers with the Delivery SDK's CheckAnswer and typed FetchAnswer, and takes the toolchains value from the Delivery SDK; askrun_gaps.go and toolchain.go are gone (B5j; ADR 0584 clause 2, ADR 0634 section 6; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:24:13 +01:00
jschoubben 2685b9c749 Merge branch 'b5/registry' into feat/registry-milestone 2026-11-05 16:13:37 +01:00
jschoubben 725ea4dba4 Merge branch 'b5i/registry' into feat/registry-milestone 2026-11-05 16:13:21 +01:00
jschoubben ffa026f7d8 checks/readable knows the owners' published constants (diskreadings, backupgood), not mesh-sdk's health ones the modules no longer name (B5g; ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:12:37 +01:00
jschoubben da560f90f8 nats reads manifest-accepted in the seat mesh-catalog's typed event and subject (B5g; ADR 0604, ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:11:13 +01:00
jschoubben df53a0c59e gitea's whole test names the builder's versions address from the builder's builds package (B5g; ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:09:54 +01:00
jschoubben 634abead7d mesh-build-go, -image and -node declare the builder's published package builds, which they now read their forms and keys through (B5i; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:09:52 +01:00
jschoubben 85999b7efd distribution steps its windows with mesh-delivery's sdk window package (B5g; ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:09:37 +01:00
jschoubben b8642c8b27 mesh-self-check reads the placement, seat holders, presence and health reports through the seats' types and clients; RootContract is the node-engine client's (B5g; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:09:16 +01:00
jschoubben 8f1581f34a mesh-build-go: a seat's ask type runs as the neutral ask (AskOf, the step description, the fetch arguments) (B5i; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:08:26 +01:00
jschoubben 1e974ce9e8 mesh-config keeps held values in the seat's typed state kept, through the config client's key and codec (B5g, B2f; ADR 0565 §5, ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:08:11 +01:00
jschoubben 941959198e mesh-build-node runs its asks on the Delivery SDK's neutral build ask, step and answer forms instead of mesh-sdk's go/delivery; its state names are its seat's; the three build modules require the contracts they import (B5i; ADR 0634 §1, §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:07:50 +01:00
jschoubben d8b4bb5487 mesh-vault reads signers, node logins, tokens, placement, haves and the handing through the seats' types and clients; its holder names are its own constants (B5g; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:07:00 +01:00
jschoubben 078f1e5fa9 artifact-store-tools answers held and says artifacts-gone in the seat's generated types (B5g, B2f; ADR 0571 §2, ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:06:52 +01:00
jschoubben aa29d8feb8 mesh-build-image runs its asks on the Delivery SDK's neutral build ask, step and answer forms instead of mesh-sdk's go/delivery; its state names are its seat's (B5i; ADR 0634 §1, §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:06:48 +01:00
jschoubben cfde90906f mesh-build-go: the imports the move added sit in the module group (B5i; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:06:24 +01:00
jschoubben 8786bb3ba1 mesh-build-go runs its asks on the Delivery SDK's neutral build ask, step and answer forms instead of mesh-sdk's go/delivery: the Puller takes buildstep.StepAsk (the read bridge is the form's own AskAs), the toolchains value is the build runner's own, the stale-step part is buildrun's, keys and values come from the seats and clients (B5i; ADR 0634 §1, §6; issue 1966)
The part build-step lacks the check job's toolchain and types the check answer's fetch as the fetch arguments; both are kept in askrun_gaps.go until the part has them.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:05:34 +01:00
jschoubben 72f22b11ec mesh-delivery-gate reads and writes its values through the seat's types and meshdeliveryclient's codecs, the view's reach, the checker's verdict key; the holder names are its own constants; no go/delivery left (B5h; ADR 0634 §1, §2; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:00:39 +01:00
jschoubben 84a7639042 mesh-config reads the placement, wants, haves, modules, manifests, predefined list and config candidates through the seats' types and clients, the builder's builds through its published package; the kept value is its own layout (B5f; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:52:59 +01:00
jschoubben 6e4de66df7 mesh-provisioner reads the placement, wants, haves, compositions, modules and manifests through the seats' types and clients and the builder's builds through its published package (B5f; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:49:06 +01:00
jschoubben d4abb82b07 artifact-store-tools reads the builder's builds through its published package, the wants, haves and manifests through the seats' types and clients; the sweep seat's two bodies are the holder's own (B5f; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:46:23 +01:00
jschoubben 42387b660c mesh-conditions' hold reads wants, haves and placement through the seats' types and clients; the motion is mesh-sdk's go/motion (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:40:54 +01:00
jschoubben 0f1d4c5e0f pacman reads a node's declared packages in the node-engine seat's layout, not go/delivery's (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:39:13 +01:00
jschoubben 597910c291 mesh-provisioner-client reads the placement and the node's have through the placement and node-engine clients and the seats' types (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:38:00 +01:00
jschoubben 8fabdc4b91 disk-load reads the node-engine's data state through the node-engine client and the seat's types (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:28:35 +01:00
jschoubben a609ebc8e5 claude-code reads placements, modules and manifests through the placement and catalog clients and the seats' state names (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:27:33 +01:00
jschoubben a636dc3df3 distribution asks the builder through the builder's published package builds, taken by its series (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:25:34 +01:00
jschoubben 72dd8a719e gitea asks the builder through the builder's published package builds, taken by its series (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:25:33 +01:00
jschoubben 4923e44cf7 mesh-watcher reads who holds a seat through the placement client and the seat's state name, not go/delivery (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:23:30 +01:00
jschoubben 1d20825d69 nfs-server reads who holds a seat through the placement client and the seat's state name, not go/delivery (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:23:30 +01:00
jschoubben 45f4edbb36 minio reads who holds a seat through the placement client and the seat's state name, not go/delivery (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:23:30 +01:00
jschoubben c39c3fdddd mesh-conditions keeper works over the seat's types: conditions, events and findings in the seat's layout, the old layout still read (B5e; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:20:52 +01:00
jschoubben b0f2441dd3 mesh-config and nats consume the seat mesh-catalog's manifest-accepted, not the indexer's (B7d finding 1; ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:18:27 +01:00
jschoubben 62bb25675d distribution: its wake events are heard through the events hub, the module's one durable pull (B5d; B9d hub; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:14:12 +01:00
jschoubben 4104143d16 The writers of findings give the seat's time.Time (artifact-store-tools, distribution, gitea, mesh-config; B5d; ADR 0634; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:13:16 +01:00
jschoubben 916bdde6a4 mesh-healers: attempts, would, the healer-acted event and its findings are the seats' contract types (B5d; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:09:43 +01:00
jschoubben 97cc9b1ccf mesh-self-check: a run is the seat's checked event and a finding the seat mesh-conditions' value (B5d; ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:09:00 +01:00
jschoubben 0ae418b43c mesh-watcher names the self-check seat and its event from the seat's contract (B5d; ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:07:01 +01:00
jschoubben 1d3ed6e78e messenger, desk-channel, telegram, mesh-config take the channel benches' shapes from the operator-channel contract and its client (B5d; ADR 0634 §1, §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:03:24 +01:00
jschoubben f61760f738 seatgen writes a date-time as time.Time (a string in TypeScript), by value when required, by pointer when optional (B5d; ADR 0634; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:01:10 +01:00
jschoubben c46009709d The key keeper is ssh-client's published package sshkeys, taken by series by the three build modules; Said, KindBuilder, ExecRunnerClean from the Delivery sdk (B5d; ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:55:59 +01:00
jschoubben b4dbda287b Findings are written and read in the seat mesh-conditions' layout: gitea, distribution, mesh-config, artifact-store-tools (B5b Left 5 part; ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:50:35 +01:00
jschoubben e0eecbf3c8 Readings and last good nights are the owners' published packages: disk-load's diskreadings and restic's backupgood, taken by series (B5b Left 3; ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:48:10 +01:00
jschoubben c7ae96414f claude-code: the again tool answers every operator's session and the healers; the seat-list case waits on the reader's Ready (ADR 0636 §7; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:42:32 +01:00
jschoubben 9017edaf9e seatgen: a client may import its contracts module's domain SDK (-domain-sdk; ADR 0634 §2; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:41:28 +01:00
jschoubben 75cf83a656 The seat-list case waits for the reader's change instead of racing it; the node build's generated-package fixture carries its domain (issue 1966) 2026-11-05 14:40:30 +01:00
jschoubben 059fa5d4c4 Seat fixtures of the claude-code and node-build tests carry their domain (ADR 0636 §5; issue 1966) 2026-11-05 14:39:49 +01:00
jschoubben 60c66a3641 messenger: the ask-events entry's grant is the config value readers, as the events template takes it (K-24; ADR 0595; issue 1966) 2026-11-05 14:36:29 +01:00
jschoubben 631f689f40 gofmt of the files the hand-acts and conditions clients touched (issue 1966) 2026-11-05 14:34:18 +01:00
jschoubben 500154b865 The gate accepts the bridge's published forgemsg by its series (K-22; ADR 0634 §3, F3; issue 1966) 2026-11-05 14:33:24 +01:00
jschoubben 12b5c62304 Seat fixtures of the build module's tests carry their domain (ADR 0636 §5; issue 1966) 2026-11-05 14:31:58 +01:00
jschoubben b2db504ab2 Build, image, node, distribution and gitea take parts, buildstep and composer from mesh-delivery-sdk; seatgen's client check allows a client's own packages and its -go-module has no root default; the build module writes its finding through the conditions client (K-4..K-20 part; ADR 0634 §3, §4, ADR 0635 §4; issue 1966) 2026-11-05 14:31:04 +01:00
jschoubben e8f93d5db0 Provision and provider users take the provisioner's client; mesh-vault writes its findings through the conditions client (K-4..K-20, K-21 part; ADR 0634 §4; issue 1966) 2026-11-05 14:26:48 +01:00
jschoubben 7c617bb085 Merge b4/registry (9fafbcfd): the renamed seats (issue 1966) 2026-11-05 14:22:14 +01:00
jschoubben 87388b9046 Eleven modules record hand acts through the hand-acts client, the finding writers are the conditions client's, nats takes the broker seat's name from its package (K-21 part, K-4..K-20 part; ADR 0634 §4, ADR 0635 §4; issue 1966) 2026-11-05 14:21:57 +01:00
jschoubben 9fafbcfd58 The seats are called mesh-module-assigner and mesh-node-enroller in mesh-cli, claude-code, the checks, mesh-config, mesh-vault, conditions, self-check and the provisioner client (ADR 0634 §9; K-21; issue 1966) 2026-11-05 14:19:32 +01:00
jschoubben 2f7ff1c911 claude-code: the seat list is read from the seat mesh-catalog and followed on its events, the hand acts go through the hand-acts client, the seat is mesh-module-assigner (K-23, K-21 part, K-4..K-20 part; ADR 0635 §1, ADR 0634 §9; issue 1966) 2026-11-05 14:14:32 +01:00
jschoubben 7ae2ed88c0 messenger: the asks it says are a bus-state entry of the events template, not a stream of its own (K-24, F6, ADR 0595, ADR 0619; issue 1966) 2026-11-05 14:09:44 +01:00
jschoubben a86e2ff1fc Mesh-catalog modules import operator-channel from its domain's contracts module and the registry seats from mesh-delivery's (K-4..K-20 first part; ADR 0634 §7, ADR 0636; issue 1966)
The group's module paths are placeholders the re-pin rewrites.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 14:07:30 +01:00
jschoubben 518539f3d1 Merge mesh-catalog main (559939aa) into the registry branch: gate reads forgemsg, messenger narrows asks (K-0; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 14:05:04 +01:00
jschoubben a2272916a0 mesh-build-go: a real-git test that a root module's source leaves out its nested module folders and a folder module is narrowed like a root one (D-I4, ADR 0634 §8; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:25:09 +01:00
jschoubben 2b2b3e4b8c mesh-build-go: the generated-files compare finds a contracts module in any folder; a folder module's Go packages each publish under their own module path (ADR 0634 §8; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:23:00 +01:00
jschoubben 1abae72457 seatgen: no seat list and no root package, definitions in any folder, seat-list-grain files generate nothing, packages with a dependency-free lock (ADR 0635 §1, ADR 0634 §8, F7; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:21:04 +01:00
jschoubben 0beef7ff9c seatgen: a seat's states give a constant, key builders and parsers and strict value types, Go and TypeScript (ADR 0634 §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 12:50:59 +01:00
mesh-tools 559939aa8c Merge pull request 'nfs-server: the look below a share runs beside the pass, so a long walk no longer fails its health (issue 1971)' (#616) from fix/1971-nfs-walk-beside-look into main 2026-11-05 10:02:10 +00:00
jschoubben 87fd0cd8b9 nfs-server: a pending share is carried only by the export line for the same folder (issue 1971, review round 2)
mesh/delivery merged: source.main moved to novox/mesh-catalog@559939aa8ce1
mesh/check pass 87fd0cd8b985 on 559939aa8ce1 | carried from 211186edd769 | unit 625 run, 4127 kept, 13 skipped | integration 676 run, 4136 kept, 22 sk…
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 10:45:30 +01:00
jschoubben 279ac41087 nfs-server: a share the export file carries stays exported while its first look runs after a restart (issue 1971, review round 1)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 10:43:50 +01:00
jschoubben b0bdcd02c9 nfs-server: the look below a share runs beside the pass, so the status is written every pass (issue 1971)
A walk of minutes inside the pass held the status back until the module was found unhealthy and the assigner held the version.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 09:39:24 +01:00
mesh-tools 211186edd7 Merge pull request 'Gate reads the forge bridge's own messages and passes a group's end by; operator-channel.asks narrows its answer (issues 1585, 595; ADR 0633)' (#615) from feat/take-in into main 2026-11-05 00:33:41 +00:00
jschoubben 5ceb6e9e38 Gate: pin forgemsg at mesh-delivery c78941e8, the head whose take-in events need no stream (issue 1585)
mesh/delivery merged: source.main moved to novox/mesh-catalog@211186edd769
mesh/delivery-group group feat/take-in merged: every member is merged
mesh/check being carried to 5ceb6e9e38a4 on 211186edd769 | from fd4931a75261, mesh-delivery ae31b66a9e33 | novox/mesh-catalog#615 in group feat/take-in
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-05 00:59:56 +01:00
jschoubben d023a25849 Gate and keeper, review round 1: a group's end is told by its whole event, the gate pinned at mesh-delivery 7b7096f3, the keeper's row dropped (issue 1585, ADR 0633)
mesh/delivery-group group feat/take-in rejected: composed together: the checker's verdict at the members' heads (38623984d6ad): mesh-build-go
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (2): mesh-build-go: the indexer's judge at the group's mesh-delivery head 7b7096f37ea76e1…; and 1 more | d023a25849f3 on fd4931a75261
The gate passes by a group's end only when the event is the forge bridge's forge.group.merged whole, so the merged message
of a repository named forge/group is read as one; row in the holder test. The keeper's take-in-waits row (T26) is dropped:
it showed nothing the keeper does not already hold. The gate pins forgemsg at mesh-delivery 7b7096f3.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-05 00:29:37 +01:00
jschoubben ebe1248f0b Gate: read the forge bridge's messages from its own package forgemsg, and pin mesh-sdk at main (issue 1585, ADR 0633)
Forge, its kinds and the group's end moved out of mesh-sdk into the forge bridge's own package (mesh-delivery
modules/mesh-delivery-forge-bridge/forgemsg, the operator, 2026-11-04: logic one module owns lives with it). The gate
imports it at the mesh-delivery head 4e4601a1 that holds the move, and the suffix it passes by before decoding is the
package's own ForgeGroupEndEvent, not a constant of its own. mesh-sdk is pinned at 5a38a12, origin/main.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-05 00:06:59 +01:00
jschoubben 8377f8a878 operator-channel.asks: key, state, since and bound narrow its answer, which says when it cut it short (issue 595)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 23:14:17 +01:00
jschoubben 796fb69d17 Gate ignores a group's end on FORGE before decoding; keeper row for take-in-waits (issue 1585, ADR 0633)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 22:47:56 +01:00
jschoubben 236ce2fe12 mesh-conditions: the indexer's manifest-held has its home (ADR 0632 §2; issue 1966)
A module's new entry held from the whole mesh past its bound is a finding of
the indexer's; without a home the keeper would not raise it.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 21:38:14 +01:00
jschoubben bbb9e37df4 docker: trust the store at the image seat's reach (ADR 0632 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 21:11:15 +01:00
jschoubben 1bf98663c6 artifact-store-tools: read the store through the registry seats' list, never its files or its read side (ADR 0632 §1, §4; issue 1966)
The docker exec listing and the HTTP door go; held and the sweep's index
skip ask the seat's list too. The store's address file is the image seat's
reach, used for the catalogue's definitions only.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 21:11:15 +01:00
jschoubben 541c2e02d9 distribution: provide and serve no artifact-store, and answer the seats' list from the store's own files (ADR 0632 §1, §3, §4; issue 1966)
The store is found by the seats' holder and its listen registry; list says,
per repository, each manifest's digest, whether it is an index and what it
names, its tags, its size and whether it is held, so the sweep reads the
store through the seat. E15 now asks that nothing is provided or served.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 21:11:15 +01:00
mesh-tools fd4931a752 Merge pull request 'mesh-build-go: a merge preview names a path and a path inside it (mesh-contracts' go), and a step's cause keeps the module Go could not find (issue 1966)' (#613) from fix/buildstep-preview into main 2026-11-04 19:44:20 +00:00
jschoubben 64cb7ecc53 health-undeclared: 56, the artifact store's container now says how it is ready (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:30:48 +01:00
jschoubben 10bbbadcf2 artifact-store-tools: require no artifact-store; the sweep reaches the store through the registry seats (issue 1966)
The operator's decision of 2026-11-04: the store's clean-up asks the registry seats' verbs and is no consumer of the
provision.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:28:37 +01:00
jschoubben 3dfc3533be distribution: the store's /v2/ checked over http through the front, beside the program's tool check (issue 1966)
A tool check alone is refused by the indexer's judge (ADR 0227 rule 8, ADR 0240 rule 2); the operator chose a check of
another kind on 2026-11-04.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:28:37 +01:00
jschoubben da2aa91832 Keep Go's indented continuation in a step's cause: the composer's reason ended at "requires" and hid the module that could not be found (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@fd4931a75261
mesh/check being carried to da2aa9183221 on fd4931a75261 | from 8acad77d33f9
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:19:16 +01:00
jschoubben 1332f8b523 Ask git ls-tree for nested named paths in rounds: given go and go/seats/x together it lists go's children, so mesh-contracts' preview failed (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:19:16 +01:00
jschoubben af487d38ed mesh-vault: adopt from the bus node's kept have when the placement names it
A have of the bus module heard while the placement did not yet name its node as holding the bus
adopted nothing, and nothing adopted it again until the have changed, so a vault could stay without
the bus node's adoption and the loaded list's revision for good. The vault now keeps each node's
latest have and, on the placement's next change, adopts from the bus node's kept have if it was not
adopted: the placement's event, no timer. The rotation test now writes the have before the
placement (its wait for the vault's picture is gone), so it takes this path every run.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:04:06 +01:00
jschoubben cea09d8a36 Make five manifests the milestone changed pass the indexer's judge: an event in one place, one form per state, a digest of a text field, trusted and runtime config values (issue 1966)
messenger's refusal took its declaration of operator-channel out of the judged catalogue, so claude-code's use of it
was refused too. distribution's two remaining refusals (its program builds beside the artifact store it provides,
and its only health check is its own tool) are a design question left to the lead.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 19:55:36 +01:00
jschoubben b4e0518282 mesh-vault: the player holds a family's events until the vault subscribes, and the rotation test waits for the vault's placement
The rotation test failed twice under parallel runs ("never: the rotation begins"), from two races in
its player. The player refused mesh/subscribe, so the placement events it said never reached the
vault: when the vault's first view was answered before the test wrote the placement, the vault never
knew the bus node. And the test wrote the bus node's have right after the placement, so the vault
could hear the have before it applied the placement, adopt nothing, and never learn the loaded list's
revision. The player now answers mesh/subscribe and holds a family's events until it is subscribed,
as the module's consumer does; the test writes the placement only after the vault's first view and
the have only once the vault's picture holds every placement key (vault_first_views), so both orders
are fixed rather than waited out.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 19:54:27 +01:00
jschoubben d0a69afbb1 Keep Go's indented continuation in a step's cause: the composer's reason ended at "requires" and hid the module that could not be found (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 19:45:55 +01:00
jschoubben 4d3c901b99 Ignore every program a local go build leaves, and refuse a main package whose program is not ignored (issue 1577)
Twice in the registry milestone a module's program, built in its folder, was committed with git add -A
and stayed in the branch's history. The ignore list was kept by hand and covered 56 of 229 programs.
checks/gitignored now lists every main package's program (in its directory and at its Go module's
root) and fails on one no ignore file names; the root .gitignore names each, anchored to its path.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 19:39:33 +01:00
jschoubben 680da05c4c Ask git ls-tree for nested named paths in rounds: given go and go/seats/x together it lists go's children, so mesh-contracts' preview failed (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 19:38:50 +01:00
jschoubben d8af436536 Pin mesh-sdk 05b3e2e1b94f and mesh-contracts ae5266e77639, the group's final heads, in every Go module (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:46:38 +01:00
jschoubben 1f9b130225 mesh-vault: secret_own's description says it lists the own secrets of a machine, so a search for those words finds it (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:42:41 +01:00
jschoubben 5ab349df6c Pin mesh-sdk e5d89c5eedb5 and mesh-contracts 347a3053580a, the group's heads after review round 2, in every Go module (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:25:28 +01:00
jschoubben c50a7d059a Merge branch 'wip/registry-gcat-b' into feat/registry-milestone 2026-11-04 18:21:56 +01:00
jschoubben c275919f11 mesh-conditions: a home for view-unsaid, every views owner's own
mesh-sdk's views.UnsaidFinding (id <owner>.<state>) is written by the indexer and the assigner in their own findings;
the closed set finds a catalogue writer of it too. nats.status-unwritten is said to the keeper directly, not a finding:
no row.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:18:23 +01:00
jschoubben 4a3d7b60a1 G-catalog-B 6: on registry 2.8.3 a shared-digest tagged version's remove answers and says kept
Saying removed for a version the store still holds told the builder something false. remove now answers
kept with the reason shared-digest and the other tags naming its digest, removed false, and says the
seat's event kept with them (mesh-contracts' shared registry part); the builder raises no retry.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:15:24 +01:00
jschoubben f3983ed473 nats: the manifest test reads spent read-only through the SDK's own decoding
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:14:43 +01:00
jschoubben 480556a329 G-catalog-B 8: the retire removes only the teams the npm pair provisioner made
A person who later makes a team named packages or readers would have lost it at the next start. A team
counts as the provisioner's only in its organisation and when every member is one of its accounts (it
put its own accounts there alone, and they go with the first run), so a team made later stays.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:13:44 +01:00
jschoubben ac29f20517 G-catalog-B 7: a transfer dropped by hand is recorded through mesh-hand-acts first
registry_incoming, npm_incoming and go_incoming dropped a transfer by hand with only a log line. Each
drop now records its hand act through the seat mesh-hand-acts before anything goes, as nats_requests
does; an act not recorded leaves the transfer open. distribution's manifest invokes the seat.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:13:44 +01:00
jschoubben f86a1f2a36 nats: a warrant's spend record in the keeper's own state spent; the fill pass while the consumer binds; partial says kept
Review round 2 of the registry milestone:
- security F2: the spend record leaves requests, which the tools write as the tool runner's account, for state spent,
  named in the keeper's bus.state and declared read-only (the lead's 18:1x ruling: the tools read it, never write);
  nats_requests lists the warrants spent from it;
- running F1: the fill pass also runs while the manifest-accepted consumer is bound again;
- health note 3: an event said on the seat's subject and refused by its history is kept with its one id and said again
  on the history alone, never again with a new id;
- seams note 3, health note 4: words unsaid with a status that cannot be written raise the keeper's own condition
  status-unwritten, cleared when it is written; the health names what a restart would lose.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:13:36 +01:00
jschoubben dad2708805 G-catalog-B 5: distribution and gitea look at every open transfer at the idle watch's start
A transfer whose idle key ended while the bundle was down was never dropped. Both hand mesh-sdk's
FollowIdle their open transfers, which it gives Expired once the watch is taken.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:09:42 +01:00
jschoubben 80cdcbfa2f Pin mesh-sdk c54648e74346 and mesh-contracts ec9529022c44, the group's heads, in every Go module (issue 1966)
Every module that imports mesh-sdk requires its group head; each requires the mesh-contracts seats and clients it
imports, and mesh-contracts/go through the SDK's seat list. The placeholder versions are gone.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:43:43 +01:00
jschoubben 4b93dbb04e Merge branch 'wip/registry-fcat-b' into feat/registry-milestone 2026-11-04 17:34:55 +01:00
jschoubben ddcd790304 F-parts: on registry 2.8.3 a shared-digest tagged version's remove keeps the tag and says removed
The store's pinned image is registry 2.8.3, whose DeleteManifest cannot delete a tag alone: by a tag it
deletes the empty digest and answers 400 DIGEST_INVALID. The fakes accepted a DELETE by tag, a fixture
agreeing with the mistake, so every merge of a reproducible build would have been a refused remove and a
retry finding live. RemoveTag now answers ErrNoTagAlone for that answer; remove then keeps the tag, whose
bytes stay under the other tag anyway, and says removed with that reason, so the builder lets it go. Any
other store failure is still refused; a digest shared with nothing is still deleted by digest. The fakes
answer as 2.8.3 does.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:30:50 +01:00
jschoubben 34269df920 F-parts: distribution and gitea bind mesh-sdk's idle key and binding cap instead of their own copies
Both holders carried the same idle bound and the same read of incoming's cap; the second instance of the
kind moved into mesh-sdk's Receiver (Arm, Expired, Bound, with BusArm, BusCap and FollowIdle). Each
holder now hands its idle state and its objects to those, and keeps its rows.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:26:11 +01:00
jschoubben 1af1a7b002 nats: the keeper wakes on decided, spends a warrant once, and says again what the bus did not take
Review round 1 of the registry milestone (design 25 § 12.2, § 12.3, § 12.7):
- the fill pass runs from the start, while the first view is asked;
- recreate and drop wake on the operator channel's decided on the keeper's durable consumer, never a minute's tick;
- a warrant is spent by compare-and-set (acted.<ask id> in requests) before anything is deleted; a second request on it
  is refused; a request whose ask ended unanswered is ended, said; the ask is raised before the request is handed;
- created is marked only once said; an event or a condition the bus did not take is kept, named by the health and
  said again at the next fill pass with its one id, on the subjects left (the double publish);
- the health judges the first view past its bound and never skips an unread consumer backlog;
- an unrecordable hand act is said on the request; nats_requests reads what waits, what ended and each warrant spent,
  and drops a waiting request by hand; a tool waits for its request's end on one watch of requests;
- the whole-module test reads the warrant from the router's record under a process connection.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:19:54 +01:00
jschoubben b5644829d3 F-catalog-B: the catalogue's Go is as gofmt writes it, and checks/gofmt holds it so
buildstep's generated.go and generated_test.go and claude-code's config_test.go reached the join
unformatted: the second of a kind, which no check caught since each module's tests hold only themselves.
All three are formatted, and checks/gofmt refuses a Go file of the catalogue gofmt would change.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:18:45 +01:00
jschoubben cd3e444a76 F-catalog-B: no ignore pattern hides a module's source; nfs-server's and restic's patterns anchored
A bare pattern meant for a binary (mesh-build-node) matched the command directory of the same name, so git
ignored every new file there and publish.go was never committed (catalogue review F1). The kind: every
bare pattern of a module's .gitignore is anchored to the binary (nfs-server's hid all of cmd/nfs-server),
and checks/gitignored refuses an ignore pattern, the module's or the catalogue's, that hides a directory
under a module's cmd/ or internal/, a Go file added there, or a source file there, in a git checkout and a
plain folder alike.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:17:54 +01:00
jschoubben ce989b001a F-catalog-B: the gitea module removes the npm pair's teams packages and readers and their accounts
ADR 0605 §5 revokes the npm write token, and the 14:04 deferral is withdrawn (security review F5): the
accounts the removed pair provisioner made (mesh_<node>_<consumer>) still logged in with write on the
organisation's packages. cmd/agent-teams, run at each start of the module and change of its teams value,
removes both teams and each account of that form whose only teams they are, never purging: one the forge
refuses to delete because it owns something keeps it and has its login prohibited.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:15:41 +01:00
jschoubben 7a60771165 F-catalog-B: npm_incoming and go_incoming read and drop the package seats' transfers; an idle one drops by its key
The package seats kept incoming, transfers and findings with no read or drop tool (tools review F4).
npm_incoming and go_incoming list a seat's open transfers, incoming's fill and the cap its binding carries,
and drop one by hand. The same fault as distribution's (running review F2, its second instance): the
transfers state's own lifetime dropped a transfer's key and left its chunks, and the drop otherwise waited
for the next publish. transfers keeps for good; each open transfer has a key in the new state idle with its
own time to live, whose end drops it. The holder counts against its binding's cap.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:14:04 +01:00
jschoubben 6cd4aedb7c F-catalog-B: registry_mirrors reads distribution's mirrors, asks one again and drops one by hand
The state mirrors had no read tool and no ask-again (seams review note 1): a copy a follower holds and
does not end could only wait for a restart. registry_mirrors lists each kept copy and its claim, asks one
again with its claim let go, and drops one, saying refused under the ask's id so its waiter ends.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:10:09 +01:00
jschoubben 4c4abb1b66 mesh-conditions: observe node-tools' and claude-code's first views and the builder's publishing kinds
Their views findings were written and raised by nobody (ADR 0615 §6). The test finds every catalogue module
writing a views finding in the tree, so a new writer cannot go unobserved; homes for publish-refused,
publish-unanswered, seat-outcome-unread and family-not-heard.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:09:58 +01:00
jschoubben b4ab61c0aa F-catalog-B: distribution counts incoming against its binding's cap and drops an idle transfer by its expiring key
The holder counted against 20 GiB while the bus caps incoming at 8 GiB, so the bus refused writes before
the holder said refused naming the cap, and the 80 % finding came too late (running review F2). The cap is
read from incoming's binding (the object store's fill); IncomingCap, 8 GiB, is said only where none is read,
and the manifest's row holds it equal to max-mib. An abandoned transfer was dropped only on the next publish,
and the transfers state's own lifetime dropped its key while its parts stayed: transfers keeps for good, and
each open transfer has a key in the new state idle with its own time to live, whose end drops it (re-armed
for the rest when it took a part since). registry_incoming reports the bound cap.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:09:17 +01:00
jschoubben 39cd545814 F-catalog-B: distribution's windows step by mesh-sdk's delivery.StepWindow and say why a node holds one
A read naming no node (an empty node_haves) closed both windows for good (security review F3, second of
a kind): the window embeds delivery.WindowHeld and steps by the shared rule, named being every node the
read named, and keeps its own guard that a window once closed stays closed. A node-engine whose
manifest-reads did not answer (a timeout included) counts as stating nothing as before, and the window's
record now says so per node (Why), so registry_old_name tells a timeout from an old node-engine. A read
that fails otherwise is an error the wake judges nothing on, no longer taken as not served.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:07:10 +01:00
jschoubben 5ef6287ce4 mesh-config: the whole test waits on the module's views subscriptions, never a second's sleep
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:05:55 +01:00
jschoubben 7cd9b611d2 F-catalog-B: distribution's front takes no bootstrap credential
The lead's ENG-5 ruling: on a fresh mesh the bootstrap pushes the holders' own images to the store on its
loopback with no credential, so the front's bootstrap token, its file, the bootstrap-push window and the
program's removal of the file go. Fewer secrets; nothing to refuse once a window closes.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:05:24 +01:00
jschoubben 1f79a4d52a F-catalog-B: distribution removes a tagged version's tag alone when another tag names its digest
Deleting the manifest by its digest removed every tag on it, and reproducible builds share a digest
between a branch's tagged version and the trunk's numbered one, so a branch's end deleted the trunk's
image (security review F1). The fake catalog now deletes by digest as the store does; a store that
cannot delete a tag alone removes nothing and the removal is refused.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:04:46 +01:00
jschoubben 65c2d76506 claude-code: drop a committed go.work.sum, a local workspace leftover
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:04:07 +01:00
jschoubben d3fa102e4d checks/manifests refuses a manifest giving one field twice
A decoder keeps the last of two equal keys without a word, so a merge
of two branches that each add a field to a manifest loses one side
silently; the indexer's judge reads the same bytes. Rows: a top-level
field and a field inside an artifact, each named by its path.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:45:23 +01:00
jschoubben fd908a444d Join: mesh-build-go's manifest gives invokes once; gitea's row names all three
A3's merge auto-merged a second invokes into mesh-build-go's manifest
(the registry seats' publish verbs beside the indexer's read tool); a
decoder kept the last and dropped the read tool, which
TestTheModuleReadsTheIndexersManifests caught. One list holds both.
gitea's package-registry row named only the builder's versions; the
manifest also asks the indexer's modules and records a re-read.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:45:23 +01:00
jschoubben b4e7ffd89c Join: mesh-knowledge's page address is no word of the environment
The overview page read MESH_KNOWLEDGE_PAGE, which nothing in the mesh
sets; only its bus test did, to serve no page. checks/environment,
which came with A3's merge, refuses such a read. The page listens on
the port its manifest declares; the test's bundle sets the package's
address empty instead.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:43:28 +01:00
jschoubben ad671183bf Join: merge origin/main (#611, a closing reviewed on its closing alone)
Conflict in mesh-knowledge's reviewOne: the join judges a record in
parts, #611 asks a closing only of the records its judged links name,
with the status it moves from. Both kept: a closing is one part,
ClosingPart, asked #611's ClosingQuestion over the named records.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:39:22 +01:00
jschoubben d3d4dbc8ed Join: merge A3 (wip/registry-a3)
Conflicts, both sides kept: artifact-store-tools' invokes (the assigner's
and indexer's read tools, followed through views, beside the registry
seats' hold, release and remove), its main (the views reader and the
seat asker), its manifest row; gitea's invokes (the indexer's read tool,
the hand act and the builder's versions) and its consumes of the
indexer's views. gitea's account test takes the packages account's
names; repoCheckFacts, removed on main, is not imported. No catalogue
module reads the indexer's state.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:38:20 +01:00
jschoubben 05dd17ebaa J7: the gate reads the ask it keeps through operator-channel's client (occ.Carried)
askOf keeps its key step and holds the carried ask to its id and an option with
occ.Carried, the one reader the assigner, teller and view use; its own decode, id and
option checks are gone. Rows in TestTheGateRefusesAnAskWithAnotherIdOrNoOption name the
client's refusals and a value carrying no ask; they failed with the gate's own checks.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:49:38 +01:00
jschoubben e71b006d85 Join: merge origin/main (#610, the knowledge base's close verb)
Conflicts in mesh-knowledge: main.go Verbs and manifest_test.go, A6's draft verbs and
#610's close, both kept in the manifest's order; review.go reviewOne, A6 judges a version
in parts and #610 judges a closing on its closing alone: a closing version is one part
(ClosingPart) asked ClosingQuestion. Joined: close.go's asks import by the rewrite map,
and its rules probe names its overview section "Overview", since A6's section-form rule
refuses a file-named section (close's test failed on it).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:48:32 +01:00
jschoubben 725eea8a68 Join: mesh-knowledge's progress bus test counts the page's first view
At the joined SDK the bundle is served in the test, so ServePage's first view asks the
tracker's list and milestones and mesh-delivery's groups once beside progress (checked:
with the page's start skipped the counts are the old 3, 1, 3).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:42:35 +01:00
jschoubben 3f39b60d9a Join: mesh-config's whole test reads the subject the runtime publishes on from the manifest
The seat definition names mesh-config's renamed, retiring and retired on its own stream
(mesh.stream, A1c), and the runtime publishes an event the module's stream carries
there; the test assumed EVENTS (mesh.mod) for every event and failed at the joined heads.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:40:33 +01:00
jschoubben 880de92bc8 Join item 6: no compiled program in the tree, and the health count written down
A8's CAT-RD4 and CAT-RD8 committed the mesh-conditions and minio binaries
(checks/nobinaries): removed and ignored. The long-running resources without health fell
to 57 in the joined tree (checks/health ratchet).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:38:12 +01:00
jschoubben 6ec562e964 Join item 6: gitea announces its findings readable, which the condition keeper reads
A8 gave gitea a findings state and the keeper its read; gitea's announcement did not name
it, so the read was refused on the mesh (checks/readable) and its own kept test failed.
Rows in test/kept.test.ts; they failed before the change.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:38:12 +01:00
jschoubben 2c807a3800 Join item 1: claude-code requires the mesh-contracts modules it imports, for the group pin
Its seat set (mesh-contracts/go, A6's CC2) and the operator-channel contract and client
were imported with no require; the root workspace hid it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:34:11 +01:00
jschoubben 2770a55b61 Join item 2: mesh-knowledge's ledger pipe, call ids and page heartbeat come from the SDK
The ledger pipe asks the runtime through stdio.BusState (it answered ErrNoPipe before), an
operation names the call the runtime proved (stdio.CallIDOf), and the page takes the
runtime's word on its consumer (stdio.OnHeartbeat): a beat is every subscription heard, a
missed heartbeat marks each subscribed source at once with the runtime's why. Rows in
TestASourceThatDoesNotAnswerIsSaidAndAskedAgainOnABackoff and
TestTheLedgerStoreRefusesWhatTheLedgerWouldHaveToTakeBack; each failed before the change.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:33:35 +01:00
jschoubben 9d83987815 Join item 1: the catalogue's modules take the router's ask from operator-channel's contract and client
mesh-sdk holds neither asks nor asker, nor the vault's answer types, and mesh-delivery's
sdk/views is mesh-sdk's go/views (a1-progress.md, the rewrite map for the join): thirteen
modules switch to the operatorchannel contract and client, mesh-sdk's channel package and
meshvault, with go.mod requiring each at the group pin (nats: meshbroker too). The gate
holds the checks mesh-sdk dropped (an ask's id is its key's asking, at least one option),
with a row that fails without them; claude-code refuses a router record whose times do
not parse, as its JSON decode did, with a row.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:33:27 +01:00
jschoubben 87a30055a6 Join item 3: the condition keeper reads every finding the registry milestone added
The builder's four kinds, the indexer's four, the package seats' token refused and the
owners' view too large get their homes; the keeper reads the findings of every reader
of the indexer's and the assigner's state (teller, enrolment, builder, checker, view),
and asks the indexer's findings of its read tool and follows its views events, as the
assigner's (ADR 0615 §1). Rows in TestEveryControllerKindHasItsHome and the manifest
test; each failed before the change.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:19:13 +01:00
jschoubben a46e52000b Join: merge A2 (wip/registry-a2) 2026-11-04 15:12:01 +01:00
jschoubben aad5c6ceea Join: merge A6 (wip/registry-a6)
Conflict in mesh-knowledge main.go, the propose tool's description: #609 (79c2fc7f)
names ideas and the research-active-at-most limit, A6 names a research effort's
partial new version; both sentences kept, A6's rule count (31).
2026-11-04 15:12:01 +01:00
jschoubben c295176048 Join: merge A5 (wip/registry-a5) 2026-11-04 15:11:21 +01:00
jschoubben ea4e22577c Join: merge A8 (wip/registry-a8)
Conflict in mesh-conditions keeper.go Observers and module.json reads: origin/main
added mesh-vault's findings, A8 added the first-view readers' findings; both kept.
2026-11-04 15:11:21 +01:00
jschoubben bcac2d6d1c CAT-RD2: gitea says an unanswered first view in its findings and records the re-read through mesh-hand-acts 2026-11-04 12:24:11 +01:00
jschoubben 4d597eb9dc CAT-RD1..RD10: the readers use mesh-sdk's views.Reader, say an unanswered first view in their own findings (mesh-conditions observes them) and record a re-read through mesh-hand-acts 2026-11-04 12:23:37 +01:00
jschoubben 7843edb1eb CAT-RD10: the deprecated mesh-catalog module reads none of the indexer's state; it registers builds without manifests, said once (ruling U4) 2026-11-04 12:16:37 +01:00
jschoubben b00a826681 CAT-RD2: gitea asks the indexer's modules once and follows its events (TypeScript follower), no reads; mesh-build-go's manifest text kept as it was 2026-11-04 12:14:56 +01:00
jschoubben d618cbb9ca CAT-RD3: mesh-build-go asks the indexer's read tool for the manifest a compose ask names, no reads of the indexer 2026-11-04 12:12:01 +01:00
jschoubben 550bb4fdd1 CAT-RD5: mesh-provisioner asks the assigner's placement and wants and the indexer's modules and manifests once and follows their events, no reads 2026-11-04 12:11:18 +01:00
jschoubben f62c2ff380 CAT-RD4: mesh-conditions asks the assigner's wants, placement and findings once and follows their events, no reads (on CAT-C10's base) 2026-11-04 12:10:09 +01:00
jschoubben 2339ac5af7 CAT-RD1: artifact-store-tools asks the assigner's wants and the indexer's manifests once and follows their events, no reads 2026-11-04 12:09:12 +01:00
jschoubben 5bc05201ef CAT-RD6: mesh-self-check asks the owners' placement, seat-holders, modules and manifests once and follows their events, no reads 2026-11-04 12:08:14 +01:00
jschoubben 9e726d4c41 CAT-RD9: nfs-server asks the assigner's seat-holders once and follows its events, no reads 2026-11-04 12:07:07 +01:00
jschoubben aff9d6ae6a CAT-RD8: minio asks the assigner's seat-holders once and follows its events, no reads, no cache on a timer 2026-11-04 12:06:34 +01:00
744 changed files with 11364 additions and 6757 deletions
+229 -42
View File
@@ -1,49 +1,236 @@
node_modules/
dist/
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
modules/pacman/pacman-tools
modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
modules/messenger/cmd/messenger/messenger
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
modules/mesh-conditions/mesh-conditions
modules/minio/object-store
modules/claude-code/cmd/claude-code/claude-code
modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
modules/messenger/messenger
modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
modules/mesh-reads/mesh-reads
modules/mesh-reads/cmd/mesh-reads/mesh-reads
modules/mesh-files/mesh-files
modules/mesh-files/cmd/mesh-files/mesh-files
# The provisioner client built in place (issue 671).
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
modules/brother/cmd/brother/brother
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
modules/mesh-config/mesh-config
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
*.test
# binaries a local go build leaves in a module root
/checks/paths/paths
# Every program a local `go build` leaves, in its main package's directory and at its Go module's root, each anchored to
# its one path so it never hides source (issue 1577: twice in one milestone a module's program was committed). The
# checks/gitignored test refuses a main package whose program no ignore file names: add its line here.
/checks/agenthome/agenthome
/checks/config/config
/checks/deps/deps
/checks/environment/environment
/checks/imagebuild/imagebuild
/checks/imageplace/imageplace
/checks/locks/locks
/checks/mailstore/mailstore
/checks/noplacement/noplacement
/checks/owned/owned
/checks/particulars/particulars
/checks/paths/paths
/checks/predefined/predefined
/checks/readable/readable
/checks/removedpackages/removedpackages
/checks/secrets/secrets
/checks/sshdir/sshdir
/checks/templates/templates
/checks/toolchains/toolchains
/checks/words/words
/modules/adwaita/adwaita-tools
/modules/adwaita/cmd/adwaita-tools/adwaita-tools
/modules/artifact-store-tools/artifact-store-tools
/modules/artifact-store-tools/cmd/artifact-store-tools/artifact-store-tools
/modules/asus-zephyrus-g14/cmd/zephyrus/zephyrus
/modules/asus-zephyrus-g14/zephyrus
/modules/avahi/avahi-tools
/modules/avahi/cmd/avahi-tools/avahi-tools
/modules/blueman/blueman-tools
/modules/blueman/cmd/blueman-tools/blueman-tools
/modules/bluetooth/bluetooth-tools
/modules/bluetooth/cmd/bluetooth-tools/bluetooth-tools
/modules/brother/brother
/modules/brother/cmd/brother/brother
/modules/claude-code/claude-code
/modules/claude-code/cmd/claude-code/claude-code
/modules/claude-licence-manager/claude-licence-manager
/modules/claude-licence-manager/cmd/claude-licence-manager/claude-licence-manager
/modules/clipmenu/clipmenu-tools
/modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools
/modules/cups/cmd/cups-tools/cups-tools
/modules/cups/cups-tools
/modules/dbus/cmd/dbus-tools/dbus-tools
/modules/dbus/dbus-tools
/modules/desk-channel/cmd/desk-channel/desk-channel
/modules/desk-channel/desk-channel
/modules/disk-load/cmd/disk-load/disk-load
/modules/disk-load/disk-load
/modules/distribution/cmd/distribution-tools/distribution-tools
/modules/distribution/cmd/distribution/distribution
/modules/distribution/distribution
/modules/distribution/distribution-tools
/modules/dmenu/cmd/dmenu-tools/dmenu-tools
/modules/dmenu/dmenu-tools
/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
/modules/dnsmasq/dnsmasq-tools
/modules/docker-compose/cmd/docker-compose-tools/docker-compose-tools
/modules/docker-compose/docker-compose-tools
/modules/docker/cmd/docker-tools/docker-tools
/modules/docker/docker-tools
/modules/dunst/cmd/dunst-tools/dunst-tools
/modules/dunst/dunst-tools
/modules/fail2ban/cmd/fail2ban-tools/fail2ban-tools
/modules/fail2ban/fail2ban-tools
/modules/feh/cmd/feh-tools/feh-tools
/modules/feh/feh-tools
/modules/flatpak/cmd/flatpak-tools/flatpak-tools
/modules/flatpak/flatpak-tools
/modules/fonts/cmd/fonts-tools/fonts-tools
/modules/fonts/fonts-tools
/modules/forticlient/cmd/forticlient-tools/forticlient-tools
/modules/forticlient/forticlient-tools
/modules/gitea/agent-teams
/modules/gitea/agent-token
/modules/gitea/cmd/agent-teams/agent-teams
/modules/gitea/cmd/agent-token/agent-token
/modules/gitea/cmd/forge-account-provider/forge-account-provider
/modules/gitea/cmd/forge/forge
/modules/gitea/cmd/package-registry/package-registry
/modules/gitea/cmd/repositories/repositories
/modules/gitea/forge
/modules/gitea/forge-account-provider
/modules/gitea/package-registry
/modules/gitea/repositories
/modules/gnome-keyring/cmd/gnome-keyring-tools/gnome-keyring-tools
/modules/gnome-keyring/gnome-keyring-tools
/modules/hostname/cmd/hostname-tools/hostname-tools
/modules/hostname/hostname-tools
/modules/i3/cmd/i3-tools/i3-tools
/modules/i3/i3-tools
/modules/i3status-rust/cmd/i3status-rust-tools/i3status-rust-tools
/modules/i3status-rust/i3status-rust-tools
/modules/kernel/cmd/kernel-tools/kernel-tools
/modules/kernel/kernel-tools
/modules/keycloak/cmd/keycloak-provider/keycloak-provider
/modules/keycloak/keycloak-provider
/modules/lemurs/cmd/lemurs-tools/lemurs-tools
/modules/lemurs/lemurs-tools
/modules/localization/cmd/localization-tools/localization-tools
/modules/localization/localization-tools
/modules/logrotate/cmd/logrotate-tools/logrotate-tools
/modules/logrotate/logrotate-tools
/modules/mailu/automx
/modules/mailu/cmd/automx/automx
/modules/memory-pressure/cmd/memory-pressure/memory-pressure
/modules/memory-pressure/memory-pressure
/modules/mesh-build-go/buildstep/cmd/mesh-pack/mesh-pack
/modules/mesh-build-go/buildstep/cmd/seatgen/seatgen
/modules/mesh-build-go/buildstep/mesh-pack
/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
/modules/mesh-build-go/mesh-build-go
/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
/modules/mesh-build-image/mesh-build-image
/modules/mesh-build-node/cmd/mesh-build-node/mesh-build-node
/modules/mesh-build-node/mesh-build-node
/modules/mesh-cli/cmd/mesh-cli/mesh-cli
/modules/mesh-cli/mesh-cli
/modules/mesh-conditions/cmd/mesh-conditions/mesh-conditions
/modules/mesh-conditions/mesh-conditions
/modules/mesh-config/cmd/mesh-config/mesh-config
/modules/mesh-config/mesh-config
/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/mesh-delivery-gate
/modules/mesh-delivery-gate/mesh-delivery-gate
modules/mesh-config/cmd/mesh-config/mesh-config
# Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools
modules/networkmanager/uplink-tools
checks/words/words
modules/mesh-cli/mesh-cli
modules/mesh-cli/cmd/mesh-cli/mesh-cli
/modules/mesh-files/cmd/mesh-files/mesh-files
/modules/mesh-files/mesh-files
/modules/mesh-hand-acts/cmd/mesh-hand-acts/mesh-hand-acts
/modules/mesh-hand-acts/mesh-hand-acts
/modules/mesh-healers/cmd/mesh-healers/mesh-healers
/modules/mesh-healers/mesh-healers
/modules/mesh-issues/cmd/mesh-issues/mesh-issues
/modules/mesh-issues/mesh-issues
/modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
/modules/mesh-knowledge/mesh-knowledge
/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mesh-mail-notifier
/modules/mesh-mail-notifier/mesh-mail-notifier
/modules/mesh-mail-reader/cmd/mesh-mail-reader/mesh-mail-reader
/modules/mesh-mail-reader/mesh-mail-reader
/modules/mesh-mail-web/cmd/mesh-mail-web/mesh-mail-web
/modules/mesh-mail-web/mesh-mail-web
/modules/mesh-mail/cmd/mesh-mail/mesh-mail
/modules/mesh-mail/mesh-mail
/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
/modules/mesh-provisioner-client/mesh-provisioner-client
/modules/mesh-provisioner/cmd/mesh-provisioner/mesh-provisioner
/modules/mesh-provisioner/mesh-provisioner
/modules/mesh-reads/cmd/mesh-reads/mesh-reads
/modules/mesh-reads/mesh-reads
/modules/mesh-self-check/cmd/mesh-self-check/mesh-self-check
/modules/mesh-self-check/mesh-self-check
/modules/mesh-vault/cmd/mesh-vault/mesh-vault
/modules/mesh-vault/mesh-vault
/modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
/modules/mesh-watcher/mesh-watcher
/modules/messenger/cmd/messenger/messenger
/modules/messenger/messenger
/modules/minio/cmd/object-store/object-store
/modules/minio/object-store
/modules/mounts/cmd/mounts/mounts
/modules/mounts/mounts
/modules/nats/cmd/mesh-nats-snapshot/mesh-nats-snapshot
/modules/nats/cmd/nats-tools/nats-tools
/modules/nats/mesh-nats-snapshot
/modules/nats/nats-tools
/modules/netcheck/cmd/netcheck/netcheck
/modules/netcheck/netcheck
/modules/networkmanager/cmd/uplink-tools/uplink-tools
/modules/networkmanager/uplink-tools
/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud-client-tools
/modules/nextcloud-client/nextcloud-client-tools
/modules/nfs-server/cmd/nfs-server/nfs-server
/modules/nfs-server/nfs-server
/modules/nm-applet/cmd/nm-applet-tools/nm-applet-tools
/modules/nm-applet/nm-applet-tools
/modules/only-office/cmd/only-office/only-office
/modules/only-office/only-office
/modules/openrazer/cmd/openrazer-tools/openrazer-tools
/modules/openrazer/openrazer-tools
/modules/pacman/cmd/pacman-tools/pacman-tools
/modules/pacman/pacman-tools
/modules/picom/cmd/picom-tools/picom-tools
/modules/picom/picom-tools
/modules/polychromatic/cmd/polychromatic-tools/polychromatic-tools
/modules/polychromatic/polychromatic-tools
/modules/postgres/cmd/postgres-provider/postgres-provider
/modules/postgres/postgres-provider
/modules/power/cmd/power/power
/modules/power/power
/modules/records/cmd/records/records
/modules/records/records
/modules/restic/cmd/restic-backups/restic-backups
/modules/restic/restic-backups
/modules/rofi/cmd/rofi-tools/rofi-tools
/modules/rofi/rofi-tools
/modules/route-adapter/cmd/route-provider/route-provider
/modules/route-adapter/route-provider
/modules/screen-lock/cmd/screen-lock-tools/screen-lock-tools
/modules/screen-lock/screen-lock-tools
/modules/sensors/cmd/sensors/sensors
/modules/sensors/sensors
/modules/slack/cmd/slack-tools/slack-tools
/modules/slack/slack-tools
/modules/snapd/cmd/snapd-tools/snapd-tools
/modules/snapd/snapd-tools
/modules/ssh-client/cmd/ssh-client-tools/ssh-client-tools
/modules/ssh-client/ssh-client-tools
/modules/sudo/cmd/sudo-tools/sudo-tools
/modules/sudo/sudo-tools
/modules/systemd-networkd/cmd/uplink-tools/uplink-tools
/modules/systemd-networkd/uplink-tools
/modules/systemd-resolved/cmd/resolver-tools/resolver-tools
/modules/systemd-resolved/resolver-tools
/modules/systemd/cmd/systemd-tools/systemd-tools
/modules/systemd/systemd-tools
/modules/telegram/cmd/telegram/telegram
/modules/telegram/telegram
/modules/time-sync/cmd/time-sync-tools/time-sync-tools
/modules/time-sync/time-sync-tools
/modules/triggerhappy/cmd/triggerhappy-tools/triggerhappy-tools
/modules/triggerhappy/triggerhappy-tools
/modules/xclip/cmd/xclip-tools/xclip-tools
/modules/xclip/xclip-tools
/modules/xdg/cmd/xdg-tools/xdg-tools
/modules/xdg/xdg-tools
/modules/xorg/cmd/xorg/xorg
/modules/xorg/xorg
/modules/xterm/cmd/xterm-tools/xterm-tools
/modules/xterm/xterm-tools
/modules/zfs/cmd/zfs-tools/zfs-tools
/modules/zfs/zfs-tools
+1 -1
View File
@@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/config
go 1.22
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
+1 -1
View File
@@ -39,7 +39,7 @@ func TestRefusalsJudgeATree(t *testing.T) {
{"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""},
{"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""},
{"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""},
{"the seat's successors pass", map[string]string{"modules/g/module.json": `{"module":"g","reads":["mesh-placement.placement"],"invokes":["seat:mesh-hand-acts.hand-act"]}`}, ""},
{"the seat's successors pass", map[string]string{"modules/g/module.json": `{"module":"g","reads":["mesh-module-assigner.assignments"],"invokes":["seat:mesh-hand-acts.hand-act"]}`}, ""},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
+1 -2
View File
@@ -2,6 +2,5 @@ module checks/credentialform
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
-4
View File
@@ -1,4 +0,0 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
+168
View File
@@ -122,3 +122,171 @@ func Find(root string, dirs []string) ([]Hidden, error) {
}
return hidden, nil
}
// Programs are the paths `go build` writes a compiled program to, for every main package of every Go module under
// root (issue 1577, met twice in one milestone: a module's program committed with `git add -A`): `go build` in the
// package's directory writes it there, and `go build ./<dir>` from the module's root writes it at the root, each named
// after the package's import path. Relative to root, sorted; directories named node_modules, testdata or vendor, and
// .git, are left out, and so is a path that is a directory of the tree.
func Programs(root string) ([]string, error) {
var mods []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && skipped(d.Name()) && p != root {
return filepath.SkipDir
}
if !d.IsDir() && d.Name() == "go.mod" {
mods = append(mods, filepath.Dir(p))
}
return nil
})
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, m := range mods {
modPath, err := modulePath(filepath.Join(m, "go.mod"))
if err != nil {
return nil, err
}
err = filepath.WalkDir(m, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if p != m {
if skipped(d.Name()) {
return filepath.SkipDir
}
if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil {
return filepath.SkipDir // a module of its own, walked on its own
}
}
isMain, err := mainPackage(p)
if err != nil || !isMain {
return err
}
name := filepath.Base(p)
if p == m {
name = pathBase(modPath)
}
for _, dir := range []string{p, m} {
out := filepath.Join(dir, name)
if st, err := os.Stat(out); err == nil && st.IsDir() {
continue // go build refuses to write over a directory, so no program lands there
}
rel, _ := filepath.Rel(root, out)
seen[filepath.ToSlash(rel)] = true
}
return nil
})
if err != nil {
return nil, err
}
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out, nil
}
// Unignored names each of programs (paths from root) that root's ignore files do not ignore: a program a local build
// leaves there that `git add -A` would commit.
func Unignored(root string, programs []string) ([]string, error) {
gitDir, err := os.MkdirTemp("", "gitignored-")
if err != nil {
return nil, err
}
defer os.RemoveAll(gitDir)
if out, err := exec.Command("git", "init", "-q", "--bare", gitDir).CombinedOutput(); err != nil {
return nil, fmt.Errorf("git init: %v: %s", err, out)
}
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
cmd := exec.Command("git", "--git-dir="+gitDir, "--work-tree="+abs, "-c", "core.excludesFile=/dev/null",
"check-ignore", "--no-index", "-v", "-n", "-z", "--stdin")
cmd.Dir = abs
cmd.Stdin = strings.NewReader(strings.Join(programs, "\x00") + "\x00")
out, err := cmd.Output()
if ee, ok := err.(*exec.ExitError); !(err == nil || ok && ee.ExitCode() == 1) {
return nil, fmt.Errorf("git check-ignore: %w", err)
}
// -v -n -z answers source, line, pattern and path for every path asked; the source is empty for one no pattern
// matches, and a pattern starting with ! keeps the path.
f := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00")
var open []string
for i := 0; i+3 < len(f); i += 4 {
if f[i+2] == "" || strings.HasPrefix(f[i+2], "!") {
open = append(open, f[i+3])
}
}
return open, nil
}
func skipped(name string) bool {
switch name {
case ".git", "node_modules", "testdata", "vendor":
return true
}
return false
}
// modulePath reads the module line of a go.mod.
func modulePath(goMod string) (string, error) {
b, err := os.ReadFile(goMod)
if err != nil {
return "", err
}
for _, l := range strings.Split(string(b), "\n") {
if f := strings.Fields(l); len(f) >= 2 && f[0] == "module" {
return strings.Trim(f[1], `"`), nil
}
}
return "", fmt.Errorf("%s names no module", goMod)
}
// pathBase is the name `go build` gives a program at a module's root: its path's last element, a major version
// suffix (/v2) left out.
func pathBase(modPath string) string {
parts := strings.Split(modPath, "/")
last := parts[len(parts)-1]
if len(parts) > 1 && len(last) > 1 && last[0] == 'v' && strings.Trim(last[1:], "0123456789") == "" {
last = parts[len(parts)-2]
}
return last
}
// mainPackage is whether dir holds a Go file, not a test, of package main.
func mainPackage(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, e := range entries {
n := e.Name()
if e.IsDir() || filepath.Ext(n) != ".go" || strings.HasSuffix(n, "_test.go") {
continue
}
b, err := os.ReadFile(filepath.Join(dir, n))
if err != nil {
return false, err
}
for _, l := range strings.Split(string(b), "\n") {
if f := strings.Fields(l); len(f) >= 2 && f[0] == "package" {
if f[1] == "main" {
return true, nil
}
break
}
}
}
return false, nil
}
+78
View File
@@ -78,3 +78,81 @@ func TestNoIgnorePatternHidesAModulesSource(t *testing.T) {
t.Errorf("%s is hidden by %s: a module's source is never ignored; anchor the pattern to the binary (/<name>, /cmd/<name>/<name>)", h.Path, h.Pattern)
}
}
// The kind of issue 1577, met twice in one milestone: a module's program `go build` left in its folder was committed.
// Every program a main package builds, in its own directory and at its module's root, is found, and one no ignore file
// names is open; a directory of the same name is no program's place.
func TestEveryProgramABuildLeavesIsIgnored(t *testing.T) {
files := map[string]string{
"modules/m/go.mod": "module example.com/m/v2\n",
"modules/m/main.go": "// a program\npackage main\n",
"modules/m/cmd/m-tools/main.go": "package main\n",
"modules/m/cmd/m-tools/main_test.go": "package main\n",
"modules/m/internal/x/x.go": "package x\n",
"modules/m/internal/x/x_test.go": "package main\n",
"modules/m/node_modules/y/y.go": "package main\n",
"modules/m/tool/go.mod": "module tool\n",
"modules/m/tool/cmd/tool/main.go": "package main\n",
"modules/m/tool/tool/source.go": "package tool\n",
"checks/c/go.mod": "module example.com/checks/c\n",
"checks/c/main.go": "package main\n",
}
for name, c := range map[string]struct {
ignore string
open []string
}{
"nothing ignored": {"", []string{"checks/c/c", "modules/m/cmd/m-tools/m-tools", "modules/m/m", "modules/m/m-tools",
"modules/m/tool/cmd/tool/tool"}},
"each anchored": {"/checks/c/c\n/modules/m/cmd/m-tools/m-tools\n/modules/m/m\n/modules/m/m-tools\n/modules/m/tool/cmd/tool/tool\n", nil},
"one kept by a negation": {"/checks/c/c\n/modules/m/*\n!/modules/m/m\n/modules/m/cmd/m-tools/m-tools\n/modules/m/tool/cmd/tool/tool\n",
[]string{"modules/m/m"}},
} {
root := t.TempDir()
for f, b := range files {
p := filepath.Join(root, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(b), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(c.ignore), 0o644); err != nil {
t.Fatal(err)
}
programs, err := Programs(root)
if err != nil {
t.Fatal(err)
}
want := "checks/c/c,modules/m/cmd/m-tools/m-tools,modules/m/m,modules/m/m-tools,modules/m/tool/cmd/tool/tool"
if got := strings.Join(programs, ","); got != want {
t.Fatalf("%s: programs %s, want %s", name, got, want)
}
open, err := Unignored(root, programs)
if err != nil {
t.Fatal(err)
}
if strings.Join(open, ",") != strings.Join(c.open, ",") {
t.Errorf("%s: open %v, want %v", name, open, c.open)
}
}
}
// The catalogue's own tree: every program a local build leaves is ignored, so `git add -A` never takes one.
func TestNoProgramABuildLeavesIsCommitted(t *testing.T) {
root := filepath.Join("..", "..")
programs, err := Programs(root)
if err != nil {
t.Fatal(err)
}
if len(programs) == 0 {
t.Fatal("no main package found: the check reads nothing")
}
open, err := Unignored(root, programs)
if err != nil {
t.Fatal(err)
}
for _, p := range open {
t.Errorf("%s is the program a local go build leaves, and no ignore file names it: add /%s to the catalogue's .gitignore", p, p)
}
}
+1 -2
View File
@@ -2,6 +2,5 @@ module checks/health
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
-4
View File
@@ -1,4 +0,0 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
+1 -2
View File
@@ -2,6 +2,5 @@ module checks/manifests
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
-4
View File
@@ -1,4 +0,0 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
+11 -7
View File
@@ -1,6 +1,6 @@
// Command noplacement refuses what the repository path of placement left behind (novox/hq ADR 0567 §10, §12).
//
// Placement, which module runs on which node, is bus state held by the seat mesh-placement; no file of the
// Placement, which module runs on which node, is bus state held by the seat mesh-module-assigner; no file of the
// catalogue says it, and no manifest reads it from the delivery indexer, which no longer keeps it. It refuses:
//
// 1. a `placement` directory (or file) at the root of the catalogue;
@@ -8,7 +8,8 @@
// 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16);
//
// 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…`
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`;
// or `mesh-delivery-indexer_placement…`), or the assigner's retired state placement (`mesh-delivery-assigner.placement…`,
// `mesh-module-assigner.placement…`, its views and its read tool), in any string value, such as `reads`;
//
// The controller's assignments, which item 4 once listed readers of, are refused with every other name of the
// controller by checks/controllerdeps (novox/hq ADR 0590 §5).
@@ -27,14 +28,17 @@ import (
"strings"
)
// Prefixes are the names of the indexer's placement parts; a manifest string starting with one names them.
var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement"}
// Prefixes are the names of the indexer's placement parts and the retired names of the assigner's state, now
// assignments (novox/hq ADR 0641); a manifest string starting with one names them.
var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement",
"mesh-delivery-assigner.placement", "mesh-delivery-assigner_placement", "mesh-delivery-assigner.views.placement",
"mesh-delivery-assigner.assigner_read_placement", "mesh-module-assigner.placement"}
// Findings is every refusal in the catalogue at root, sorted.
func Findings(root string) ([]string, error) {
var out []string
if _, err := os.Lstat(filepath.Join(root, "placement")); err == nil {
out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-placement (novox/hq ADR 0567 §10)")
out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-module-assigner (novox/hq ADR 0567 §10)")
}
if _, err := os.Lstat(filepath.Join(root, "particulars")); err == nil {
out = append(out, "particulars: the repository lists no node names; the checker hands them to the check (novox/hq ADR 0567 §16)")
@@ -55,7 +59,7 @@ func Findings(root string) ([]string, error) {
}
module := filepath.Base(filepath.Dir(file))
for _, s := range named(doc, nil) {
out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s))
out = append(out, fmt.Sprintf("%s: names %q, a name that is retired: the indexer keeps no placement (novox/hq ADR 0567 §12) and the seat mesh-module-assigner's state is assignments (novox/hq ADR 0641)", module, s))
}
}
sort.Strings(out)
@@ -99,5 +103,5 @@ func main() {
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("no placement directory, and no manifest reads the indexer's placement")
fmt.Println("no placement directory, and no manifest names the retired placement")
}
+6 -2
View File
@@ -13,13 +13,17 @@ func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
files map[string]string
want string // empty: clean
}{
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-assigner.placement"]}`}, ""},
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-assigner.assignments"]}`}, ""},
{"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"},
{"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"},
{"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`},
{"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"},
{"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"},
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""},
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-module-assigner.assignments"]}`}, ""},
{"the assigner's retired state", map[string]string{"modules/e/module.json": `{"module":"e","reads":["mesh-delivery-assigner.placement"]}`}, `e: names "mesh-delivery-assigner.placement"`},
{"its retired views family", map[string]string{"modules/f/module.json": `{"module":"f","consumes":["mesh-delivery-assigner.views.placement"]}`}, "views.placement"},
{"its retired read tool", map[string]string{"modules/g/module.json": `{"module":"g","invokes":["mesh-delivery-assigner.assigner_read_placement"]}`}, "assigner_read_placement"},
{"the seat's retired verb", map[string]string{"modules/h/module.json": `{"module":"h","invokes":["mesh-module-assigner.placement"]}`}, "mesh-module-assigner.placement"},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
+1 -1
View File
@@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/owned
go 1.22
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
+1 -1
View File
@@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/paths
go 1.22
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
+1 -1
View File
@@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/predefined
go 1.22
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
+11 -2
View File
@@ -2,6 +2,15 @@ module checks/readable
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-catalog/modules/restic/backupgood v0.0.0-00010101000000-000000000000
)
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
replace git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings => ../../modules/disk-load/diskreadings
replace git.novox.be/novox/mesh-catalog/modules/restic/backupgood => ../../modules/restic/backupgood
require (
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect
)
-4
View File
@@ -1,4 +0,0 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
+7 -5
View File
@@ -6,7 +6,7 @@
// the owner's manifest went with clause 5, and announcetest holds a module to its own list, not to its readers.
//
// An owner announces from its code. A Go module announces when its code imports mesh-sdk's go/announce: every
// announce.State literal there is read, its Name a string, a constant of the package, or a constant of mesh-sdk this
// announce.State literal there is read, its Name a string, a constant of the package, or a constant of an owner's package this
// check knows (Known). A TypeScript module announces when its code says the event `states` or calls the SDK's keeps:
// every `{ name: "...", readable: true }` there is read. An owner that announces nothing from code is read as before
// on the mesh, and is not judged; neither is an owner outside the catalogue (another repository's).
@@ -29,13 +29,15 @@ import (
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
"git.novox.be/novox/mesh-catalog/modules/restic/backupgood"
)
// Known are the mesh-sdk constants an announcement may name a state by, by the selector the code writes.
// Known are the constants of the owners' published packages an announcement may name a state by, by the selector the
// code writes: disk-load's readings and restic's last good night (ADR 0634 §4: a reading's layout is its owner's).
var Known = map[string]string{
"health.GoodState": health.GoodState,
"health.ReadingsState": health.ReadingsState,
"backupgood.GoodState": backupgood.GoodState,
"diskreadings.ReadingsState": diskreadings.ReadingsState,
}
const announcePkg = `"git.novox.be/novox/mesh-sdk/go/announce"`
+1 -1
View File
@@ -30,7 +30,7 @@ func TestFindings(t *testing.T) {
{"go, a known mesh-sdk name and readers", map[string]string{
"modules/r/module.json": `{"module":"r","reads":["o.good","o.s"]}`,
"modules/o/module.json": `{"module":"o"}`,
"modules/o/cmd/o/main.go": goOwner + "var _ = []announce.State{{Name: health.GoodState, Readable: true}, {Name: \"s\", Readers: []string{\"r\"}}}\n"}, ""},
"modules/o/cmd/o/main.go": goOwner + "var _ = []announce.State{{Name: backupgood.GoodState, Readable: true}, {Name: \"s\", Readers: []string{\"r\"}}}\n"}, ""},
{"go, a name that cannot be read", map[string]string{
"modules/r/module.json": `{"module":"r","reads":["o.s"]}`,
"modules/o/module.json": `{"module":"o"}`,
+1 -1
View File
@@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/secrets
go 1.22
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
+1 -1
View File
@@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/templates
go 1.22
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
+1 -1
View File
@@ -109,7 +109,7 @@ func TestCheckAddressesRefusesTheControllersAddresses(t *testing.T) {
{"`mesh-controller.<verb>`", true},
{"the mesh-controller module is gone.", false},
{"located in mesh-controller#246", false},
{"ask mesh-placement.placement", false},
{"ask mesh-module-assigner.assignments", false},
} {
got := checkAddresses([]text{{file: "m/a.go", line: 1, s: c.s}})
if (len(got) == 1) != c.want {
+1 -1
View File
@@ -1,4 +1,4 @@
57
56
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: checks/health fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
+1 -1
View File
@@ -2,7 +2,7 @@ module adwaita
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+21 -27
View File
@@ -6,38 +6,34 @@ directory and the file the mesh writes there from its setting `older-store-names
## Why a module beside the store
The store is the `distribution` module. A module that provides the artifact store cannot also build an
artifact: building publishes to the store, so the store would be needed to create itself, and the
controller's module check refuses that manifest. So the store's tools are a second module, as the
controller's refusal says to do. **Assign it to the machine that holds the store**: its tools read the
store's files through the store's own container, and anywhere else they answer that the container is not
on that machine.
The store is the `distribution` module, the registry seats' holder. These tools became a module of their own when the
indexer's judge refused a store holder that builds an artifact; that rule is gone (issue 1966), and the sweep stays its
own module, holding the seat `mesh-artifact-sweep`. It requires no provision: what it holds, releases and removes it
asks the registry seats `oci-artifact-registry` and `container-image-registry` (see *The sweep through the registry
seats*), and what the store holds it reads through the same seats' `list` (novox/hq ADR 0632 §4): it reads neither the
store's files nor its read side, so it runs on any machine.
## Tools
| tool | | what |
|---|---|---|
| `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size |
| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) |
| `artifact_store_usage` | r | the store's size, each manifest's as its seat says it, and the largest repositories |
| `artifact_store_references` | r | what the controller's records and the catalogue's definitions say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
| `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` |
### How the store is read
The store's door lists repositories and tags, but not a manifest no tag names, and the mesh pins every
machine by digest, so that is most of them. So the bundle reads the store's own files through its own
container (`docker exec mesh-registry`, busybox `find`, `stat` and `cat`), and only reads them: every
blob with its size, every manifest each repository holds, what each tag names, and each manifest's
content, four hundred to an exec. What it read it keeps (a manifest is named by its content, so it never
changes), and the next call reads only what is new. A manifest that could not be read is counted and
said; what it marks beyond itself is then unknown, so sizes may read low and freed bytes high, and the
answer says so.
The store's read side lists repositories and tags, but not a manifest no tag names, and the mesh pins every
machine by digest, so that is most of them. So the bundle asks the two registry seats' `list`
(`container-image-registry` for images and indexes, `oci-artifact-registry` for bundles and archives), which the
store's holder answers from the store's own files: each manifest's digest, whether it is an index and the manifests it
names, the tags naming it, its size and whether a hold keeps it (novox/hq ADR 0632 §4).
docker runs as the tool runner's account; a socket that refuses it is asked again through `sudo -n`,
never with a prompt, as the container runtime's own tools do.
A manifest *marks* its own content, its configuration and its layers, and an index marks the manifests
it lists. The store's collector removes every blob no manifest marks, so these numbers are its own.
A manifest's size is its own content and every blob it names, as its seat says it, so a layer two manifests share
is counted in each, and the bytes the store's nightly collector frees are the sizes of the manifests that go, an
upper bound. The address the bundle says the store is at comes from `${seat:container-image-registry:reach}`, the
image seat holder's listen (ADR 0632 §1), and is used for the catalogue's definitions only, never to reach the store.
### The states of a manifest
@@ -99,16 +95,14 @@ manifest that stays and would lose a platform it names — to be read before a r
go test ./...
```
Against a fake container that prints what the two scripts would: the listing parsed (a repository name
with slashes, a cut listing refused), manifests read with one absent or unreadable said as unread, a
digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks),
every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept
manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked,
Against a fake of the two registry seats' `list`: the store read through it (an index and the manifests it names, a
bundle's holder, a hold, the seat's sizes), every state, what the records keep that the store lacks, bytes freed as the
sizes of what goes, a dry run never confirming, a real run without `why` refused before anything is asked,
the fall-back when the references let go of cannot be carried, a manifest a definition pins under the
store's current or an older name, a definition not read said first, escalation through `sudo -n`, and that the
store's current or an older name, a definition not read said first, and that the
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs and the
catalogue's two tools.
Any command other than the two read-only scripts fails the test.
Any ask other than a seat's `list` fails the read tools' tests.
## Files it does not know
@@ -152,7 +152,7 @@ func TestOlderNamesNotReadAreSaidFirst(t *testing.T) {
// references is one artifact_store_references tool, kept across calls as the runtime keeps the bundle.
func references(t *testing.T, a *asked) func(map[string]any) (any, error) {
f := world()
s := &Store{Container: "mesh-registry", Run: f.run(t)}
s := &Store{Ask: f.ask(t)}
for _, tl := range Tools(s, recordsAsked{a}, &Catalogue{Ask: a.ask}) {
if tl.Name == "artifact_store_references" {
return tl.Run
@@ -1,10 +1,11 @@
package main
// The artifact store's door and the bus, as the holder reaches them through this module's runtime.
// The artifact store, through its seats, and the bus, as the holder reaches them through this module's runtime.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
@@ -14,23 +15,43 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
"artifactstoretools/internal/registry"
)
// door is registry.Store at the store's address as this machine reaches it.
type door struct{ s registry.Store }
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
var Gone = errors.New("the store does not hold it")
// It is read only: what the sweep holds and removes it asks the registry seats (sweepseats.go).
func (d *door) Address() string { return d.s.Address }
func (d *door) Holds(ctx context.Context, r string) error { return d.s.Holds(ctx, r) }
func (d *door) IsIndex(ctx context.Context, r string) (bool, error) {
return d.s.IsIndex(ctx, r)
// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing the store holds by digest.
//
// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite responses (skip one and
// go on, abandon the sweep for the other), and collapsing them into "an error" is how a cautious loop became one that did
// nothing while reporting the right number.
var ErrNotOurs = errors.New("not a reference into the mesh's artifact store")
// door is the store as its seats' list answers it (ADR 0632 §4), at the address this machine reaches it by: it reads
// only; what the sweep holds and removes it asks the registry seats too (sweepseats.go).
type door struct {
address string
store *Store
}
// DoorAt reads the store's address (host:port) from the file the manifest fills from the seat mesh-artifact-store's
// reach, each time, so a store that moved is reached where it is now.
func DoorAt(file string) func() (Door, error) {
func (d *door) Address() string { return d.address }
// Holds is whether the store holds what a reference names: an image's manifest, a bundle's or an archive's blob. Gone when
// its seat lists none (ADR 0526 clause 3: a registration by hand is refused at bytes that are not there).
func (d *door) Holds(_ context.Context, r string) error {
_, err := d.store.listed(r)
return err
}
// IsIndex is whether a reference is an index the store holds, as its seat lists it. Gone when it does not hold it.
func (d *door) IsIndex(_ context.Context, r string) (bool, error) {
e, err := d.store.listed(r)
return e.Index, err
}
// DoorAt reads the store's address (host:port) from the file the manifest fills from the seat container-image-registry's
// reach, each time, so a store that moved is said where it is now; the store is read through s.
func DoorAt(file string, s *Store) func() (Door, error) {
return func() (Door, error) {
if file == "" {
return nil, fmt.Errorf("this bundle was given no file naming the artifact store's address")
@@ -43,7 +64,7 @@ func DoorAt(file string) func() (Door, error) {
if address == "" {
return nil, fmt.Errorf("the artifact store's address is empty in %s", file)
}
return &door{s: registry.Store{Address: address}}, nil
return &door{address: address, store: s}, nil
}
}
@@ -0,0 +1,39 @@
package main
import (
"encoding/json"
"fmt"
"sort"
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
)
// The holders whose states this module reads, one constant each (a reader names its holder once; ADR 0634 §4).
const (
assignerModule = "mesh-delivery-assigner"
indexerModule = "mesh-delivery-indexer"
)
// The seat mesh-artifact-sweep types the answer of its verb held and the body of its event artifacts-gone; this module
// holds the seat, so it answers and says them in those types.
type (
// HeldAnswer is the answer of the seat's verb held.
HeldAnswer = sweep.HeldAnswer
// ArtifactsGone is the body of the seat's event artifacts-gone.
ArtifactsGone = sweep.ArtifactsGone
)
// GoneSubject is where the seat says artifacts-gone.
const GoneSubject = sweep.SubjectArtifactsGone
// EncodeArtifactsGone is the event's body, the references sorted; it refuses one naming no module, no fingerprint or no
// gone reference.
func EncodeArtifactsGone(e ArtifactsGone) ([]byte, error) {
e.Event = sweep.EventArtifactsGone
e.Gone = append([]string(nil), e.Gone...)
sort.Strings(e.Gone)
if e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 {
return nil, fmt.Errorf("%s names a module, a fingerprint and at least one gone reference", sweep.EventArtifactsGone)
}
return json.Marshal(e)
}
@@ -18,35 +18,37 @@ import (
"encoding/json"
"errors"
"fmt"
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/views"
"artifactstoretools/internal/registry"
)
// What the holder reads and keeps.
const (
WantsRead = delivery.AssignerModule + ".wants"
BuildsRead = delivery.BuilderModule + ".builds"
WantsRead = assignerModule + ".wants"
BuildsRead = builds.BuilderModule + ".builds"
HavesRead = "node-engine.have"
// ManifestsRead is the indexer's manifests: the images a manifest pins by itself.
ManifestsRead = delivery.IndexerModule + ".manifests"
// FindingsState is the holder's own findings (health.FindingsState): image-missing.
FindingsState = health.FindingsState
ManifestsRead = indexerModule + "." + catalog.StateManifests
// FindingsState is the holder's own findings (meshconditions.StateFindings): image-missing.
FindingsState = meshconditions.StateFindings
// Module is this module, the observer its findings name.
Module = "artifact-store-tools"
// GoneSubject is where the seat says artifacts-gone.
GoneSubject = "mesh.seat." + catalogue.ArtifactSweepSeat + ".event." + delivery.ArtifactsGoneEvent
)
// The sweep's bounds, the controller's (cmd/mesh-controller/collect.go mostPerSweep, sweepBudget).
@@ -71,7 +73,7 @@ type Bus interface {
Publish(subject string, body any) error
}
// Door is the artifact store's own door, read only: registry.Store, behind an interface so a test needs no store. What
// Door is the artifact store as its seats' list answers it, read only, behind an interface so a test needs no store. What
// the sweep holds and removes it asks the registry seats (Seats, sweepseats.go).
type Door interface {
Address() string
@@ -83,9 +85,9 @@ type Door interface {
type Holder struct {
Bus Bus
Door func() (Door, error)
// Listed is the store's own listing now (Store.View); nil reads none, and then nothing is said gone or collected.
// Listed is the store as its seats list it now (Store.View); nil reads none, and then nothing is said gone or collected.
Listed func(ctx context.Context) (*View, error)
Acts handacts.Recorder
Acts meshhandactsclient.Recorder
// Seats asks a registry seat's verb, `seat:<seat>.<verb>` (hold, remove): the runtime's stdio.Ask in main.
Seats func(key string, body any) (json.RawMessage, error)
Now func() time.Time
@@ -100,7 +102,7 @@ type Holder struct {
type Build struct {
Module, FP, Key string
At time.Time
Artifacts []delivery.BuiltArtifact
Artifacts []builds.BuiltArtifact
// Manifest is the digest of the manifest it built, in the indexer's `manifests`.
Manifest string
}
@@ -113,7 +115,7 @@ func (h *Holder) Builds() ([]Build, error) {
}
var out []Build
for _, k := range keys {
module, fp, err := delivery.ParseBuilderBuildKey(k)
module, fp, err := builds.ParseBuilderBuildKey(k)
if err != nil {
continue
}
@@ -124,8 +126,8 @@ func (h *Holder) Builds() ([]Build, error) {
if !found {
continue
}
b, err := delivery.DecodeBuilderBuild(raw)
if err != nil || b.State != delivery.BuildDone {
b, err := builds.DecodeBuilderBuild(raw)
if err != nil || b.State != builds.BuildDone {
continue
}
at, err := h.endOf(k)
@@ -150,7 +152,7 @@ func (h *Holder) endOf(key string) (time.Time, error) {
return time.Time{}, fmt.Errorf("the history of the builder's build %s: %w", key, err)
}
for _, r := range revs {
if b, err := delivery.DecodeBuilderBuild(r.Value); err == nil && b.State == delivery.BuildDone && r.At.After(at) {
if b, err := builds.DecodeBuilderBuild(r.Value); err == nil && b.State == builds.BuildDone && r.At.After(at) {
at = r.At
}
}
@@ -182,11 +184,11 @@ func (h *Holder) Wants() ([]Want, error) {
if err != nil {
return nil, fmt.Errorf("the want %s: %w", k, err)
}
var w delivery.Want
if !found || json.Unmarshal(raw, &w) != nil || w.Removed || w.FP == "" {
var w meshwants.WantsByWantNodeModuleValue
if !found || json.Unmarshal(raw, &w) != nil || w.Removed || w.Fp == "" {
continue
}
out = append(out, Want{Node: node, Module: module, FP: w.FP})
out = append(out, Want{Node: node, Module: module, FP: w.Fp})
}
return out, nil
}
@@ -218,8 +220,8 @@ func KeepSet(builds []Build, wants []Want) map[string]*Ref {
out := map[string]*Ref{}
for _, b := range builds {
for _, a := range b.Artifacts {
reference := canonical(catalogue.Recorded(a.Reference))
if _, ours := catalogue.InArtifactStore(reference); !ours || a.Kind == "package" {
reference := canonical(registryForms.Recorded(a.Reference))
if _, ours := registryForms.InArtifactStore(reference); !ours || a.Kind == "package" {
continue
}
r := out[reference]
@@ -353,17 +355,17 @@ func (h *Holder) judge(ctx context.Context) (*judged, error) {
// against is what each build the builder's log says ended stood on, by build key: the bases' references.
func (h *Holder) against() (map[string][]string, error) {
entries, err := h.Bus.LogSince(delivery.BuildLogRead, h.now().Add(-delivery.BuilderLogDays*24*time.Hour))
entries, err := h.Bus.LogSince(builds.BuildLogRead, h.now().Add(-builds.BuilderLogDays*24*time.Hour))
if err != nil {
return nil, err
}
out := map[string][]string{}
for _, raw := range entries {
e, err := delivery.DecodeBuildLogEntry(raw.Value)
if err != nil || e.Event != delivery.BuildEndedEvent {
e, err := builds.DecodeBuildLogEntry(raw.Value)
if err != nil || e.Event != builds.BuildEndedEvent {
continue
}
key, err := delivery.BuilderBuildKey(e.Module, e.FP)
key, err := builds.BuilderBuildKey(e.Module, e.FP)
if err != nil {
continue
}
@@ -384,7 +386,7 @@ func Mirrors(v *View, builds []Build, kept map[string][]string, against map[stri
continue
}
for d := range v.L.Repos[name].Revisions {
r := catalogue.ArtifactStoreScheme + name + "@" + d
r := artifactref.ArtifactStoreScheme + name + "@" + d
out[r] = &Ref{Reference: r, Kind: "image"}
}
}
@@ -398,7 +400,7 @@ func Mirrors(v *View, builds []Build, kept map[string][]string, against map[stri
continue
}
for _, base := range bases {
if m := out[catalogue.Recorded(base)]; m != nil {
if m := out[registryForms.Recorded(base)]; m != nil {
m.Builds = append(m.Builds, b)
why := kept[b.Key]
if len(why) > 0 {
@@ -446,7 +448,7 @@ func (j *judged) records(repository string, collected bool) *Records {
}
}
r.Counts[state]++
if (repository != "" && !strings.HasPrefix(strings.TrimPrefix(ref.Reference, catalogue.ArtifactStoreScheme), repository)) ||
if (repository != "" && !strings.HasPrefix(strings.TrimPrefix(ref.Reference, artifactref.ArtifactStoreScheme), repository)) ||
(state == "collected" && !collected) {
continue
}
@@ -576,7 +578,7 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
if !s.Confirmed {
// An index waits for a confirmed collect (ADR 0251 §7, ADR 0257 §4).
index, err := door.IsIndex(within, r)
if err != nil && !errors.Is(err, registry.Gone) && !errors.Is(err, registry.ErrNotOurs) {
if err != nil && !errors.Is(err, Gone) && !errors.Is(err, ErrNotOurs) {
a.Stopped = fmt.Sprintf("the store could not say whether %s is an index, so nothing more was asked: %v", r, err)
a.Left = len(eligible) - i
break
@@ -599,7 +601,7 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
refused = true
break
}
if _, err := h.Acts.Record(handacts.Request{What: "let go of " + r + " (" + s.By + ")", Why: why,
if _, err := h.Acts.Record(meshhandactsclient.Request{What: "let go of " + r + " (" + s.By + ")", Why: why,
Cause: "artifact-store-sweep", Verb: "collect"}); err != nil {
h.logf("%s was let go of and could not be recorded in the hand-act log: %v", r, err)
}
@@ -614,7 +616,7 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
// sayGone says artifacts-gone for each build that named one of refs: the builder builds a key again whose artifact is gone.
func (h *Holder) sayGone(j *judged, refs []string, how string) {
byKey := map[string]*delivery.ArtifactsGone{}
byKey := map[string]*ArtifactsGone{}
var keys []string
for _, r := range refs {
ref := j.refs[r]
@@ -624,7 +626,8 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
for _, b := range ref.Builds {
g := byKey[b.Key]
if g == nil {
g = &delivery.ArtifactsGone{Module: b.Module, Key: b.Key, Fingerprint: b.FP, At: h.now().UTC()}
at := h.now().UTC()
g = &ArtifactsGone{Module: b.Module, Key: b.Key, Fingerprint: b.FP, At: &at}
byKey[b.Key] = g
keys = append(keys, b.Key)
}
@@ -633,7 +636,7 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
}
sort.Strings(keys)
for _, k := range keys {
raw, err := delivery.EncodeArtifactsGone(*byKey[k])
raw, err := EncodeArtifactsGone(*byKey[k])
if err == nil {
err = h.Bus.Publish(GoneSubject, json.RawMessage(raw))
}
@@ -647,7 +650,7 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
// artifacts-gone against its own key.
func namedBy(b Build, r string) string {
for _, a := range b.Artifacts {
if rec := catalogue.Recorded(a.Reference); canonical(rec) == r {
if rec := registryForms.Recorded(a.Reference); canonical(rec) == r {
return rec
}
}
@@ -667,30 +670,30 @@ func (h *Holder) AfterBuild(key string) {
}
// Held is the seat's `held`: each reference, held, gone or unknown with why. Holds and writes nothing.
func (h *Holder) Held(refs []string) (delivery.HeldAnswer, error) {
func (h *Holder) Held(refs []string) (HeldAnswer, error) {
if len(refs) == 0 {
return delivery.HeldAnswer{}, errors.New("held judges the references given, and none was")
return HeldAnswer{}, errors.New("held judges the references given, and none was")
}
if len(refs) > 64 {
return delivery.HeldAnswer{}, fmt.Errorf("held judges at most 64 references at once, and %d were given", len(refs))
return HeldAnswer{}, fmt.Errorf("held judges at most 64 references at once, and %d were given", len(refs))
}
for _, r := range refs {
if _, ours := catalogue.InArtifactStore(r); !ours {
return delivery.HeldAnswer{}, fmt.Errorf("%s is not a reference into the mesh's artifact store (artifact-store://...): nothing was asked", r)
if _, ours := registryForms.InArtifactStore(r); !ours {
return HeldAnswer{}, fmt.Errorf("%s is not a reference into the mesh's artifact store (artifact-store://...): nothing was asked", r)
}
}
door, err := h.Door()
if err != nil {
return delivery.HeldAnswer{}, err
return HeldAnswer{}, err
}
asking, stop := context.WithTimeout(context.Background(), 20*time.Second)
defer stop()
a := delivery.HeldAnswer{Store: door.Address(), Held: []string{}, Gone: []string{}}
a := HeldAnswer{Store: door.Address(), Held: []string{}, Gone: []string{}}
for _, r := range refs {
switch err := door.Holds(asking, canonical(r)); {
case err == nil:
a.Held = append(a.Held, r)
case errors.Is(err, registry.Gone):
case errors.Is(err, Gone):
a.Gone = append(a.Gone, r)
default:
if a.Unknown == nil {
@@ -726,7 +729,7 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) {
}
byImage := map[string]*Image{}
add := func(module, fp, in string) error {
key, err := delivery.BuilderBuildKey(module, fp)
key, err := builds.BuilderBuildKey(module, fp)
if err != nil {
return nil
}
@@ -734,14 +737,14 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) {
if err != nil || !found {
return err
}
b, err := delivery.DecodeBuilderBuild(raw)
b, err := builds.DecodeBuilderBuild(raw)
if err != nil {
return nil
}
images := []string{}
for _, a := range b.Artifacts {
if (a.Kind == "image" || a.Kind == "upstream") && a.Reference != "" {
images = append(images, catalogue.Routed(catalogue.Recorded(a.Reference), address))
images = append(images, registryForms.Routed(registryForms.Recorded(a.Reference), address))
}
}
pinned, err := h.Pinned(b.Manifest)
@@ -775,7 +778,7 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) {
return out, fmt.Errorf("the haves: %w", err)
}
for _, k := range keys {
n, module, isNode, err := delivery.ParseHaveKey(k)
n, module, isNode, err := engineclient.ParseHaveKey(k)
if err != nil || isNode || n != node {
continue
}
@@ -783,12 +786,12 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) {
if err != nil {
return out, err
}
var have delivery.Have
if !found || json.Unmarshal(raw, &have) != nil || have.FP == "" {
var have nodeengine.HaveByNodeModuleValue
if !found || json.Unmarshal(raw, &have) != nil || have.Fp == "" {
continue
}
out.SentKnown = true
if err := add(module, have.FP, "sent"); err != nil {
if err := add(module, have.Fp, "sent"); err != nil {
return out, err
}
}
@@ -807,7 +810,7 @@ func (h *Holder) Pinned(manifest string) ([]string, error) {
if manifest == "" {
return nil, nil
}
key, err := delivery.ManifestsKey(manifest)
key, err := catalogclient.ManifestsKey(manifest)
if err != nil {
return nil, nil
}
@@ -815,14 +818,14 @@ func (h *Holder) Pinned(manifest string) ([]string, error) {
if err != nil || !found {
return nil, err
}
m, err := delivery.DecodeManifest(raw)
m, err := catalogclient.DecodeManifest(raw)
if err != nil {
return nil, nil
}
var parsed struct {
Resources []map[string]any `json:"resources"`
}
if json.Unmarshal([]byte(m.JSON), &parsed) != nil {
if json.Unmarshal([]byte(m.Manifest), &parsed) != nil {
return nil, nil
}
var out []string
@@ -842,8 +845,8 @@ func inStoreAt(image, address string) string {
if address == "" || !strings.HasPrefix(image, address+"/") || !strings.Contains(image, "@sha256:") {
return ""
}
r := catalogue.Recorded(image)
if _, ours := catalogue.InArtifactStore(r); !ours {
r := registryForms.Recorded(image)
if _, ours := registryForms.InArtifactStore(r); !ours {
return ""
}
return r
@@ -867,7 +870,7 @@ func (h *Holder) Observe(ctx context.Context) error {
for _, b := range j.built {
builds[b.Module+" "+b.FP] = b
}
now := map[string]health.Finding{}
now := map[string]meshconditions.FindingsByKeyValue{}
for _, w := range j.wants {
b, ok := builds[w.Module+" "+w.FP]
if !ok {
@@ -876,7 +879,7 @@ func (h *Holder) Observe(ctx context.Context) error {
var refs []string
for _, a := range b.Artifacts {
if a.Kind == "image" {
refs = append(refs, catalogue.Recorded(a.Reference))
refs = append(refs, registryForms.Recorded(a.Reference))
}
}
// And what its manifest pins by itself under the store's address, as the controller judged S25.
@@ -907,7 +910,7 @@ func (h *Holder) Observe(ctx context.Context) error {
if _, still := now[k]; !still {
// A finding of a first view unanswered is the reader's (views.Reader), cleared by its answer, not by a sweep.
if raw, found, err := h.Bus.Get(FindingsState, k); err == nil && found {
if f, err := health.DecodeFinding(raw); err == nil && f.Kind == views.UnansweredKind {
if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind {
continue
}
}
@@ -922,11 +925,11 @@ func (h *Holder) Observe(ctx context.Context) error {
return err
}
if found {
if was, err := health.DecodeFinding(raw); err == nil && was.Summary == f.Summary {
if was, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && was.Summary == f.Summary {
continue // written only when what it says changes
}
}
if _, err := health.EncodeFinding(f); err != nil {
if err := meshconditionsclient.CheckFinding(f); err != nil {
return err
}
if err := h.Bus.Put(FindingsState, k, f); err != nil {
@@ -937,11 +940,11 @@ func (h *Holder) Observe(ctx context.Context) error {
}
// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages).
func imageMissing(node, module, image string, at time.Time) health.Finding {
func imageMissing(node, module, image string, at time.Time) meshconditions.FindingsByKeyValue {
id := module + "." + node
return health.Finding{V: health.FindingVersion, Key: health.FindingKey(health.ScopeModule, id, ImageMissing), Kind: ImageMissing,
Subject: health.Subject{Scope: health.ScopeModule, ID: id, Machine: node},
Severity: health.Warning, Resolver: health.ResolverOperator,
return meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: meshconditionsclient.Key(meshconditionsclient.ScopeModule, id, ImageMissing), Kind: ImageMissing,
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: id, Machine: node},
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
Summary: fmt.Sprintf("%s is left out of every send to %s: its declaration names an image that the artifact store does "+
"not hold (the evidence names it); the running container is untouched", module, node),
Headline: capital(module) + " is not updated on " + node,
@@ -949,7 +952,7 @@ func imageMissing(node, module, image string, at time.Time) health.Finding {
"was not sent to %s. What runs there is untouched, and the rest of %s is sent as usual.", module, node, node),
Needs: fmt.Sprintf("build %s again; the details name the image.", module),
Resolved: "Resolved: the store holds the image of " + module + " again",
Evidence: []health.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}},
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}},
Source: "S25", Observer: Module, At: at.UTC()}
}
@@ -19,8 +19,9 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
meshhandacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
@@ -158,7 +159,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) {
// The seat mesh-hand-acts, answering every act it records; artifacts-gone heard on the seat's subject.
var mu sync.Mutex
var acts, gone []string
if _, err := nc.Subscribe("mesh.seat."+handacts.Seat+".tool.hand-act", func(m *nats.Msg) {
if _, err := nc.Subscribe("mesh.seat."+meshhandacts.Seat+".tool.hand-act", func(m *nats.Msg) {
mu.Lock()
acts = append(acts, string(m.Data))
mu.Unlock()
@@ -198,7 +199,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) {
return m.Data, nil
}
h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
h.Acts = handacts.Recorder{Ask: ask}
h.Acts = meshhandactsclient.Recorder{Ask: ask}
// A build's end heard from the builds bucket, as follow hands it over.
kv, _ := bus.kv(BuildsRead)
@@ -5,6 +5,11 @@ import (
"encoding/json"
"errors"
"fmt"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"slices"
"sort"
"strings"
@@ -12,14 +17,14 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"artifactstoretools/internal/registry"
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
)
// memBus is the holder's bus in memory: states by name, each key's history, and what was published.
@@ -34,7 +39,7 @@ type memBus struct {
func (b *memBus) LogSince(read string, at time.Time) ([]stdio.LogEntry, error) {
var out []stdio.LogEntry
for _, e := range b.log {
if read == delivery.BuildLogRead && !e.At.Before(at) {
if read == builds.BuildLogRead && !e.At.Before(at) {
out = append(out, e)
}
}
@@ -107,9 +112,9 @@ func (d *memDoor) seat(key string, body any) (json.RawMessage, error) {
var in struct{ Name, Digest, Reason string }
_ = json.Unmarshal(raw, &in)
seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
ref := catalogue.ArtifactStoreScheme + in.Name + "@" + in.Digest
ref := artifactref.ArtifactStoreScheme + in.Name + "@" + in.Digest
if seat == "oci-artifact-registry" {
ref = catalogue.ArtifactStoreScheme + in.Name + "/blobs/" + in.Digest
ref = artifactref.ArtifactStoreScheme + in.Name + "/blobs/" + in.Digest
}
d.calls = append(d.calls, key+" "+in.Name+"@"+in.Digest+" "+in.Reason)
if err := d.refuse[verb+" "+ref]; err != nil {
@@ -141,7 +146,7 @@ func (d *memDoor) seat(key string, body any) (json.RawMessage, error) {
func (d *memDoor) Address() string { return "10.0.0.5:5000" }
func (d *memDoor) Holds(_ context.Context, r string) error {
if !d.holds[r] {
return registry.Gone
return Gone
}
return nil
}
@@ -176,8 +181,8 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
door := &memDoor{holds: map[string]bool{}, indexes: map[string]bool{}}
listing := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}}
for n := 1; n <= 7; n++ {
key, _ := delivery.BuilderBuildKey("letta", fp(n))
raw, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []delivery.BuiltArtifact{
key, _ := builds.BuilderBuildKey("letta", fp(n))
raw, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []builds.BuiltArtifact{
{Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: imageRef("letta", n)},
{Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: archiveRef("letta", n)},
{Name: "pkg", Kind: "package", Digest: "sha256:" + strings.Repeat("f", 64), Reference: "letta@1.0." + fmt.Sprint(n)}}})
@@ -205,7 +210,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
door.holds[mirror(n)] = true
}
for n := 1; n <= 7; n++ {
e := delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: "letta", FP: fp(n), Repository: "novox/x", Commit: "c",
e := builds.BuildLogEntry{Event: builds.BuildEndedEvent, Module: "letta", FP: fp(n), Repository: "novox/x", Commit: "c",
Node: "ace", At: day.Add(time.Duration(n) * time.Hour), Outcome: "done"}
switch n {
case 2:
@@ -213,21 +218,21 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
case 7:
e.Against = []string{mirror(2)}
}
raw, _ := delivery.EncodeBuildLogEntry(e)
raw, _ := builds.EncodeBuildLogEntry(e)
b.log = append(b.log, stdio.LogEntry{Sequence: uint64(n), Key: "letta", Value: raw, At: e.At})
}
manifest, _ := delivery.EncodeManifest(delivery.Manifest{Module: "letta",
JSON: `{"module":"letta","resources":[{"id":"app","type":"container","image":"` + pinnedImage + `"},` +
manifest, _ := catalogclient.EncodeManifest(catalog.ManifestsByDigestValue{Module: "letta",
Manifest: `{"module":"letta","resources":[{"id":"app","type":"container","image":"` + pinnedImage + `"},` +
`{"id":"built","type":"container","image":"${artifact:image}"}]}`})
mk, _ := delivery.ManifestsKey(strings.Repeat("d", 64))
mk, _ := catalogclient.ManifestsKey(strings.Repeat("d", 64))
_ = b.Put(ManifestsRead, mk, json.RawMessage(manifest))
want, _ := json.Marshal(delivery.Want{V: delivery.Version, FP: fp(1), Self: true})
want, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp(1), Self: true})
_ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want))
var acts []string
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil },
Now: func() time.Time { return day.Add(48 * time.Hour) },
Acts: handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
Acts: meshhandactsclient.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
acts = append(acts, key+" "+string(raw))
return json.RawMessage(`{}`), nil
@@ -303,10 +308,10 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
}
if c.seatForm {
for _, n := range []int{2, 3} {
key, _ := delivery.BuilderBuildKey("letta", fp(n))
raw, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []delivery.BuiltArtifact{
{Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: catalogue.SeatReference("container-image-registry", "letta", "image", digestOf(imageRef("letta", n)))},
{Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: catalogue.SeatReference("oci-artifact-registry", "letta", "files", digestOf(archiveRef("letta", n)))}}})
key, _ := builds.BuilderBuildKey("letta", fp(n))
raw, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []builds.BuiltArtifact{
{Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: artifactref.SeatReference(reg.Seat, "letta", "image", digestOf(imageRef("letta", n)))},
{Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: artifactref.SeatReference(ociseat.Seat, "letta", "files", digestOf(archiveRef("letta", n)))}}})
_ = b.Put(BuildsRead, key, json.RawMessage(raw))
}
}
@@ -405,9 +410,9 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
if err := h.Observe(context.Background()); err != nil {
t.Fatal(err)
}
key := health.FindingKey(health.ScopeModule, "letta.ace", ImageMissing)
key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "letta.ace", ImageMissing)
raw, found, _ := b.Get(FindingsState, key)
f, err := health.DecodeFinding(raw)
f, err := meshconditions.DecodeFindingsByKeyValue(raw)
if !found || err != nil || f.Subject.Machine != "ace" || f.Source != "S25" || !strings.Contains(f.Evidence[0].Said, imageRef("letta", 1)) {
t.Fatalf("the finding is %+v (%v, %v)", f, found, err)
}
@@ -427,7 +432,7 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
t.Fatalf("the pinnedImage image missing is %s", raw)
}
have, _ := json.Marshal(delivery.Have{V: delivery.Version, FP: fp(7), Phase: "running"})
have, _ := json.Marshal(nodeengine.HaveByNodeModuleValue{V: 1, Fp: fp(7), Phase: "running"})
_ = b.Put(HavesRead, "ace.letta", json.RawMessage(have))
images, err := h.ImagesOf("ace", "10.0.0.5:5000")
if err != nil || len(images.Images) != 3 || !images.SentKnown || images.Images[0].Image != "10.0.0.5:5000/letta/image@sha256:"+strings.Repeat("1", 64) ||
@@ -0,0 +1,166 @@
package main
// **The store, read through its seats** (novox/hq ADR 0632 §4): the read tools and the sweep read what the store holds
// from the seats' `list`, container-image-registry's images and indexes and oci-artifact-registry's bundles and archives,
// never from the store's files or its read side. What a manifest marks beyond itself is the seat's size of it, so a
// blob two manifests share is counted in each, and what the store's nightly collector frees is not known before it runs.
import (
"context"
"encoding/json"
"fmt"
"sort"
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
)
// Repo is one repository as the seats' list answers it.
type Repo struct {
Name string
// Revisions are the digests of every manifest it holds, tagged or not.
Revisions map[string]bool
// Tags are what each tag names now.
Tags map[string]string
}
// Listing is the store as the seats' list answers it: every repository, and a size for each manifest and each
// bundle's or archive's blob, keyed by its digest. A manifest's size is its own content and every blob it names, as the
// seat answers it, so a layer two manifests share is counted in each.
type Listing struct {
Blobs map[string]int64
Repos map[string]*Repo
}
func (l *Listing) repo(name string) *Repo {
r := l.Repos[name]
if r == nil {
r = &Repo{Name: name, Revisions: map[string]bool{}, Tags: map[string]string{}}
l.Repos[name] = r
}
return r
}
// RepoNames are the repositories in order.
func (l *Listing) RepoNames() []string {
out := make([]string, 0, len(l.Repos))
for n := range l.Repos {
out = append(out, n)
}
sort.Strings(out)
return out
}
// Manifest is what one manifest names.
type Manifest struct {
MediaType string
ConfigMedia string
// Refs are the blobs it names: its configuration and its layers.
Refs []string
// Layers are its layers alone, in order.
Layers []string
// Children are the manifests an index names.
Children []string
}
// Holder is the shape of a manifest that keeps one blob: an empty configuration and one layer. The
// controller holds every archive the mesh keeps this way (novox/hq issue 253), and keeps a named
// document the same way, under a tag.
func (m *Manifest) Holder() bool {
return m != nil && m.ConfigMedia == "application/vnd.oci.empty.v1+json" && len(m.Layers) == 1
}
// Key names one manifest in one repository.
func Key(repo, digest string) string { return repo + "@" + digest }
// Store is the artifact store as its two seats' `list` answers it: Ask is the runtime's stdio.Ask in main.
type Store struct {
Ask func(key string, body any) (json.RawMessage, error)
}
// ListOf asks seat's list, of one repository or, with name empty, of every one.
func (s *Store) ListOf(seat, name string) ([]reg.Repository, error) {
if s == nil || s.Ask == nil {
return nil, fmt.Errorf("this process reaches no registry seat")
}
raw, err := s.Ask("seat:"+seat+"."+reg.VerbList, reg.ListInput{Name: name})
if err != nil {
return nil, fmt.Errorf("%s's list: %w", seat, err)
}
var a reg.ListAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s's answer to list: %w", seat, err)
}
return a.Repositories, nil
}
// View reads the store now, through both seats.
func (s *Store) View(context.Context) (*View, error) {
images, err := s.ListOf(reg.Seat, "")
if err != nil {
return nil, err
}
artifacts, err := s.ListOf(ociseat.Seat, "")
if err != nil {
return nil, err
}
return ViewOf(images, artifacts), nil
}
// ViewOf is the store as the two seats list it: each manifest a repository's revision, with its tags; an index naming its
// platform manifests; a bundle's or archive's manifest keeping its one blob, so it reads as the holder of that archive.
func ViewOf(seats ...[]reg.Repository) *View {
l := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}}
m := map[string]*Manifest{}
held := map[string]bool{}
for _, repos := range seats {
for _, r := range repos {
repo := l.repo(r.Name)
for _, e := range r.Manifests {
repo.Revisions[e.Digest] = true
for _, t := range e.Tags {
repo.Tags[t] = e.Digest
}
doc := &Manifest{Children: append([]string(nil), e.Platforms...)}
l.Blobs[e.Digest] = e.Size
if e.Artifact != "" {
// The holder's size is the archive's with it: the blob itself counts nothing more.
doc.ConfigMedia, doc.Layers, doc.Refs = emptyConfig, []string{e.Artifact}, []string{e.Artifact}
if _, sized := l.Blobs[e.Artifact]; !sized {
l.Blobs[e.Artifact] = 0
}
}
m[Key(r.Name, e.Digest)] = doc
if e.Held {
held[Key(r.Name, e.Digest)] = true
}
}
}
}
return &View{L: l, M: m, Unread: map[string]string{}, Held: held}
}
// emptyConfig is the configuration media type of a manifest keeping one blob.
const emptyConfig = "application/vnd.oci.empty.v1+json"
// listed is the one entry of a reference's seat list naming it: by its manifest's digest, or a bundle's or an archive's by
// the blob its reference names. Gone when the seat lists none.
func (s *Store) listed(reference string) (reg.Listed, error) {
seat, name, digest, err := seatOf(reference)
if err != nil {
return reg.Listed{}, fmt.Errorf("%w: %v", ErrNotOurs, err)
}
repos, err := s.ListOf(seat, name)
if err != nil {
return reg.Listed{}, err
}
for _, r := range repos {
for _, e := range r.Manifests {
if (seat == ociseat.Seat && e.Artifact == digest) || (seat != ociseat.Seat && e.Digest == digest) {
return e, nil
}
}
}
return reg.Listed{}, Gone
}
@@ -1,12 +1,10 @@
// The artifact-store-tools module's Go bundle (novox/hq ADR 0251 §1–3, ADR 0571 clause 2, to-be 51): a process the
// node's runtime launches and speaks MCP over stdio to. It says what the artifact store holds — its repositories, its
// size, and what the mesh keeps of each manifest — read from the store's own files through the store's own container,
// and it holds the seat mesh-artifact-sweep: the keep set, the sweep at each build's end, and S25's image-missing.
// size, and what the mesh keeps of each manifest — read through the registry seats' list (novox/hq ADR 0632 §4), and it
// holds the seat mesh-artifact-sweep: the keep set, the sweep at each build's end, and S25's image-missing.
//
// **A module beside the store, not the store's own.** A module that provides the artifact store cannot also build an
// artifact: building publishes to the store, so the store would be needed to create itself. So these tools are a
// second module, assigned to the machine that holds the store. stdout is the protocol; what this bundle says, it says
// on stderr.
// **A module beside the store, not the store's own.** It requires no provision: what it reads, holds and removes it asks
// the registry seats (listed.go, sweepseats.go), never the store's files or its read side. stdout is the protocol; what this bundle says, it says on stderr.
package main
import (
@@ -15,9 +13,9 @@ import (
"os"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -25,17 +23,13 @@ import (
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
func main() {
s := &Store{
Container: os.Getenv("MESH_ARTIFACT_STORE_CONTAINER"),
Run: ExecRunner,
UID: os.Getuid(),
}
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: views.OwnFindings{}, Acts: handacts.Recorder{Ask: stdio.Ask}}
v.Follow(delivery.AssignerModule, "wants", WantsTool)
v.Follow(delivery.IndexerModule, "manifests", ManifestsTool)
h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE")), Listed: s.View,
s := &Store{Ask: stdio.Ask}
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
v.Follow(assignerModule, "wants", WantsTool)
v.Follow(indexerModule, "manifests", ManifestsTool)
h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE"), s), Listed: s.View,
Seats: stdio.Ask, Now: time.Now, Logf: logf}
h.Acts = handacts.Recorder{Ask: stdio.Ask}
h.Acts = meshhandactsclient.Recorder{Ask: stdio.Ask}
v.Start(context.Background())
go follow(h, v)
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
@@ -47,6 +41,6 @@ func main() {
// The owners' read tools of the states the holder follows, which the manifest invokes (ADR 0615).
const (
WantsTool = delivery.AssignerModule + ".assigner_read_wants"
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
WantsTool = assignerModule + ".assigner_read_wants"
ManifestsTool = indexerModule + ".indexer_read_manifests"
)
@@ -1,175 +0,0 @@
package main
import (
"context"
"fmt"
"sort"
"strings"
"sync"
)
// readScript prints each manifest named on its command line, each after a line naming its path. A
// manifest is JSON, which holds no raw line break inside a string, so no line of one starts with the
// marker. One the store does not have is said, not skipped.
const readScript = `cd ` + storageRoot + `
for p in "$@"; do
echo "#manifest $p"
if [ -f "$p" ]; then cat "$p"; echo; else echo "#absent"; fi
done
echo '#end'`
// readBatch is how many manifests one exec reads: well under any command line's limit.
const readBatch = 400
// Store reaches the artifact store's own files, through its own container, and only reads them.
type Store struct {
Container string
Run Runner
UID int
mu sync.Mutex
// cache holds every manifest read: a manifest is named by its content's digest, so it never changes.
cache map[string]*Manifest
}
// List reads the store's files.
func (s *Store) List(ctx context.Context) (*Listing, error) {
if s.Container == "" {
return nil, fmt.Errorf("this bundle was not told the artifact store's container (MESH_ARTIFACT_STORE_CONTAINER)")
}
out, err := docker(ctx, s.Run, s.UID, "exec", s.Container, "sh", "-c", listScript)
if err != nil {
return nil, err
}
return ParseListing(out)
}
// Key names one manifest in one repository.
func Key(repo, digest string) string { return repo + "@" + digest }
// blobPath is where the store keeps a blob, under storageRoot.
func blobPath(digest string) string {
algorithm, hex, _ := strings.Cut(digest, ":")
if algorithm == "" || len(hex) < 2 || strings.ContainsAny(digest, "/ \n") {
return ""
}
return "blobs/" + algorithm + "/" + hex[:2] + "/" + hex + "/data"
}
// Manifests reads every manifest the listing names, from the cache or the store's files. Answers each
// one read, and each one not read with why.
func (s *Store) Manifests(ctx context.Context, l *Listing) (map[string]*Manifest, map[string]string) {
need := map[string]bool{}
s.mu.Lock()
if s.cache == nil {
s.cache = map[string]*Manifest{}
}
for _, name := range l.RepoNames() {
for d := range l.Repos[name].Revisions {
if _, ok := s.cache[d]; !ok {
need[d] = true
}
}
}
s.mu.Unlock()
digests := make([]string, 0, len(need))
for d := range need {
digests = append(digests, d)
}
sort.Strings(digests)
failed := map[string]string{}
for start := 0; start < len(digests); start += readBatch {
batch := digests[start:min(start+readBatch, len(digests))]
read, err := s.read(ctx, batch)
s.mu.Lock()
for _, d := range batch {
switch m := read[d]; {
case err != nil:
failed[d] = err.Error()
case m == nil:
failed[d] = "the store holds no manifest content for it"
default:
s.cache[d] = m
}
}
s.mu.Unlock()
}
got := map[string]*Manifest{}
unread := map[string]string{}
s.mu.Lock()
defer s.mu.Unlock()
for _, name := range l.RepoNames() {
for d := range l.Repos[name].Revisions {
if m, ok := s.cache[d]; ok {
got[Key(name, d)] = m
} else if why, ok := failed[d]; ok {
unread[Key(name, d)] = why
} else {
unread[Key(name, d)] = "not read"
}
}
}
return got, unread
}
// read reads one batch of manifests. A blob that is not a manifest, or not there, is answered as nil;
// an exec that fails fails the batch.
func (s *Store) read(ctx context.Context, digests []string) (map[string]*Manifest, error) {
args := []string{"exec", s.Container, "sh", "-c", readScript, "sh"}
byPath := map[string]string{}
for _, d := range digests {
if p := blobPath(d); p != "" {
byPath[p] = d
args = append(args, p)
}
}
out, err := docker(ctx, s.Run, s.UID, args...)
if err != nil {
return nil, err
}
return parseManifests(out, byPath)
}
// parseManifests reads readScript's output.
func parseManifests(out string, byPath map[string]string) (map[string]*Manifest, error) {
got := map[string]*Manifest{}
current := ""
var body strings.Builder
flush := func() {
if d := byPath[current]; d != "" {
m, err := ParseManifest([]byte(body.String()))
if err != nil {
m = nil
}
got[d] = m
}
current = ""
body.Reset()
}
ended := false
for _, line := range strings.Split(out, "\n") {
switch {
case strings.HasPrefix(line, "#manifest "):
flush()
current = strings.TrimPrefix(line, "#manifest ")
case line == "#absent":
if d := byPath[current]; d != "" {
got[d] = nil
}
current = ""
body.Reset()
case line == "#end":
flush()
ended = true
default:
body.WriteString(line)
body.WriteString("\n")
}
}
if !ended {
return nil, fmt.Errorf("reading the store's manifests was cut short (it never reached its end)")
}
return got, nil
}
@@ -1,38 +0,0 @@
package main
import "testing"
func TestManifestsAreReadFromTheStoresFilesAndAMissingOneIsSaid(t *testing.T) {
got, err := parseManifests("#manifest blobs/sha256/aa/aa1/data\n"+image(d("cfg"), d("l"))+"\n\n#manifest blobs/sha256/bb/bb1/data\n#absent\n#manifest blobs/sha256/cc/cc1/data\nnot json\n#end\n",
map[string]string{"blobs/sha256/aa/aa1/data": "sha256:aa1", "blobs/sha256/bb/bb1/data": "sha256:bb1", "blobs/sha256/cc/cc1/data": "sha256:cc1"})
if err != nil {
t.Fatal(err)
}
if m := got["sha256:aa1"]; m == nil || len(m.Layers) != 1 || m.Layers[0] != d("l") {
t.Errorf("aa1 %+v", m)
}
if got["sha256:bb1"] != nil || got["sha256:cc1"] != nil {
t.Error("an absent or unreadable manifest was read as one")
}
if _, err := parseManifests("#manifest x\n{}\n", map[string]string{"x": "sha256:x"}); err == nil {
t.Error("a cut read was accepted")
}
f := world()
delete(f.manifests, d("m-old"))
v := view(t, f)
if v.Unread[Key("app/server", d("m-old"))] == "" {
t.Error("a manifest whose content is gone was not said as unread")
}
if out := Usage(v, 3); out["unread"] == nil {
t.Error("the answer did not say a manifest was unread")
}
}
func TestADigestNeverBecomesAPathOutsideTheBlobs(t *testing.T) {
for _, bad := range []string{"sha256:", "nocolon", "sha256:a/../../etc", "sha256:ab cd"} {
if p := blobPath(bad); p != "" {
t.Errorf("%q became %q", bad, p)
}
}
}
@@ -1,96 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
)
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
Err string
}
// Runner runs one command, so every tool can be tested without a daemon.
type Runner func(ctx context.Context, name string, args ...string) Ran
// ListTimeout is how long listing the store's files may take: below the runtime's thirty-second call
// limit, so a store that hangs is answered as such rather than as a call the runtime gave up on.
const ListTimeout = 15 * time.Second
// ExecRunner runs a command on this machine, bounded by ListTimeout.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, ListTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
var exitErr *exec.ExitError
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(ListTimeout/time.Second))
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Err = 127, "ENOENT"
case errors.As(err, &exitErr):
r.Status = exitErr.ExitCode()
case err != nil:
r.Status, r.Err = 1, err.Error()
}
return r
}
var socketRefused = regexp.MustCompile(`(?i)permission denied.*docker.*sock|docker\.sock.*permission denied`)
// docker runs one docker command as this account and answers its stdout. A socket that refuses the
// account is asked again through `sudo -n`, as the container runtime's own tools do; never as root
// otherwise, and never with a prompt.
func docker(ctx context.Context, run Runner, uid int, args ...string) (string, error) {
r := run(ctx, "docker", args...)
program := "docker"
if r.Status != 0 && r.Err == "" && uid != 0 && socketRefused.MatchString(r.Stderr) {
program = "sudo"
r = run(ctx, "sudo", append([]string{"-n", "docker"}, args...)...)
}
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
switch {
case r.Err == "ENOENT" && program == "sudo":
return "", errors.New("the runtime's socket refused this account, and sudo is not installed here to escalate with")
case r.Err == "ENOENT":
return "", errors.New("docker is not installed on this machine, so the store's files cannot be listed")
case r.Err != "":
return "", fmt.Errorf("listing the store's files did not answer: %s", r.Err)
case program == "sudo" && strings.HasPrefix(said, "sudo:"):
return "", fmt.Errorf("the runtime's socket refused this account and it may not escalate without a prompt: %s", firstLine(said))
case strings.Contains(said, "No such container"):
return "", fmt.Errorf("the store's container is not on this machine: %s", firstLine(said))
case strings.Contains(said, "is not running"):
return "", fmt.Errorf("the store's container is not running: %s", firstLine(said))
}
if l := firstLine(said); l != "" {
return "", fmt.Errorf("listing the store's files failed (%d): %s", r.Status, l)
}
return "", fmt.Errorf("listing the store's files failed with status %d", r.Status)
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -1,6 +1,6 @@
package main
// The seat mesh-artifact-sweep's verbs (novox/hq ADR 0571 clause 2; catalogue.ArtifactSweepSeat), each a tool named
// The seat mesh-artifact-sweep's verbs (novox/hq ADR 0571 clause 2; sweep.Seat), each a tool named
// `artifact-store.<verb>` as the runtime serves a seat's verb, answering as the controller's verb answered: {output,
// ok, answer}.
@@ -8,8 +8,8 @@ import (
"fmt"
"strings"
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// verbAnswer is the controller's verbs' answer shape, which the builder and docker read (answerOf).
@@ -21,7 +21,7 @@ type verbAnswer struct {
// SeatTools are the seat's verbs over h; s reads the store's listing for mirrors.
func SeatTools(h *Holder, s *Store) []stdio.Tool {
seat := catalogue.ArtifactSweepSeat + "."
seat := sweep.Seat + "."
return []stdio.Tool{
{Name: seat + "artifacts", Description: "Every artifact the mesh made that the store holds: kept and why, eligible, " +
"or with collected those it no longer holds.",
@@ -1,180 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
// What the artifact store holds, read from its own files, and nothing written to them.
//
// **Why its files.** The store's door lists repositories and the tags in each, but not a manifest no tag
// names — and since the mesh pins every machine by digest, that is almost every manifest the store
// holds. The files list them all, with every blob's size, and hold every manifest's content. They are
// read through the store's own container, which is the only process that has them, and only read
// (novox/hq ADR 0251 §1). So this module runs on the machine that holds the store; anywhere else its
// tools say the container is not there.
// storageRoot is where the registry keeps its files, inside its container.
const storageRoot = "/var/lib/registry/docker/registry/v2"
// listScript lists, in three sections, every blob with its size, every manifest each repository holds,
// and what each tag names now. Busybox's find and stat (the store's image is Alpine): no -printf. An
// upload in progress is not listed, and nor are a tag's former targets.
const listScript = `set -e
cd ` + storageRoot + `
echo '#blobs'
if [ -d blobs ]; then find blobs -type f -name data | xargs -r stat -c '%s %n'; fi
echo '#revisions'
if [ -d repositories ]; then find repositories -type f -path '*/_manifests/revisions/sha256/*/link'; fi
echo '#tags'
if [ -d repositories ]; then find repositories -type f -path '*/_manifests/tags/*/current/link' -exec grep -H . {} + || true; fi
echo '#end'`
// Repo is one repository as the store's files list it.
type Repo struct {
Name string
// Revisions are the digests of every manifest it holds, tagged or not.
Revisions map[string]bool
// Tags are what each tag names now.
Tags map[string]string
}
// Listing is the store's files: every blob with its size, and every repository.
type Listing struct {
Blobs map[string]int64
Repos map[string]*Repo
}
func (l *Listing) repo(name string) *Repo {
r := l.Repos[name]
if r == nil {
r = &Repo{Name: name, Revisions: map[string]bool{}, Tags: map[string]string{}}
l.Repos[name] = r
}
return r
}
// RepoNames are the repositories in order.
func (l *Listing) RepoNames() []string {
out := make([]string, 0, len(l.Repos))
for n := range l.Repos {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ParseListing reads listScript's output. A section that never ended means the listing was cut short,
// which is an error: a partial listing would make held things look absent.
func ParseListing(out string) (*Listing, error) {
l := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}}
section := ""
ended := false
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
section = line
ended = line == "#end"
continue
}
switch section {
case "#blobs":
size, path, ok := strings.Cut(line, " ")
n, err := strconv.ParseInt(size, 10, 64)
if !ok || err != nil {
return nil, fmt.Errorf("the store's listing has a blob line it cannot read: %q", line)
}
// blobs/sha256/ab/<hex>/data
parts := strings.Split(strings.TrimPrefix(path, "./"), "/")
if len(parts) != 5 || parts[0] != "blobs" || parts[4] != "data" {
return nil, fmt.Errorf("the store's listing has a blob at an unexpected place: %q", path)
}
l.Blobs[parts[1]+":"+parts[3]] = n
case "#revisions":
// repositories/<repo>/_manifests/revisions/sha256/<hex>/link
name, rest, ok := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(line, "./"), "repositories/"), "/_manifests/revisions/")
parts := strings.Split(rest, "/")
if !ok || len(parts) != 3 || parts[2] != "link" {
return nil, fmt.Errorf("the store's listing has a manifest at an unexpected place: %q", line)
}
l.repo(name).Revisions[parts[0]+":"+parts[1]] = true
case "#tags":
// repositories/<repo>/_manifests/tags/<tag>/current/link:sha256:<hex>
path, digest, ok := strings.Cut(line, ":sha256:")
name, rest, ok2 := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(path, "./"), "repositories/"), "/_manifests/tags/")
tag, _, ok3 := strings.Cut(rest, "/current/link")
if !ok || !ok2 || !ok3 || tag == "" {
return nil, fmt.Errorf("the store's listing has a tag line it cannot read: %q", line)
}
l.repo(name).Tags[tag] = "sha256:" + digest
default:
return nil, fmt.Errorf("the store's listing has a line outside any section: %q", line)
}
}
if !ended {
return nil, fmt.Errorf("the store's listing was cut short (it never reached its end)")
}
return l, nil
}
// Manifest is what one manifest names.
type Manifest struct {
MediaType string
ConfigMedia string
// Refs are the blobs it names: its configuration and its layers.
Refs []string
// Layers are its layers alone, in order.
Layers []string
// Children are the manifests an index names.
Children []string
}
// Holder is the shape of a manifest that keeps one blob: an empty configuration and one layer. The
// controller holds every archive the mesh keeps this way (novox/hq issue 253), and keeps a named
// document the same way, under a tag.
func (m *Manifest) Holder() bool {
return m != nil && m.ConfigMedia == "application/vnd.oci.empty.v1+json" && len(m.Layers) == 1
}
// ParseManifest reads a manifest's content, of any of the shapes a registry holds.
func ParseManifest(raw []byte) (*Manifest, error) {
type desc struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
}
var v struct {
MediaType string `json:"mediaType"`
Config *desc `json:"config"`
Layers []desc `json:"layers"`
Manifests []desc `json:"manifests"`
FSLayers []struct {
BlobSum string `json:"blobSum"`
} `json:"fsLayers"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, fmt.Errorf("not a manifest: %v", err)
}
m := &Manifest{MediaType: v.MediaType}
if v.Config != nil && v.Config.Digest != "" {
m.ConfigMedia = v.Config.MediaType
m.Refs = append(m.Refs, v.Config.Digest)
}
for _, d := range v.Layers {
m.Refs = append(m.Refs, d.Digest)
m.Layers = append(m.Layers, d.Digest)
}
for _, d := range v.FSLayers {
m.Refs = append(m.Refs, d.BlobSum)
m.Layers = append(m.Layers, d.BlobSum)
}
for _, d := range v.Manifests {
m.Children = append(m.Children, d.Digest)
}
return m, nil
}
@@ -1,7 +1,10 @@
package main
import (
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
"git.novox.be/novox/mesh-sdk/go/views"
"context"
@@ -24,66 +27,72 @@ func d(name string) string {
func hexOf(digest string) string { return strings.TrimPrefix(digest, "sha256:") }
// fakeStore is a store's files and its manifests: what listScript and readScript would print.
// fakeStore is a store's blobs and manifests, as the two registry seats' list answers them (ADR 0632 §4).
type fakeStore struct {
blobs map[string]int64
revisions map[string][]string // repo -> digests
tags map[string]map[string]string
manifests map[string]string // digest -> content
held map[string]bool // Key(repo, digest) -> held
}
func (f *fakeStore) listing() string {
var b strings.Builder
b.WriteString("#blobs\n")
for dg, n := range f.blobs {
fmt.Fprintf(&b, "%d blobs/sha256/%s/%s/data\n", n, hexOf(dg)[:2], hexOf(dg))
}
b.WriteString("#revisions\n")
for repo, ds := range f.revisions {
for _, dg := range ds {
fmt.Fprintf(&b, "repositories/%s/_manifests/revisions/sha256/%s/link\n", repo, hexOf(dg))
// ask is the seats as the runtime asks them: list on each, every repository or one; anything else fails the test, as
// these tools only read.
func (f *fakeStore) ask(t *testing.T) func(string, any) (json.RawMessage, error) {
return func(key string, body any) (json.RawMessage, error) {
seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
if verb != "list" || (seat != "container-image-registry" && seat != "oci-artifact-registry") {
t.Fatalf("asked %s", key)
}
}
b.WriteString("#tags\n")
for repo, ts := range f.tags {
for t, dg := range ts {
fmt.Fprintf(&b, "repositories/%s/_manifests/tags/%s/current/link:%s\n", repo, t, dg)
raw, _ := json.Marshal(body)
var in reg.ListInput
_ = json.Unmarshal(raw, &in)
repos := []reg.Repository{}
names := make([]string, 0, len(f.revisions))
for n := range f.revisions {
names = append(names, n)
}
}
b.WriteString("#end\n")
return b.String()
}
// run is the store's container as docker exec reaches it: the listing, or the manifests asked for, as
// the two scripts print them. Anything else is a test failure: these tools only read.
func (f *fakeStore) run(t *testing.T) Runner {
return func(_ context.Context, name string, args ...string) Ran {
if name != "docker" || len(args) < 5 || args[0] != "exec" || args[1] != "mesh-registry" || args[2] != "sh" || args[3] != "-c" {
t.Fatalf("ran %s %v", name, args)
}
switch args[4] {
case listScript:
return Ran{Stdout: f.listing()}
case readScript:
var b strings.Builder
for _, p := range args[6:] {
fmt.Fprintf(&b, "#manifest %s\n", p)
found := false
for dg, body := range f.manifests {
if blobPath(dg) == p {
b.WriteString(body + "\n")
found = true
sort.Strings(names)
for _, name := range names {
if in.Name != "" && in.Name != name {
continue
}
var ms []reg.Listed
for _, dg := range f.revisions[name] {
var doc struct {
Config *struct{ MediaType, Digest string }
Layers []struct{ Digest string }
Manifests []struct{ Digest string }
}
_ = json.Unmarshal([]byte(f.manifests[dg]), &doc)
e := reg.Listed{Digest: dg, Size: f.blobs[dg], Held: f.held[Key(name, dg)]}
if doc.Config != nil {
e.Size += f.blobs[doc.Config.Digest]
}
for _, l := range doc.Layers {
e.Size += f.blobs[l.Digest]
}
for _, c := range doc.Manifests {
e.Platforms = append(e.Platforms, c.Digest)
}
e.Index = len(e.Platforms) > 0
if doc.Config != nil && doc.Config.MediaType == "application/vnd.oci.empty.v1+json" && len(doc.Layers) == 1 {
e.Artifact = doc.Layers[0].Digest
}
for tag, at := range f.tags[name] {
if at == dg {
e.Tags = append(e.Tags, tag)
}
}
if !found {
b.WriteString("#absent\n")
if (e.Artifact != "") == (seat == "oci-artifact-registry") {
ms = append(ms, e)
}
}
b.WriteString("#end\n")
return Ran{Stdout: b.String()}
if len(ms) > 0 {
repos = append(repos, reg.Repository{Name: name, Manifests: ms})
}
}
t.Fatalf("ran a script these tools do not have: %q", args[4])
return Ran{}
return json.Marshal(reg.ListAnswer{Repositories: repos})
}
}
@@ -155,7 +164,7 @@ func records() *Records {
func view(t *testing.T, f *fakeStore) *View {
t.Helper()
s := &Store{Container: "mesh-registry", UID: 1000, Run: f.run(t)}
s := &Store{Ask: f.ask(t)}
v, err := s.View(context.Background())
if err != nil {
t.Fatal(err)
@@ -170,50 +179,34 @@ func withFill(r *Records) *Records {
return r
}
func TestTheListingIsRead(t *testing.T) {
l, err := ParseListing("#blobs\n12 blobs/sha256/ab/abcd/data\n#revisions\nrepositories/a/b/c/_manifests/revisions/sha256/ff/link\n#tags\nrepositories/a/b/c/_manifests/tags/v1/current/link:sha256:ff\n#end\n")
if err != nil {
t.Fatal(err)
// The store is read through its two seats' list (ADR 0632 §4): every manifest of every repository, tagged or not, an
// index with the manifests it names, a bundle's or archive's manifest as the holder of its blob; each manifest's size is
// the seat's, so nothing is unmarked before a manifest goes, and a manifest gone frees its size.
func TestTheStoreIsReadThroughItsSeats(t *testing.T) {
f := world()
f.held = map[string]bool{Key("app/tools", d("h-kept")): true}
v := view(t, f)
server := v.L.Repos["app/server"]
if server == nil || len(server.Revisions) != 4 || server.Tags["latest"] != d("m-stranger") {
t.Fatalf("app/server reads %+v", server)
}
if l.Blobs["sha256:abcd"] != 12 {
t.Errorf("blobs %v", l.Blobs)
if m := v.M[Key("app/server", d("idx"))]; m == nil || !reflect.DeepEqual(m.Children, []string{d("m-kept")}) {
t.Errorf("the index names %+v", m)
}
r := l.Repos["a/b/c"]
if r == nil || !r.Revisions["sha256:ff"] || r.Tags["v1"] != "sha256:ff" {
t.Errorf("repository with slashes misread: %+v", r)
if m := v.M[Key("app/tools", d("h-kept"))]; !m.Holder() || m.Layers[0] != d("a-kept") {
t.Errorf("the holder of a-kept reads %+v", m)
}
}
func TestACutListingIsRefused(t *testing.T) {
for _, out := range []string{"#blobs\n12 blobs/sha256/ab/abcd/data\n", "#blobs\nnot a line\n#end\n", "stray\n#end\n"} {
if _, err := ParseListing(out); err == nil {
t.Errorf("accepted %q", out)
}
if _, held := v.L.Blobs[d("a-kept")]; !held || !v.Held[Key("app/tools", d("h-kept"))] {
t.Errorf("a-kept is not read as held by the store, or its hold is not read: %v", v.Held)
}
}
func TestTheMarkIsTheCollectorsIncludingAnIndexAndSharedBlobs(t *testing.T) {
v := view(t, world())
marked := v.Marked(nil)
for _, name := range []string{"cfg", "shared", "old-only", "stranger-layer", "a-kept", "a-old", "doc-body", "empty", "m-kept", "idx"} {
if !marked[d(name)] {
t.Errorf("%s not marked", name)
}
if want := int64(len(f.manifests[d("m-old")])) + 10 + 1000 + 500; v.L.Blobs[d("m-old")] != want {
t.Errorf("m-old's size %d, want %d (its content and every blob it names)", v.L.Blobs[d("m-old")], want)
}
if marked[d("orphan")] {
t.Error("a blob no manifest names is marked")
if freed, n := v.Unmarked(nil); freed != 0 || n != 0 {
t.Errorf("unmarked %d in %d before anything goes", freed, n)
}
freed, n := v.Unmarked(nil)
if freed != 7000 || n != 1 {
t.Errorf("the collector frees %d in %d, want 7000 in 1", freed, n)
}
// The index alone gone frees its own content and nothing m-kept still marks.
_, n = v.Unmarked(map[string]bool{Key("app/server", d("idx")): true})
if n != 2 {
t.Errorf("without the index %d blobs unmarked, want 2 (orphan and the index itself)", n)
}
if sh := v.Shared(); !sh[d("empty")] || sh[d("cfg")] {
t.Error("shared misjudged")
if freed, n := v.Unmarked(map[string]bool{Key("app/server", d("idx")): true}); freed != v.L.Blobs[d("idx")] || n != 1 {
t.Errorf("the index gone frees %d in %d, want its size", freed, n)
}
}
@@ -257,16 +250,15 @@ func TestAStateThisBundleDoesNotKnowIsKept(t *testing.T) {
}
}
func TestBytesFreedCountASharedBlobAsKept(t *testing.T) {
func TestBytesFreedAreTheSizesOfWhatGoes(t *testing.T) {
v := view(t, world())
a := &CollectAnswer{DryRun: true, Eligible: 2, WouldLetGo: []string{
"artifact-store://app/server@" + d("m-old"), "artifact-store://app/tools/blobs/" + d("a-old")}}
out := Collected(v, a, true, nil)
now, after := out["collector_frees_now_bytes"].(int64), out["collector_frees_after_bytes"].(int64)
// m-old's own content, old-only and a-old and its holder go; shared and cfg stay with m-kept.
want := int64(7000) + 500 + 4000 + v.L.Blobs[d("m-old")] + v.L.Blobs[d("h-old")]
if now != 7000 || after != want {
t.Errorf("now %d after %d, want 7000 and %d", now, after, want)
// m-old and the holder of a-old go, each with the size its seat says.
if want := v.L.Blobs[d("m-old")] + v.L.Blobs[d("h-old")]; now != 0 || after != want {
t.Errorf("now %d after %d, want 0 and %d", now, after, want)
}
if out["manifests_gone"].(int) != 2 {
t.Errorf("gone %v", out["manifests_gone"])
@@ -277,8 +269,7 @@ func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
v := view(t, world())
entries := Classify(v, withFill(records()), nil)
sums := Summarise(v, entries, entries)
el := sums[StateEligible]
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
if el := sums[StateEligible]; el.OnlyTheirBytes != v.L.Blobs[d("m-old")] {
t.Errorf("eligible only-theirs %d", el.OnlyTheirBytes)
}
}
@@ -395,7 +386,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) {
f := world()
s := &Store{Container: "mesh-registry", Run: f.run(t)}
s := &Store{Ask: f.ask(t)}
for _, tl := range Tools(s, recordsAsked{a}, &Catalogue{Ask: a.ask}) {
if tl.Name == name {
return tl.Run
@@ -449,6 +440,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
Invokes []string `json:"invokes"`
Reads []string `json:"reads"`
Consumes []string `json:"consumes"`
Requires []any `json:"requires"`
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
@@ -475,7 +467,9 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
// CAT-AS1: what it holds and removes it asks the two registry seats the artifact store's holder serves.
want := []string{"seat:mesh-hand-acts.hand-act", CatalogueModules, CatalogueModule, WantsTool, ManifestsTool,
"seat:oci-artifact-registry.hold", "seat:oci-artifact-registry.release", "seat:oci-artifact-registry.remove",
"seat:container-image-registry.hold", "seat:container-image-registry.release", "seat:container-image-registry.remove"}
"seat:container-image-registry.hold", "seat:container-image-registry.release", "seat:container-image-registry.remove",
// ADR 0632 §4: what the store holds it reads through the two seats' list.
"seat:container-image-registry.list", "seat:oci-artifact-registry.list"}
if !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes %v", m.Invokes)
}
@@ -484,16 +478,27 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
slices.Contains(m.Reads, WantsRead) || slices.Contains(m.Reads, ManifestsRead) {
t.Errorf("consumes %v, reads %v", m.Consumes, m.Reads)
}
// The operator, 2026-11-04 (issue 1966): it reaches the store through the registry seats' verbs, and requires no
// provision artifact-store.
if len(m.Requires) != 0 {
t.Errorf("requires %v, want nothing: the store is reached through the registry seats", m.Requires)
}
// Its address file is filled from the image seat holder's listen (ADR 0632 §1), never by the retired provision.
address, err := os.ReadFile("../../files/store-address")
if err != nil || strings.TrimSpace(string(address)) != "${seat:container-image-registry:reach}" {
t.Errorf("the store's address file reads %q (%v), want the image seat's reach", address, err)
}
var verbs []string
for _, tl := range SeatTools(&Holder{}, &Store{}) {
verbs = append(verbs, strings.TrimPrefix(tl.Name, catalogue.ArtifactSweepSeat+"."))
verbs = append(verbs, strings.TrimPrefix(tl.Name, sweep.Seat+"."))
}
seat, _ := catalogue.SeatNamed(catalogue.ArtifactSweepSeat)
catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions()))
seat, _ := catalogue.SeatNamed(sweep.Seat)
var contract []string
for _, v := range seat.Serves {
contract = append(contract, v.Name)
}
if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.ArtifactSweepSeat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) ||
if len(m.Claims) != 1 || m.Claims[0].Name != sweep.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) ||
!reflect.DeepEqual(verbs, contract) {
t.Errorf("claims %+v, serves %v, the seat's verbs %v", m.Claims, verbs, contract)
}
@@ -503,25 +508,3 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
}
}
}
func TestASocketRefusalIsAskedAgainThroughSudo(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) Ran {
ran = append(ran, name)
if name == "docker" {
return Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"}
}
return Ran{Stdout: "#end\n"}
}
if _, err := docker(context.Background(), run, 1000, "exec", "x"); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(ran, []string{"docker", "sudo"}) {
t.Errorf("ran %v", ran)
}
if _, err := docker(context.Background(), func(context.Context, string, ...string) Ran {
return Ran{Status: 1, Stderr: "Error: No such container: mesh-registry"}
}, 1000, "exec"); err == nil || !strings.Contains(err.Error(), "not on this machine") {
t.Errorf("err %v", err)
}
}
@@ -14,23 +14,25 @@ import (
"fmt"
"strings"
ociseat "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
)
// SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked.
const SweepRefused = "sweep-refused"
// sweepFindingKey is the one key of the sweep's finding in the module's findings.
var sweepFindingKey = health.FindingKey(health.ScopeModule, Module, SweepRefused)
var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused)
// canonical is a kept reference in the older form, `artifact-store://<path>`, whichever form it came in; another is as
// it was.
func canonical(reference string) string {
path, kept := catalogue.InArtifactStore(reference)
path, kept := registryForms.InArtifactStore(reference)
if !kept {
return reference
}
@@ -40,7 +42,7 @@ func canonical(reference string) string {
// seatOf is the seat, name and digest a kept reference is held under: a bundle or an archive on oci-artifact-registry,
// an image or a copy of an upstream base on container-image-registry.
func seatOf(reference string) (seat, name, digest string, err error) {
path, kept := catalogue.InArtifactStore(reference)
path, kept := registryForms.InArtifactStore(reference)
if !kept {
return "", "", "", fmt.Errorf("%s is no reference into the mesh's registries", reference)
}
@@ -55,7 +57,7 @@ func seatOf(reference string) (seat, name, digest string, err error) {
// isCopy is whether a kept reference is a copy of an upstream base.
func isCopy(reference string) bool {
path, _ := catalogue.InArtifactStore(reference)
path, _ := registryForms.InArtifactStore(reference)
return strings.HasPrefix(path, "upstream/")
}
@@ -113,16 +115,16 @@ func (h *Holder) removeOnSeat(reference, why string) error {
// sweepFinding writes the sweep's finding: what the seat refused, and why.
func (h *Holder) sweepFinding(what string, err error) {
at := h.now().UTC()
f := health.Finding{V: health.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
Subject: health.Subject{Scope: health.ScopeModule, ID: Module},
Severity: health.Warning, Resolver: health.ResolverOperator,
f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: Module},
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what),
Headline: "The artifact sweep stopped",
Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " +
"refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.",
Needs: "read the seat holder's journal for the refusal; the next sweep asks again.",
Resolved: "Resolved: the artifact sweep ran through without a refusal",
Evidence: []health.Evidence{{At: at, Said: fmt.Sprintf("%s: %v", what, err)}},
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: fmt.Sprintf("%s: %v", what, err)}},
Source: "CAT-AS1", Observer: Module, At: at}
if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil {
h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr)
@@ -145,3 +147,7 @@ func keptWhy(r *Ref) string {
}
return "kept: " + strings.Join(r.Why, "; ")
}
// registryForms reads and routes a reference of the registry form, with the two registry seats' names from their contracts
// (the registry clients' reference forms, novox/hq ADR 0605 §9, ADR 0634 §4).
var registryForms = artifactref.Seats{Image: reg.Seat, Object: ociseat.Seat}
@@ -33,7 +33,8 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Resources []map[string]any `json:"resources"`
Packages map[string]string `json:"packages"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
@@ -41,6 +42,10 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// The builder's keys are read through its published package, taken by its series (ADR 0634 §1).
if m.Packages["builds"] != "0.1" {
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
@@ -27,9 +27,9 @@ func Tools(s *Store, c Recorded, cat *Catalogue) []stdio.Tool {
{
Name: "artifact_store_repositories",
Description: "Every repository the artifact store holds: its tags and what each names, how many manifests it holds " +
"(tagged or not — the mesh pins by digest, so most are untagged) and its size, the blobs its manifests mark. " +
"A blob two repositories share is counted in each, and shared_bytes says how much of a size that is. Read from " +
"the store's own files; changes nothing. Replaces curl /v2/_catalog and /v2/<name>/tags/list. (r)",
"(tagged or not — the mesh pins by digest, so most are untagged) and its size, each manifest's as its seat says it. " +
"A layer two manifests share is counted in each. Read through the registry seats' list (ADR 0632 §4); changes " +
"nothing. Replaces curl /v2/_catalog and /v2/<name>/tags/list. (r)",
Input: map[string]any{"repository": repoArg},
Run: func(args map[string]any) (any, error) {
v, err := s.View(ctx)
@@ -41,9 +41,9 @@ func Tools(s *Store, c Recorded, cat *Catalogue) []stdio.Tool {
},
{
Name: "artifact_store_usage",
Description: "How large the artifact store is: every blob's bytes together, the largest repositories, the bytes " +
"more than one repository's manifests mark, and the bytes no manifest marks — what the store's nightly collector " +
"frees next. Read from the store's own files; changes nothing. Replaces du on the store's directory. (r)",
Description: "How large the artifact store is: every manifest's size together, as its seat says it (a layer two " +
"manifests share is counted in each), and the largest repositories. Read through the registry seats' list (ADR " +
"0632 §4); changes nothing. Replaces du on the store's directory. (r)",
Input: map[string]any{"top": map[string]any{"type": "integer", "description": "how many of the largest repositories to name (default 10, at most 200)"}},
Run: func(args map[string]any) (any, error) {
top, err := count(args, "top", 10, 1, 200)
@@ -139,16 +139,6 @@ func Tools(s *Store, c Recorded, cat *Catalogue) []stdio.Tool {
}
}
// View reads the store now.
func (s *Store) View(ctx context.Context) (*View, error) {
l, err := s.List(ctx)
if err != nil {
return nil, err
}
m, unread := s.Manifests(ctx, l)
return &View{L: l, M: m, Unread: unread}, nil
}
// unreadNote says what manifests that could not be read do to the numbers, when there are any.
func unreadNote(v *View) map[string]any {
if len(v.Unread) == 0 {
@@ -4,13 +4,15 @@ import (
"sort"
)
// View is the store at one moment: its files, and every manifest's content that could be read.
// View is the store at one moment, as its seats list it (listed.go).
type View struct {
L *Listing
// M is each manifest read, by Key.
M map[string]*Manifest
// Unread is each manifest that could not be read, by Key, with why.
Unread map[string]string
// Held is each manifest a hold on its seat keeps from retention, by Key.
Held map[string]bool
}
// Marks are the blobs one manifest keeps from the store's collector: its own content, its
@@ -1 +1 @@
${seat:mesh-artifact-store:reach}
${seat:container-image-registry:reach}
+20 -4
View File
@@ -3,15 +3,27 @@ module artifactstoretools
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/nats-io/nats.go v1.54.0
)
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
require (
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.1 // indirect
@@ -24,3 +36,7 @@ require (
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
require (
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect
)
-8
View File
@@ -1,11 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 h1:5HeZzk6tKzcSwVivD3IcIvQ/2WIvysZvNvNKyq/oIBA=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:pPxwhTMb3hIk7UUrBicJnGQCqfTEoBGJvNFspEK2U18=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 h1:+bkN8op2yrAlBYrOB+hYnIoIZkbjXsNmc/4//uMnL4E=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:ooQa2CiP8JOvhP0bdQvwurV5wskRE7Cm68ecURBbCwU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -1,76 +0,0 @@
package registry
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
)
// blobSize is how large the store says a blob is; Gone when it does not have it.
func (s Store) blobSize(ctx context.Context, repository, digest string) (int64, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodHead, s.url(repository, "blobs", digest), nil)
if err != nil {
return 0, err
}
response, err := s.client().Do(request)
if err != nil {
return 0, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return 0, Gone
default:
return 0, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest)
}
// Read from the header rather than ContentLength: a HEAD's ContentLength is what the response
// says it would have sent, which Go reports faithfully, but a proxy in between is free to drop
// it, and the header is what the registry itself wrote.
if length := response.Header.Get("Content-Length"); length != "" {
if n, err := strconv.ParseInt(length, 10, 64); err == nil && n >= 0 {
return n, nil
}
}
if response.ContentLength >= 0 {
return response.ContentLength, nil
}
return 0, fmt.Errorf("the artifact store holds %s/blobs/%s and will not say how large it is", repository, digest)
}
// has is whether the store answers 200 for a HEAD at that URL.
func (s Store) has(ctx context.Context, url string, accept ...string) (bool, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return false, err
}
for _, media := range accept {
request.Header.Add("Accept", media)
}
response, err := s.client().Do(request)
if err != nil {
return false, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("the artifact store answered %s for %s", response.Status, url)
}
}
func (s Store) url(repository, kind, digest string) string {
return "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
}
func (s Store) client() *http.Client {
if s.HTTP != nil {
return s.HTTP
}
return &http.Client{Timeout: 30 * time.Second}
}
@@ -1,140 +0,0 @@
// Package registry reads the mesh's artifact store over its own door: whether it holds what a reference names, and
// whether a manifest is an index. It writes nothing: what the artifact sweep holds and removes it asks the registry
// seats the store's holder serves (CAT-AS1, novox/hq ADR 0617 §2).
package registry
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Store is the artifact store at an address, as this machine reaches it.
type Store struct {
// Address is `host:port` — the store as the caller reaches it now, composed and never
// recorded (novox/hq 04-ISSUES/102).
Address string
// HTTP is the client used; nil is a client with a modest timeout.
HTTP *http.Client
}
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
var Gone = errors.New("the store does not hold it")
// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing
// the store holds by digest.
//
// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite
// responses — skip one and go on, abandon the sweep for the other — and collapsing them into "an
// error" is how a cautious loop became one that did nothing while reporting the right number.
var ErrNotOurs = errors.New("not a reference into the mesh's artifact store")
// anyManifest is every manifest the store may hold, indexes included, so it answers with the
// document as it is.
var anyManifest = []string{
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
}
// Platforms is the manifests an index names, by digest; none for a manifest that is not an index.
// Gone when the store does not hold it.
func (s Store) Platforms(ctx context.Context, repository, digest string) ([]string, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "manifests", digest), nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", strings.Join(anyManifest, ", "))
response, err := s.client().Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, Gone
default:
return nil, fmt.Errorf("the artifact store answered %s when asked for %s@%s", response.Status, repository, digest)
}
var document struct {
Manifests []struct {
Digest string `json:"digest"`
} `json:"manifests"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&document); err != nil {
return nil, fmt.Errorf("%s@%s is not a manifest: %w", repository, digest, err)
}
var out []string
for _, m := range document.Manifests {
if strings.HasPrefix(m.Digest, "sha256:") {
out = append(out, m.Digest)
}
}
return out, nil
}
// split reads a recorded path into the repository, which endpoint names the thing, and the digest.
//
// Two shapes, which are the two the mesh records: `<repository>@sha256:<hex>` is a manifest, and
// `<repository>/blobs/sha256:<hex>` is a blob.
func split(path string) (repository, kind, digest string, err error) {
if before, after, ok := strings.Cut(path, "@sha256:"); ok {
return before, "manifests", "sha256:" + after, nil
}
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
return before, "blobs", "sha256:" + after, nil
}
return "", "", "", fmt.Errorf("%w: %q names nothing the store holds by digest", ErrNotOurs, path)
}
// IsIndex is whether a recorded reference is an index the store holds: one that names manifests. A
// blob, or a manifest that names none, is not. Gone when the store does not hold it.
func (s Store) IsIndex(ctx context.Context, reference string) (bool, error) {
path, ours := catalogue.InArtifactStore(reference)
if !ours {
return false, fmt.Errorf("%w: %s", ErrNotOurs, reference)
}
repository, kind, digest, err := split(path)
if err != nil || kind != "manifests" {
return false, err
}
children, err := s.Platforms(ctx, repository, digest)
return len(children) > 0, err
}
// Holds is whether the store holds what a recorded reference names: the blob of an archive, the manifest of an image.
// Gone when it does not; asks and changes nothing (ADR 0526 clause 3: a registration by hand is refused at bytes that
// are not there).
func (s Store) Holds(ctx context.Context, reference string) error {
path, ours := catalogue.InArtifactStore(reference)
if !ours {
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
}
repository, kind, digest, err := split(path)
if err != nil {
return err
}
if s.Address == "" {
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
}
if kind == "blobs" {
_, err := s.blobSize(ctx, repository, digest)
return err
}
held, err := s.has(ctx, s.url(repository, kind, digest), anyManifest...)
if err != nil {
return err
}
if !held {
return Gone
}
return nil
}
@@ -1,76 +0,0 @@
package registry
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The store's door is read only (CAT-AS1: what the sweep holds and removes it asks the registry seats): Holds says
// whether the store holds what a reference names, in either reference form, IsIndex whether it is an index; a reference
// that is not the mesh's own is never asked. A fake store answers by path and records what it was asked.
func TestTheDoorReadsWhatTheStoreHoldsAndWritesNothing(t *testing.T) {
const index = `{"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"sha256:c1"}]}`
const image = `{"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`
var asked []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked = append(asked, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/v2/web/app/manifests/sha256:abc", "/v2/web/files/blobs/sha256:def":
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(image))
} else {
w.Header().Set("Content-Length", "7")
}
case "/v2/web/app/manifests/sha256:idx":
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(index))
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
store := Store{Address: strings.TrimPrefix(srv.URL, "http://")}
ctx := context.Background()
for name, c := range map[string]struct {
reference string
holds error // nil, Gone or ErrNotOurs
index bool
}{
"an image": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:abc"},
"an image in the seat's form": {reference: catalogue.SeatReference("container-image-registry", "web", "app", "sha256:abc")},
"an archive": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:def"},
"an archive in the seat's form": {reference: catalogue.SeatReference("oci-artifact-registry", "web", "files", "sha256:def")},
"an index": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:idx", index: true},
"an image the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:999", holds: Gone},
"an archive the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:999", holds: Gone},
"another registry's image": {reference: "docker.io/library/alpine@sha256:abc", holds: ErrNotOurs},
"a reference naming no digest": {reference: catalogue.ArtifactStoreScheme + "web/app:latest", holds: ErrNotOurs},
} {
t.Run(name, func(t *testing.T) {
asked = nil
if err := store.Holds(ctx, c.reference); !errors.Is(err, c.holds) && (err != nil || c.holds != nil) {
t.Errorf("Holds = %v, want %v", err, c.holds)
}
if c.holds == nil {
if got, err := store.IsIndex(ctx, c.reference); err != nil || got != c.index {
t.Errorf("IsIndex = %v, %v, want %v", got, err, c.index)
}
}
for _, a := range asked {
if !strings.HasPrefix(a, "HEAD ") && !strings.HasPrefix(a, "GET ") {
t.Errorf("the door asked %s: it writes nothing", a)
}
}
if errors.Is(c.holds, ErrNotOurs) && len(asked) != 0 {
t.Errorf("a reference that is not the mesh's own was asked: %v", asked)
}
})
}
}
+6 -5
View File
@@ -1,6 +1,9 @@
{
"module": "artifact-store-tools",
"version": "1",
"packages": {
"builds": "0.1"
},
"claims": [
{
"name": "mesh-artifact-sweep",
@@ -14,9 +17,6 @@
]
}
],
"requires": [
"artifact-store"
],
"invokes": [
"seat:mesh-hand-acts.hand-act",
"seat:mesh-catalog.modules",
@@ -28,7 +28,9 @@
"seat:oci-artifact-registry.remove",
"seat:container-image-registry.hold",
"seat:container-image-registry.release",
"seat:container-image-registry.remove"
"seat:container-image-registry.remove",
"seat:container-image-registry.list",
"seat:oci-artifact-registry.list"
],
"reads": [
"mesh-delivery-builder.builds",
@@ -96,7 +98,6 @@
"artifact-store-tools"
],
"env": {
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names",
"MESH_ARTIFACT_STORE_ADDRESS_FILE": "${dir:state}/store-address"
}
+1 -1
View File
@@ -2,7 +2,7 @@ module asuszephyrusg14
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module avahi
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module blueman
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module bluetooth
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -2
View File
@@ -3,13 +3,12 @@ module brother
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
require (
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+12 -2
View File
@@ -122,7 +122,7 @@ The agent kept reaching for `ssh <machine> journalctl` while the service manager
- **The "instead of" table.** Every seat verb and module tool may say which shell commands it replaces
(`replaces`, in the seat's definition or the module's manifest). The module reads the bus — the seats' verbs from the
seats' contract, the placed modules' manifests from `mesh-delivery-indexer.modules` and `.manifests`, the
machines and where each module runs from `mesh-delivery-assigner.placement` (issue 1126: the controller that
machines and where each module runs from `mesh-delivery-assigner.assignments` (issue 1126: the controller that
answered this is not placed) — at start, every ten minutes and on `claude_code_render`, keeps it in its
state (`mesh-tools.json`), and renders it into `CLAUDE.md` as one row per seat or module: `<node>/<seat>.` and
each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in
@@ -206,10 +206,20 @@ judges the record as this module would have made it, composes the ask again from
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
instruction file renders under "Instructions the operator approved". A proposal is decided once per node, and an approval older than the last change applied to its section is never applied; a record
deleted from the bus removes nothing. Refused: shell lines, front matter, any `@` after a non-letter (a file the instruction file may read in), invisible or control characters, headings of level one to three, anything shaped like
a secret, more than 16 KiB, a name the terminal sets, and — at once, naming the terminal's line — whenever no channel
a secret, more than 16 KiB, and — at once, naming the terminal's line — whenever no channel
that proves its sender is there. The controller's hand-act log records each warrant once (`warranted`), read from the
router's record. The text is never logged.
**A section registered before is replaced or removed the same way** (hq issue 340, note 14): a section registered for the
mesh or the node before the controller's terminal took that over, or set there, is proposed over by name. The proposal
carries `over`, the digest of the registered text, which the act the operator's answer binds names; the ask says
plainly that it replaces (or removes) the registered section, with the registered text's fingerprint, and shows the new
text as any other. On Approve each node writes the section into `approved.json` with that `over`, and the managed
instruction file renders it in the place of the registered section (a removal leaves the section out); on Decline or at
expiry nothing changes. A replacement keeps the registered section's own heading form, because it replaces a mesh
section; the heading rule stays for every new shared section. `claude_code_proposals` shows each proposal's `registered
section` and `claude_code_registrations` lists what approvals replaced or removed.
**A session asks the operator a question on Telegram** (hq ADR 0332, issue 589). An agent that needs a decision while
the operator may be away from the terminal calls `claude_code_ask` with a headline, the context in plain words, two to
four options (a label and what choosing it means, exactly one recommended, with why), what it does if nobody
+28 -3
View File
@@ -637,6 +637,14 @@ func mergeSettings(a, b map[string]any) map[string]any {
// registered for the mesh, then those for this node.
func InstructionSections(c Config) string {
var b strings.Builder
// The approved sections that replace or remove a registered one, by its name and the digest of its text, and the
// proposals already placed where the registered section stood.
over, placed := map[string]ApprovedSection{}, map[string]bool{}
for _, s := range c.Approved {
if s.Over != "" {
over[s.Name+"|"+s.Over] = s
}
}
registered := "Registered through the `claude-code` module before that moved to the controller's terminal; change them there, in the module's `instructions` setting."
for _, part := range []struct {
title, note string
@@ -654,14 +662,31 @@ func InstructionSections(c Config) string {
}
fmt.Fprintf(&b, "\n## %s\n\n%s\n", part.title, part.note)
for _, it := range sections {
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
text := it.Files[it.Name+".md"]
if s, ok := over[it.Name+"|"+textDigest(text)]; ok {
// What the operator approved over this registered section stands in its place, or leaves it out.
placed[s.Proposal] = true
if s.Removes {
continue
}
fmt.Fprintf(&b, "\n### %s\n\n*Replaced on the operator's approval (proposed from %s; approved %s).*\n\n%s\n", it.Name, s.From,
s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text))
continue
}
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(text))
}
}
if len(c.Approved) > 0 {
var approved []ApprovedSection
for _, s := range c.Approved {
if !s.Removes && !placed[s.Proposal] {
approved = append(approved, s)
}
}
if len(approved) > 0 {
b.WriteString("\n## Instructions the operator approved\n\nProposed by an agent through the `claude-code` module and " +
"approved by the operator on a channel that proves who answered (novox/hq ADR 0274). Changed or removed only the " +
"same way: `claude_code_instructions_propose`.\n")
for _, s := range c.Approved {
for _, s := range approved {
fmt.Fprintf(&b, "\n### %s\n\n*Proposed from %s; approved %s.*\n\n%s\n", s.Name, s.From,
s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text))
}
@@ -275,11 +275,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
Consumes []string `json:"consumes"`
}
_ = json.Unmarshal(raw, &m)
// The hand-act seat's warranted and hand-act, and the owners' read tools of the three followed states; no verb of
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", ModulesTool, ManifestsTool, PlacementTool}) ||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) ||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.placement"}) {
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) {
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
}
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
+2 -2
View File
@@ -25,8 +25,8 @@ import (
"path/filepath"
"strings"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
@@ -12,7 +12,7 @@ import (
"strings"
"testing"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
)
const agentToken = "0123456789abcdef0123456789abcdef01234567"
@@ -9,20 +9,20 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the
// seats' contract and the modules' manifests say them.
func meshForTheGuard() GuardData {
defined := []seats.Seat{
{Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{
{Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}},
{Name: "node-service-manager", Scope: "node", Serves: []seats.Verb{
defined := []seatdef.Definition{
{Seat: "mesh-module-assigner", Scope: "mesh", Verbs: []seatdef.Verb{
{Name: "assignments", Description: "Read assignments: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}},
{Seat: "node-service-manager", Scope: "node", Verbs: []seatdef.Verb{
{Name: "status", Description: "One unit as the service manager sees it now: its states.", Replaces: []string{"systemctl status", "systemctl is-active"}},
{Name: "restart", Description: "Restart one unit.", Replaces: []string{"systemctl restart"}},
{Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}},
{Name: "node-hostname", Scope: "node", Serves: []seats.Verb{
{Seat: "node-hostname", Scope: "node", Verbs: []seatdef.Verb{
{Name: "add", Description: "Add one address and its names to the operator's lines of /etc/hosts.", Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}}},
}
modules := []ModuleTools{{Module: "docker", On: []string{"anchor"}, Replaces: map[string][]string{"docker_logs": {"docker logs"}, "docker_list": {"docker ps"}}},
@@ -80,14 +80,14 @@ func TestSSHToAMeshMachineIsRefusedNamingTheTool(t *testing.T) {
"ssh -o StrictHostKeyChecking=no 10.10.0.1 docker ps -a": "anchor/docker.docker_list",
"ssh anchor docker logs --tail 100 nats": "anchor/docker.docker_logs",
"ssh laptop docker logs x": "anchor/docker.docker_logs",
"ssh a uptime": "mesh-placement.placement",
"ssh a uptime": "mesh-module-assigner.assignments",
"cd /tmp && ssh anchor journalctl": "anchor/node-service-manager.journal",
"sudo -u root ssh anchor journalctl": "anchor/node-service-manager.journal",
"timeout 10 ssh anchor journalctl": "anchor/node-service-manager.journal",
"env LANG=C ssh anchor journalctl": "anchor/node-service-manager.journal",
"bash -c 'ssh anchor journalctl -u x'": "anchor/node-service-manager.journal",
`echo "$(ssh anchor journalctl -n 5)" | tail`: "anchor/node-service-manager.journal",
"for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-placement.placement",
"for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-module-assigner.assignments",
"ssh -J anchor 203.0.113.9 journalctl": "anchor/node-service-manager.journal",
"ssh via-anchor journalctl": "anchor/node-service-manager.journal",
"ssh nas.lan journalctl": "anchor/node-service-manager.journal",
@@ -0,0 +1,16 @@
package main
import (
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
)
// handActs records the hand acts of the views reader through the seat mesh-hand-acts's client. An act the seat does not
// take is an error, and the reader then does nothing (the views package's rule).
type handActs struct{}
func (handActs) RecordHandAct(a views.HandAct) error {
_, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(meshhandactsclient.Request{What: a.What, Why: a.Why, Cause: a.Cause, Verb: a.Verb})
return err
}
@@ -6,14 +6,19 @@ import (
"encoding/json"
"errors"
"fmt"
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"os"
"reflect"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -37,33 +42,33 @@ func (b aBus) Get(read, key string) ([]byte, bool, error) {
}
// someSeats are two seats as the seats' contract says them: one held once for the mesh, one on every machine.
func someSeats() []seats.Seat {
return []seats.Seat{
{Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{
{Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}},
func someSeats() []seatdef.Definition {
return []seatdef.Definition{
{Seat: "mesh-module-assigner", Domain: "Core", Scope: "mesh", Verbs: []seatdef.Verb{
{Name: "assignments", Description: "Read assignments: one node's key.", Replaces: []string{"hostnamectl", "uptime"}},
{Name: "propose", Description: "Propose a change."}}},
{Name: "node-service-manager", Scope: "node", Serves: []seats.Verb{
{Seat: "node-service-manager", Domain: "Core", Scope: "node", Verbs: []seatdef.Verb{
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot.", Replaces: []string{"systemctl status", "systemctl is-active"}},
{Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}},
}
}
// aMesh is two machines, anchor (with a public domain) and laptop, docker and zsh placed on anchor, docker's manifest
// saying what its tool replaces, as the seat mesh-placement and the indexer keep them on the bus.
// saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus.
func aMesh(t *testing.T) aBus {
t.Helper()
b := aBus{PlacementRead: {}, ModulesRead: {}, ManifestsRead: {}}
for _, p := range []delivery.Placement{
{V: 1, Node: "anchor", Decided: delivery.NodeDecided{PublicDomain: "Anchor.Example"},
Modules: []delivery.PlacedModule{{Module: "docker"}, {Module: "zsh"}}},
b := aBus{AssignmentsRead: {}, ModulesRead: {}, ManifestsRead: {}}
for _, p := range []mesh.Assignments{
{V: 1, Node: "anchor", Decided: mesh.NodeDecided{PublicDomain: "Anchor.Example"},
Modules: []mesh.PlacedModule{{Module: "docker"}, {Module: "zsh"}}},
{V: 1, Node: "laptop"},
} {
values, err := delivery.PlacementValues(p)
values, err := assignerclient.AssignmentsValues(p)
if err != nil {
t.Fatal(err)
}
for k, v := range values {
b[PlacementRead][k] = v
b[AssignmentsRead][k] = v
}
}
for module, manifest := range map[string]string{
@@ -71,14 +76,14 @@ func aMesh(t *testing.T) aBus {
"zsh": `{"module":"zsh"}`,
} {
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest)))
mkey, _ := delivery.ManifestsKey(digest)
raw, err := json.Marshal(delivery.Manifest{V: 1, Module: module, JSON: manifest})
mkey, _ := catalogclient.ManifestsKey(digest)
raw, err := json.Marshal(catalog.ManifestsByDigestValue{V: 1, Module: module, Manifest: manifest})
if err != nil {
t.Fatal(err)
}
b[ManifestsRead][mkey] = raw
key, _ := delivery.ModulesKey(module)
b[ModulesRead][key], _ = json.Marshal(delivery.Module{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"})
key, _ := catalogclient.ModulesKey(module)
b[ModulesRead][key], _ = json.Marshal(catalog.ModulesByModuleValue{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"})
}
return b
}
@@ -100,9 +105,43 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
for _, r := range m.Replacements {
addresses = append(addresses, r.Address)
}
if got := strings.Join(addresses, ","); got != "mesh-placement.placement,node-service-manager.status,node-service-manager.journal,docker.docker_logs" {
if got := strings.Join(addresses, ","); got != "mesh-module-assigner.assignments,node-service-manager.status,node-service-manager.journal,docker.docker_logs" {
t.Errorf("replacements: %s", got)
}
t.Run("the again tool answers the operator and the healers only", func(t *testing.T) {
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.mesh-healers": false, "shanks.mesh-config": true} {
if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
}
}
})
t.Run("the seats are the seat list read from the bus", func(t *testing.T) {
// A table made from what the seat mesh-catalog answers (ADR 0635 §1): no compiled list, so before the reader
// has started nothing replaces a command, and after it the seats' verbs are the seat's own.
seatListMu.Lock()
seatList = nil
seatListMu.Unlock()
if got := definedSeats(); len(got) != 0 {
t.Fatalf("seats before the reader started: %v", got)
}
startSeatList(listBus{"mesh-module-assigner": someSeats()[0], "node-service-manager": someSeats()[1]})
defer func() { seatListMu.Lock(); seatList = nil; seatListMu.Unlock() }()
// The reader's first read ends when Ready closes (no sleeping).
select {
case <-seatList.Ready():
case <-time.After(10 * time.Second):
t.Fatalf("the seat list was not read: %+v", seatList.Status())
}
got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving)
var addrs []string
for _, r := range got.Replacements {
addrs = append(addrs, r.Address)
}
// The foundation's bootstrapped seats are held beside the two the seat answered, in reading order.
if err != nil || !slices.Contains(addrs, "mesh-module-assigner.assignments") || !slices.Contains(addrs, "node-service-manager.journal") {
t.Fatalf("replacements from the bus: %v, %v", addrs, err)
}
})
if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" ||
strings.Join(m.Machines[0].Addresses, ",") != "10.10.0.1,192.0.2.10" || strings.Join(m.Machines[1].Addresses, ",") != "10.10.0.4" {
t.Errorf("machines: %+v", m.Machines)
@@ -120,7 +159,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
for _, want := range []string{
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
"| `<node>/docker.` | `docker_logs` — `docker logs` |",
"| `mesh-placement.` | `placement` — `hostnamectl`, `uptime` |",
"| `mesh-module-assigner.` | `assignments` — `hostnamectl`, `uptime` |",
"Never `ssh` to a mesh machine",
} {
if !strings.Contains(table, want) {
@@ -128,14 +167,14 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
}
}
if strings.Index(table, "node-service-manager.") > strings.Index(table, "docker.") ||
strings.Index(table, "docker.") > strings.Index(table, "`mesh-placement.`") {
strings.Index(table, "docker.") > strings.Index(table, "`mesh-module-assigner.`") {
t.Errorf("not the machines' seats, then modules, then the mesh's:\n%s", table)
}
// What the guard here named most comes first: its row, and in its row its verb.
m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-placement.placement": 1}
m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-module-assigner.assignments": 1}
ranked := InsteadOf(&m)
if !strings.Contains(ranked, "| `<node>/node-service-manager.` | `journal` — `journalctl` · `status`") ||
strings.Index(ranked, "`mesh-placement.`") > strings.Index(ranked, "`<node>/docker.`") {
strings.Index(ranked, "`mesh-module-assigner.`") > strings.Index(ranked, "`<node>/docker.`") {
t.Errorf("not ranked by the guard's record:\n%s", ranked)
}
m.Refused = nil
@@ -182,8 +221,8 @@ func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
}
}
// Where a module runs is read from the seat mesh-placement's placement (issue 1126 B13), never the controller's answer.
func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) {
// Where a module runs is read from the seat mesh-module-assigner's assignments (issue 1126 B13), never the controller's answer.
func TestTheMachinesRunningAModuleAreReadFromAssignments(t *testing.T) {
b := aMesh(t)
if got, err := NodesRunning(b, "zsh"); err != nil || strings.Join(got, ",") != "anchor" {
t.Errorf("zsh runs on %v (%v)", got, err)
@@ -192,17 +231,17 @@ func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) {
t.Errorf("a module that runs nowhere: %v (%v)", got, err)
}
if _, err := NodesRunning(aBus{}, "zsh"); err == nil {
t.Error("an unreadable placement was read as no machine")
t.Error("an unreadable assignments was read as no machine")
}
}
// ADR 0615 §1: the tools and the placement come from the owners' first views and their views events, never their state.
// ADR 0615 §1: the tools and the assignments come from the owners' first views and their views events, never their state.
// A reader whose first views are answered from the fixture reads what the state read gave, and a views event changes
// what it reads (CAT-CC3).
func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
bus := aMesh(t)
r := newMeshViews()
for _, name := range []string{ModulesRead, ManifestsRead, PlacementRead} {
for _, name := range []string{ModulesRead, ManifestsRead, AssignmentsRead} {
state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".")
values := map[string]json.RawMessage{}
for k, v := range bus[name] {
@@ -216,16 +255,16 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
t.Fatal(err)
}
}
want, err := ReadMeshToolsFrom(bus, seats.List(), resolving)
want, err := ReadMeshToolsFrom(bus, someSeats(), resolving)
if err != nil {
t.Fatal(err)
}
got, err := ReadMeshToolsFrom(viewStates{r}, seats.List(), resolving)
got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving)
if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 {
t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want)
}
// A reader not yet answered says so, and reads nothing.
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, seats.List(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
t.Fatalf("an unanswered first view: %v", err)
}
}
@@ -233,7 +272,7 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
// Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them,
// name no address of the controller, which is not placed: each job is named by the seat that owns it now.
func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) {
m, err := ReadMeshToolsFrom(aMesh(t), seats.List(), resolving)
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
if err != nil {
t.Fatal(err)
}
@@ -245,3 +284,22 @@ func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) {
t.Errorf("the instructions name the controller:\n%s", md)
}
}
// listBus answers the seat mesh-catalog's seat list from the definitions it is given.
type listBus map[string]seatdef.Definition
func (b listBus) Ask(key string, body any) (json.RawMessage, error) {
if q, _ := body.(map[string]any); q != nil && q["seat"] != nil {
d := b[q["seat"].(string)]
d.Format, d.Grain, d.Decision = seatdef.Format, seatdef.SeatList, "ADR 0635"
raw, _ := json.Marshal(d)
return json.Marshal(seatlist.Answer{Seat: d.Seat, Found: true, Entry: &seatlist.Entry{Seat: d.Seat, Source: "trunk", Digest: "d-" + d.Seat, Definition: raw}})
}
var l seatlist.Listing
for n := range b {
l.Seats = append(l.Seats, seatlist.Entry{Seat: n, Source: "trunk", Digest: "d-" + n})
}
return json.Marshal(l)
}
func (listBus) Subscribe(string, func(string, json.RawMessage) error) error { return nil }
@@ -9,5 +9,5 @@ func instructionsText(node, role string) string {
node +
"`\n- **Role:** " +
role +
"\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
+47 -16
View File
@@ -22,12 +22,11 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-contracts/go/seats"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -210,17 +209,24 @@ func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
return e.Value, true, nil
}
// nodesRunningMe is the nodes claude-code runs on, from the assigner's placement — for the register tool's question.
// nodesRunningMe is the nodes claude-code runs on, from the assigner's assignments — for the register tool's question.
func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshViews}, "claude-code") }
// The owners' read tools of the states this module follows, which the manifest invokes (novox/hq ADR 0615 §1).
// The holders whose views this module follows, as its manifest's `consumes` and `invokes` name them (the manifest test
// holds them equal).
const (
ModulesTool = delivery.IndexerModule + ".indexer_read_modules"
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
PlacementTool = delivery.AssignerModule + ".assigner_read_placement"
assignerModule = "mesh-delivery-assigner"
indexerModule = "mesh-delivery-indexer"
)
// meshViews is this module's picture of the indexer's modules and manifests and the assigner's placement: each asked
const (
ModulesTool = indexerModule + ".indexer_read_modules"
ManifestsTool = indexerModule + ".indexer_read_manifests"
AssignmentsTool = assignerModule + ".assigner_read_assignments"
)
// meshViews is this module's picture of the indexer's modules and manifests and the assigner's assignments: each asked
// once of the owner's read tool, then followed on the owner's views family. It reads none of their state.
var meshViews *views.Reader
@@ -229,17 +235,17 @@ var meshChanged = make(chan struct{}, 1)
// newMeshViews builds the reader of those three states; Start asks them.
func newMeshViews() *views.Reader {
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: views.OwnFindings{},
Acts: handacts.Recorder{Ask: stdio.Ask},
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")},
Acts: handActs{},
Changed: func(string, string, json.RawMessage, bool) {
select {
case meshChanged <- struct{}{}:
default:
}
}}
v.Follow(delivery.IndexerModule, "modules", ModulesTool)
v.Follow(delivery.IndexerModule, "manifests", ManifestsTool)
v.Follow(delivery.AssignerModule, "placement", PlacementTool)
v.Follow(indexerModule, "modules", ModulesTool)
v.Follow(indexerModule, "manifests", ManifestsTool)
v.Follow(assignerModule, "assignments", AssignmentsTool)
return v
}
@@ -319,7 +325,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
Run: func(map[string]any) (any, error) {
answer := map[string]any{}
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup); err != nil {
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup); err != nil {
answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered"
} else {
KeepMeshTools(p, m)
@@ -406,6 +412,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
}
out = append(out, judgeTool(judge), judgedTool(judge))
out = append(out, meshViews.Tools("claude_code")...)
out = append(out, seatListTools()...)
return append(out, configTools(p, config, configView)...)
}
@@ -659,3 +666,27 @@ func refusals(n int) string {
}
return fmt.Sprintf(" (after %d refusal(s))", n)
}
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7).
var seatListAgainCallers = []string{stdio.AnyPerson, "mesh-healers"}
// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The
// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called.
func seatListTools() []stdio.Tool {
names := []string{"claude_code_seat_list", "claude_code_seat_list_again"}
callers := seatListAgainCallers
shape := seatlist.Tools(&seatlist.Reader{}, names[0], names[1], callers)
for i := range shape {
run := i
shape[i].Run = func(args map[string]any) (any, error) {
seatListMu.Lock()
r := seatList
seatListMu.Unlock()
if r == nil {
return nil, errors.New("the seat list reader has not started yet: the runtime has not served this module")
}
return seatlist.Tools(r, names[0], names[1], callers)[run].Run(args)
}
}
return shape
}
+81 -31
View File
@@ -7,7 +7,7 @@ package main
// Read from the bus at start and every few minutes (issue 1126 B13; the controller that answered this before is
// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-contracts), the modules'
// own tools from the manifests the indexer keeps (`mesh-delivery-indexer.modules` and `.manifests`), and the
// machines and where each module runs from the seat mesh-placement's `placement`. Kept in the module's state so
// machines and where each module runs from the seat mesh-module-assigner's `assignments`. Kept in the module's state so
// a render never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb
// that gains a `replaces` is in the next render's table and the guard's next refusal.
@@ -16,15 +16,21 @@ import (
"encoding/json"
"errors"
"fmt"
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"net"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
// Replacement is one verb or tool and the shell commands it is the mesh's way to do.
@@ -86,9 +92,9 @@ func ReadMeshTools(p Paths) *MeshTools {
// The states read, each declared in the manifest's reads.
const (
PlacementRead = delivery.PlacementRead
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
AssignmentsRead = (assignerModule + "." + mesh.StateAssignments)
ModulesRead = indexerModule + "." + catalog.StateModules
ManifestsRead = indexerModule + "." + catalog.StateManifests
)
// States reads the module states the manifest declares in its reads: a state's keys, and one key's value.
@@ -104,17 +110,17 @@ type ModuleTools struct {
Replaces map[string][]string
}
// placements is every node's placement, from the seat mesh-placement's state.
func placements(st States) (map[string]delivery.Placement, error) {
keys, err := st.Keys(PlacementRead)
// assignments is every node's assignments, from the seat mesh-module-assigner's state.
func assignments(st States) (map[string]mesh.Assignments, error) {
keys, err := st.Keys(AssignmentsRead)
if err != nil {
return nil, fmt.Errorf("%s: %w", PlacementRead, err)
return nil, fmt.Errorf("%s: %w", AssignmentsRead, err)
}
read := delivery.NewPlacementKeys()
read := assignerclient.NewKeys()
for _, k := range keys {
v, found, err := st.Get(PlacementRead, k)
v, found, err := st.Get(AssignmentsRead, k)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", PlacementRead, k, err)
return nil, fmt.Errorf("%s %s: %w", AssignmentsRead, k, err)
}
if found {
_, _ = read.Put(k, v, 0, false) // a key that does not read places nothing
@@ -123,8 +129,8 @@ func placements(st States) (map[string]delivery.Placement, error) {
return read.All(), nil
}
// runsOn is, by module, the nodes placement puts it on, sorted.
func runsOn(placed map[string]delivery.Placement) map[string][]string {
// runsOn is, by module, the nodes assignments puts it on, sorted.
func runsOn(placed map[string]mesh.Assignments) map[string][]string {
out := map[string][]string{}
for node, p := range placed {
for _, m := range p.Modules {
@@ -137,9 +143,9 @@ func runsOn(placed map[string]delivery.Placement) map[string][]string {
return out
}
// NodesRunning is the nodes placement puts module on.
// NodesRunning is the nodes assignments puts module on.
func NodesRunning(st States, module string) ([]string, error) {
placed, err := placements(st)
placed, err := assignments(st)
if err != nil {
return nil, err
}
@@ -157,7 +163,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
}
sort.Strings(modules)
for _, module := range modules {
key, err := delivery.ModulesKey(module)
key, err := catalogclient.ModulesKey(module)
if err != nil {
continue
}
@@ -168,11 +174,11 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
}
continue
}
m, err := delivery.DecodeModule(raw)
m, err := catalogclient.DecodeModule(raw)
if err != nil || m.Manifest == "" {
continue
}
mkey, err := delivery.ManifestsKey(m.Manifest)
mkey, err := catalogclient.ManifestsKey(m.Manifest)
if err != nil {
continue
}
@@ -183,7 +189,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
}
continue
}
kept, err := delivery.DecodeManifest(raw)
kept, err := catalogclient.DecodeManifest(raw)
if err != nil {
problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err))
continue
@@ -191,7 +197,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
var man struct {
Replaces map[string][]string `json:"replaces"`
}
if json.Unmarshal([]byte(kept.JSON), &man) != nil || len(man.Replaces) == 0 {
if json.Unmarshal([]byte(kept.Manifest), &man) != nil || len(man.Replaces) == 0 {
continue
}
out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces})
@@ -217,14 +223,14 @@ func firstSentence(s string) string {
// ReplacementsOf is every seat verb and module tool that says it replaces a command: the seats' verbs first, in the
// seats' order, then the modules' own tools by module and tool.
func ReplacementsOf(defined []seats.Seat, modules []ModuleTools) []Replacement {
func ReplacementsOf(defined []seatdef.Definition, modules []ModuleTools) []Replacement {
var out []Replacement
for _, s := range defined {
for _, v := range s.Serves {
for _, v := range s.Verbs {
if len(v.Replaces) == 0 {
continue
}
out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true,
out = append(out, Replacement{Address: s.Seat + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true,
Does: firstSentence(v.Description), Replaces: v.Replaces})
}
}
@@ -255,11 +261,11 @@ func lookup(name string) []string {
return addrs
}
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node placement names
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names
// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept.
func ReadMeshToolsFrom(st States, defined []seats.Seat, resolve func(string) []string) (MeshTools, error) {
func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
var m MeshTools
placed, err := placements(st)
placed, err := assignments(st)
if err != nil {
m.Replacements = ReplacementsOf(defined, nil)
return m, err
@@ -334,7 +340,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
}
// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the placement are read again
// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the assignments are read again
// when their followed states change (a views event), not on this timer.
const MeshToolsEvery = 10 * time.Minute
@@ -347,10 +353,11 @@ func refreshMeshTools(p Paths) {
select {
case <-meshViews.Ready():
case <-time.After(MeshToolsFirst):
say("the first views of the tools and the placement were not answered in %s; reading what is held", MeshToolsFirst)
say("the first views of the tools and the assignments were not answered in %s; reading what is held", MeshToolsFirst)
}
startSeatList(seatlist.StdioBus())
for {
m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup)
m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup)
if err != nil {
say("reading what each tool replaces and which machines the mesh has: %v", err)
}
@@ -403,3 +410,46 @@ func GuardStatus(p Paths, last int) map[string]any {
"record": ReadGuardLog(d.Log, last),
}
}
// seatList is the seat list this module holds: the definitions of the seats, read from the seat mesh-catalog and
// followed on its events (novox/hq ADR 0635 §1, ADR 0636), the foundation's bootstrapped ones until it answers.
var seatList *seatlist.Reader
// startSeatList starts the reader once the runtime serves; a change of the list is told like any followed state, so the
// instructions' table is read again.
func startSeatList(bus seatlist.Bus) {
r, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(),
OnChange: func([]catalogue.Seat) {
select {
case meshChanged <- struct{}{}:
default:
}
}})
if err != nil {
say("following the seat list: %v", err)
}
seatListMu.Lock()
seatList = r
seatListMu.Unlock()
}
var seatListMu sync.Mutex
// definedSeats are the held definitions in reading order, mesh seats first, then node seats, each by name; empty until
// the reader has started.
func definedSeats() []seatdef.Definition {
seatListMu.Lock()
r := seatList
seatListMu.Unlock()
if r == nil {
return nil
}
held := r.Definitions()
var out []seatdef.Definition
for _, s := range r.Seats() {
if d, ok := held[s.Name]; ok {
out = append(out, d)
}
}
return out
}
@@ -9,7 +9,7 @@ import (
"sync"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -116,14 +116,19 @@ func proposalTools(p Paths) []stdio.Tool {
"(Telegram), shown the text (whole when it is short, else its first lines and its fingerprint), and the text " +
"reaches those sessions only on Approve. Decline, or no answer within a day, discards it. Refused for text " +
"that would run or read in something by itself (a shell line, front matter, an @ file reference), anything " +
"shaped like a secret, more than 16 KiB, a name set at the controller's terminal, or when no such channel is " +
"there (novox/hq ADR 0274, issue 340). claude_code_proposals shows where it stands.",
"shaped like a secret, more than 16 KiB, or when no such channel is there (novox/hq ADR 0274, issue 340). A name " +
"registered before for the mesh or the node, or set at the controller's terminal, is proposed too: as a " +
"replacement (the text) or with remove a removal of that registered section. The operator's ask says plainly " +
"that it replaces or removes the registered section, and shows the text the same way; on Approve every node " +
"renders the new text in the place of the old, on Decline or at expiry nothing changes. A replacement of a " +
"registered section may keep that section's own heading form (it replaces a mesh section); the heading rule " +
"stays for every new shared section. claude_code_proposals and claude_code_registrations show where it stands.",
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"),
"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"},
"remove": map[string]any{"type": "boolean", "description": "propose removing the approved or registered section of this name and scope instead"},
},
Run: func(a map[string]any) (any, error) {
if proposing == nil {
+154 -26
View File
@@ -42,8 +42,8 @@ import (
"unicode"
"unicode/utf8"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -113,6 +113,10 @@ type Proposal struct {
Form int `json:"form,omitempty"`
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
Asked string `json:"asked,omitempty"`
// Over is the digest of the text of a section registered before (for the mesh or the node, or set at the
// controller's terminal) that this proposal replaces or removes (novox/hq issue 340): the ask says so, and an
// approval is bound to it. Empty for a proposal that touches no registered section.
Over string `json:"over,omitempty"`
}
// textDigest is the digest a proposal binds: SHA-256 over the text's bytes.
@@ -166,9 +170,14 @@ func joinAnd(list []string) string {
// Act is what an answer binds: this proposal, whole, and the answer. The router's warrant names the ask's digest,
// and the ask names this act's digest in the option, so an approval authorises this text and nothing else.
func (p Proposal) Act(option string) occ.Act {
return occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
act := occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
"scope": p.Scope, "nodes": strings.Join(p.Nodes, ","), "name": p.Name, "digest": p.Digest, "from": p.From,
"at": p.At.UTC().Format(time.RFC3339Nano)}
if p.Over != "" {
// Only when set, so the act of a proposal made before keeps the digest its warrant names.
act["over"] = p.Over
}
return act
}
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
@@ -199,8 +208,18 @@ func (p Proposal) glanceAsk() oc.Ask {
}
var d strings.Builder
if p.Action == ActionRemove {
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
removes, print := "The approved text it removes", p.Fingerprint()
if p.Over != "" {
// The registered text, never the replacement's: a removal of a replacement removes both.
removes, print = "The registered text it removes", fingerprintOf(p.Over)
}
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. %s has the fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), removes, print)
if p.Over != "" && p.Digest != p.Over {
// A replacement is removed with the registered text it stands over: both fingerprints, the replacement's
// being the text sessions read.
fmt.Fprintf(&d, " The replacement sessions read, which it also removes, has the fingerprint %s.", p.Fingerprint())
}
} else {
if whole {
d.WriteString("The text:\n" + shown + "\n")
@@ -209,6 +228,9 @@ func (p Proposal) glanceAsk() oc.Ask {
}
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
if p.Over != "" {
fmt.Fprintf(&d, " It replaces the registered instructions of this name, whose text has the fingerprint %s.", fingerprintOf(p.Over))
}
}
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
does := "the text becomes part of those sessions' instructions"
@@ -220,6 +242,16 @@ func (p Proposal) glanceAsk() oc.Ask {
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
func (p Proposal) summary() string {
// A proposal over a registered section says so in the module's words, never the proposer's: the operator is
// told plainly which section of the mesh's own it replaces or removes.
if p.Over != "" {
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
if p.Action == ActionRemove {
return fmt.Sprintf("%s stops reading the instructions '%s' registered before, from its next start.", where, p.Name)
}
return fmt.Sprintf("%s reads these %d lines in place of the instructions '%s' registered before, from its next start.",
where, p.Lines(), p.Name)
}
if s := strings.TrimSpace(p.Summary); s != "" {
return s
}
@@ -461,7 +493,7 @@ func ProposalProblem(p Proposal) string {
}
switch p.Action {
case ActionAdd, ActionReplace:
if problem := ProposedTextProblem(p.Text); problem != "" {
if problem := proposedTextProblem(p.Text, p.Over != ""); problem != "" {
return problem
}
if textDigest(p.Text) != p.Digest {
@@ -474,6 +506,9 @@ func ProposalProblem(p Proposal) string {
default:
return "its action is not add, replace or remove"
}
if p.Over != "" && !strings.HasPrefix(p.Over, "sha256:") {
return "the digest of the registered text it replaces is not one"
}
return ""
}
@@ -489,7 +524,12 @@ var secretShaped = []*regexp.Regexp{
// session would run or read in by itself (issue 340's refusals, kept): a shell line, front matter, a hook, a tool
// server, a permission mode or pre-approved tools, or an `@` reference, which the instruction file reads in as a
// file; or anything shaped like a secret.
func ProposedTextProblem(text string) string {
func ProposedTextProblem(text string) string { return proposedTextProblem(text, false) }
// proposedTextProblem is ProposedTextProblem; ownHeadings says the text replaces a section registered before, which
// may keep that section's own heading form (a replacement of a mesh section is a mesh section): the heading rule
// stays for every new shared section.
func proposedTextProblem(text string, ownHeadings bool) string {
switch {
case strings.TrimSpace(text) == "":
return "the text is empty"
@@ -515,6 +555,9 @@ func ProposedTextProblem(text string) string {
return fmt.Sprintf("the text holds %q: an @ after anything but a letter or a digit may be read in as a file, "+
"and a shared section refers to no file (novox/hq issue 340)", strings.TrimSpace(m))
}
if ownHeadings {
return secretProblem(text)
}
if m := setextHeading.FindString(text); m != "" {
return "the text underlines a line with = or -, which makes it a heading that would read as a section of the mesh's own"
}
@@ -525,6 +568,10 @@ func ProposedTextProblem(text string) string {
return fmt.Sprintf("the text holds the heading %q, which would read as a section of the mesh's own; a shared "+
"section uses headings of level four or deeper", strings.TrimSpace(m))
}
return secretProblem(text)
}
func secretProblem(text string) string {
for _, re := range secretShaped {
if re.MatchString(text) {
return "the text holds something shaped like a secret, which a shared section never carries"
@@ -546,6 +593,11 @@ type ApprovedSection struct {
From string `json:"from"`
Approved time.Time `json:"approved"`
By string `json:"by"`
// Over is the digest of the registered section's text this one replaced or removed; the registered section of
// that name and text is left out of what renders. Removes marks a removal of a registered section: it holds no
// text, and its Digest is the fingerprint of the text removed.
Over string `json:"over,omitempty"`
Removes bool `json:"removes,omitempty"`
}
// ProposalDecision is what this node made of one proposal: once decided, never again.
@@ -652,8 +704,11 @@ func ApprovedHere(a Approved, node string) []ApprovedSection {
var out []ApprovedSection
for _, s := range a.Sections {
// Judged again as it renders: a record changed on disk renders nothing a proposal could not hold.
if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || ProposedTextProblem(s.Text) != "" ||
textDigest(s.Text) != s.Digest || (s.Scope != ScopeMesh && s.Scope != ScopeNode) ||
textOK := proposedTextProblem(s.Text, s.Over != "") == "" && textDigest(s.Text) == s.Digest
if s.Removes {
textOK = s.Text == "" && s.Over != "" && strings.HasPrefix(s.Digest, "sha256:")
}
if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || !textOK || (s.Scope != ScopeMesh && s.Scope != ScopeNode) ||
(s.Scope == ScopeNode && (len(s.Nodes) == 0 || nodesProblem(s.Nodes) != "")) {
continue
}
@@ -810,30 +865,42 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
}
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
registered, err := registeredSection(p, in.Scope, nodes, in.Name)
if err != nil {
return refuse(err.Error())
}
switch {
case in.Remove:
if !isActive {
return refuse(fmt.Sprintf("no approved section %q at the %s scope is held on this node to remove; one set at the "+
"controller's terminal is removed there", in.Name, in.Scope))
switch {
case isActive && active.Removes:
return refuse(fmt.Sprintf("the section %q was removed already", in.Name))
case isActive:
// An approved section that replaced a registered one is removed with it: nothing of the name is left.
prop.Action, prop.Digest, prop.Over = ActionRemove, active.Digest, active.Over
case registered != "":
prop.Action, prop.Digest, prop.Over = ActionRemove, registered, registered
default:
return refuse(fmt.Sprintf("no approved or registered section %q at the %s scope is held on this node to remove", in.Name, in.Scope))
}
prop.Action, prop.Digest = ActionRemove, active.Digest
default:
if problem := ProposedTextProblem(in.Text); problem != "" {
prop.Over = registered
if prop.Over == "" && isActive {
prop.Over = active.Over
}
if problem := proposedTextProblem(in.Text, prop.Over != ""); problem != "" {
return refuse(problem)
}
prop.Action, prop.Text, prop.Digest = ActionAdd, in.Text, textDigest(in.Text)
if isActive {
if isActive && !active.Removes {
if active.Digest == prop.Digest {
return refuse(fmt.Sprintf("the section %q already holds exactly this text", in.Name))
}
prop.Action = ActionReplace
}
}
if terminal, _ := terminalText(p); len(terminal) > 0 {
for _, it := range terminal {
if it.Kind == KindInstructions && it.Name == in.Name {
return refuse(fmt.Sprintf("a section %q is set at the controller's terminal; it is changed there", in.Name))
} else if registered != "" {
if registered == prop.Digest {
return refuse(fmt.Sprintf("the registered section %q already holds exactly this text", in.Name))
}
prop.Action = ActionReplace
}
}
if why := approverReady(bus); why != "" {
@@ -1168,15 +1235,22 @@ func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.T
switch prop.Action {
case ActionRemove:
held, ok := approved.Sections[key]
if !ok || held.Digest != prop.Digest {
switch {
case prop.Over != "" && (!ok || (!held.Removes && held.Digest == prop.Digest)):
// A registered section is removed by leaving it out of what renders: the record of the removal says
// which text, and a node holding no such section renders nothing of it either way.
approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Digest: prop.Digest,
Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w), Over: prop.Over, Removes: true}
case !ok || held.Removes || held.Digest != prop.Digest:
d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint"
approved.Changed[key] = at.UTC()
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
default:
delete(approved.Sections, key)
}
delete(approved.Sections, key)
default:
approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Text: prop.Text,
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w)}
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w), Over: prop.Over}
}
approved.Changed[key] = at.UTC()
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), occ.Says(w))
@@ -1211,6 +1285,13 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time
row := map[string]any{"id": prop.ID, "action": prop.Action, "name": prop.Name, "reaches": prop.where(),
"from": prop.From, "at": prop.At.Format(time.RFC3339), "fingerprint": prop.Fingerprint(), "lines": prop.Lines(),
"bytes": len(prop.Text)}
if prop.Over != "" {
verb := "replaces"
if prop.Action == ActionRemove {
verb = "removes"
}
row["registered section"] = verb + " the registered section whose text has the fingerprint " + fingerprintOf(prop.Over)
}
if d, ok := approved.Decided[prop.ID]; ok {
row["here"] = d
} else if why, ok := approved.Refused[prop.ID]; ok {
@@ -1252,11 +1333,58 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time
}
var here []map[string]any
for _, s := range ApprovedHere(approved, p.Node) {
here = append(here, map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal,
row := map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal,
"from": s.From, "approved": s.Approved.Format(time.RFC3339), "by": s.By,
"fingerprint": fingerprintOf(s.Digest)})
"fingerprint": fingerprintOf(s.Digest)}
if s.Over != "" {
row["registered section"] = "replaces the registered section whose text has the fingerprint " + fingerprintOf(s.Over)
if s.Removes {
row["registered section"] = "removes the registered section whose text has the fingerprint " + fingerprintOf(s.Over)
}
}
here = append(here, row)
}
return map[string]any{"proposals": rows, "approved here": here,
"how": "a proposal becomes active on a node only when that node has held the operator's warrant, read from the " +
"router's own record, to the proposal's exact text (novox/hq ADR 0274)"}, nil
}
// registeredSection is the digest of the text of the instruction section of this name that was registered before
// (for the mesh, or for this node when the proposal names it) or is set at the controller's terminal, or "" when
// there is none; an error when the places hold different texts under the name, since an approval is bound to one.
func registeredSection(p Paths, scope string, nodes []string, name string) (string, error) {
var items map[string]Item
_ = readJSON(p.config(), &items)
c := ConfigOf(Taken(items))
c.Terminal, _ = terminalText(p)
type place struct {
items []Item
says string
}
places := []place{{c.Mesh, "registered for the mesh (unregister it with claude_code_instructions_unregister, scope mesh, name " + name + ")"},
{c.Terminal, "set at the controller's terminal (its instructions setting)"}}
if scope == ScopeNode {
places = []place{{c.Node, "registered for this node (unregister it with claude_code_instructions_unregister, scope node, name " + name + ")"},
{c.Terminal, "set at the controller's terminal (its instructions setting)"}}
if !contains(nodes, p.Node) {
places = places[1:]
}
}
digest := ""
var holding []string
for _, pl := range places {
for _, it := range pl.items {
if it.Kind != KindInstructions || it.Name != name {
continue
}
d := textDigest(it.Files[name+".md"])
if digest != "" && d != digest {
return "", fmt.Errorf("the section %q is registered with different texts: %s and %s; an approval is bound to one text, "+
"so unregister one of them first", name, strings.Join(holding, ", "), pl.says)
}
digest = d
holding = append(holding, pl.says)
}
}
return digest, nil
}
@@ -13,8 +13,8 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -399,11 +399,10 @@ func TestWhatASessionWouldRunIsRefused(t *testing.T) {
if _, err := Propose(p, ProposeInput{Scope: ScopeNode, Nodes: []string{"all"}, Name: "x", Text: "t\n"}, store, r, t0); err == nil {
t.Error("all at the node scope was taken")
}
// A name the terminal sets is changed there.
// A name the terminal sets is replaced by a proposal over it (TestARegisteredSectionIsReplacedOrRemovedOnTheOperatorsApproval).
writeFile(t, p.Settings, `{"instructions": {"x": ["set at the terminal"]}}`)
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "t\n"}, store, r, t0); err == nil ||
!strings.Contains(err.Error(), "controller's terminal") {
t.Errorf("a terminal name was proposed: %v", err)
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "t\n"}, store, r, t0); err != nil {
t.Errorf("a terminal name was not proposed over: %v", err)
}
}
@@ -27,8 +27,8 @@ import (
"sync"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -12,8 +12,8 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -96,7 +96,24 @@ func Registrations(p Paths, servers ServerState, config ConfigState) (map[string
for _, it := range text {
shared = append(shared, it.Kind+" "+it.Name)
}
// The registered text an approved proposal replaced or removed on this node: what renders instead, or nothing.
replaced := []map[string]any{}
if approved, err := ReadApproved(p); err == nil {
for _, s := range ApprovedHere(approved, p.Node) {
if s.Over == "" {
continue
}
did := "replaced"
if s.Removes {
did = "removed"
}
replaced = append(replaced, map[string]any{"name": s.Name, "scope": s.Scope, "did": did,
"registered text": fingerprintOf(s.Over), "proposal": s.Proposal, "approved": s.Approved.Format("2006-01-02T15:04:05Z07:00"),
"by": s.By})
}
}
return map[string]any{
"registered text replaced by an approved proposal, or removed by one": replaced,
"set at the controller's terminal, as this node composes it": map[string]any{
"managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers,
"shared text": shared, "shared text left out": leftOut},
@@ -0,0 +1,317 @@
package main
import (
"encoding/json"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"strings"
"testing"
"time"
)
// A section registered for the mesh before the controller's terminal took that over, or set at the terminal, is
// replaced or removed by a proposal the operator approves (novox/hq ADR 0274, issue 340 note 14).
const registeredOld = "# What this mesh is\n\n## Its purpose\n\nThe old words.\n"
const registeredNew = "# What this mesh is\n\n## Its purpose\n\nThe new words.\n"
// register places the old text the way the node keeps it: in the registered items (mesh scope) or in the settings.
func register(t *testing.T, p Paths, where string) {
t.Helper()
switch where {
case "registered":
raw, _ := json.Marshal(map[string]Item{"mesh.instructions.what-this-mesh-is": {Kind: KindInstructions,
Name: "what-this-mesh-is", Scope: ScopeMesh, Files: one("what-this-mesh-is", registeredOld), By: "g14"}})
writeFile(t, p.config(), string(raw))
case "terminal":
raw, _ := json.Marshal(map[string]any{"instructions": map[string]string{"what-this-mesh-is": registeredOld}})
writeFile(t, p.Settings, string(raw))
}
}
func TestARegisteredSectionIsReplacedOrRemovedOnTheOperatorsApproval(t *testing.T) {
for _, where := range []string{"registered", "terminal"} {
t.Run(where, func(t *testing.T) {
p, _ := node(t, "g14")
other, _ := node(t, "ace")
register(t, p, where)
register(t, other, where)
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
propose := func(in ProposeInput, at time.Time) string {
t.Helper()
in.Scope, in.Name = ScopeMesh, "what-this-mesh-is"
out, err := Propose(p, in, store, r, at)
if err != nil {
t.Fatal(err)
}
return out["proposed"].(string)
}
// The same text is no change; a new section with such headings is still refused.
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "what-this-mesh-is", Text: registeredOld}, store, r, t0); err == nil {
t.Error("the registered text was proposed over itself")
}
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "fresh", Text: registeredNew}, store, r, t0); err == nil {
t.Error("a new section may hold the mesh's own headings")
}
id := propose(ProposeInput{Text: registeredNew}, t0)
a := r.published[id]
if a.Headline != "Replace Claude instructions 'what-this-mesh-is'?" ||
!strings.Contains(a.Explanation, "in place of the instructions 'what-this-mesh-is' registered before") {
t.Errorf("the ask does not say it replaces the registered section: %q / %q", a.Headline, a.Explanation)
}
if !strings.Contains(a.Details, "It replaces the registered instructions of this name, whose text has the fingerprint "+
fingerprintOf(textDigest(registeredOld))) || !strings.Contains(a.Details, "The new words.") {
t.Errorf("the ask does not show the text and what it replaces: %q", a.Details)
}
if got := renderedInstructions(t, p); !strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") {
t.Fatal("a proposal changed what renders")
}
// Declined, nothing changes.
r.close(r.chose(id, OptionDecline, t0.Add(time.Hour)))
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); changed {
t.Error("a decline changed what renders")
}
// Approved, the registered section is replaced in place, on every node that proves the warrant.
id = propose(ProposeInput{Text: registeredNew}, t0.Add(2*time.Hour))
r.close(r.chose(id, OptionApprove, t0.Add(3*time.Hour)))
for _, n := range []Paths{p, other} {
if changed, _, err := Reconcile(n, store, r, nil, t0.Add(3*time.Hour)); err != nil || !changed {
t.Fatalf("%s: the approval changed nothing (%v)", n.Node, err)
}
got := renderedInstructions(t, n)
if strings.Contains(got, "The old words.") || strings.Count(got, "The new words.") != 1 ||
strings.Count(got, "### what-this-mesh-is") != 1 {
t.Fatalf("%s: the section was not replaced once, in place:\n%s", n.Node, got)
}
if strings.Contains(got, "## Instructions the operator approved") {
t.Errorf("%s: a replacement is listed apart from the section it replaces", n.Node)
}
}
// claude_code_registrations and claude_code_proposals show it.
reg, err := Registrations(p, &bus{kept: map[string]map[string]any{}}, memConfig{})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(reg)
if !strings.Contains(string(raw), "registered text replaced by an approved proposal") {
t.Errorf("claude_code_registrations does not show the replacement: %s", raw)
}
list, _ := ListProposals(p, store, r, "", t0.Add(4*time.Hour))
raw, _ = json.Marshal(list)
if !strings.Contains(string(raw), "replaces the registered section whose text") {
t.Errorf("claude_code_proposals does not show the replacement: %s", raw)
}
// A removal of the registered section is asked the same way and leaves nothing.
rid := propose(ProposeInput{Remove: true}, t0.Add(5*time.Hour))
if got := r.published[rid]; got.Headline != "Remove Claude instructions 'what-this-mesh-is'?" {
t.Errorf("removal ask: %q", got.Headline)
}
r.close(r.chose(rid, OptionApprove, t0.Add(6*time.Hour)))
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(6*time.Hour)); !changed {
t.Fatal("the approved removal changed nothing")
}
if got := renderedInstructions(t, p); strings.Contains(got, "what-this-mesh-is") {
t.Errorf("the removal left the section:\n%s", got)
}
})
}
}
func TestARegisteredSectionIsRemovedByAProposalToo(t *testing.T) {
p, _ := node(t, "g14")
register(t, p, "registered")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "what-this-mesh-is", Remove: true}, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
if !strings.Contains(r.published[id].Explanation, "registered") || !strings.Contains(r.published[id].Details, "registered text it removes") {
t.Errorf("the ask does not say it removes a registered section: %+v", r.published[id])
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if got := renderedInstructions(t, p); strings.Contains(got, "The old words") {
t.Errorf("the registered section still renders:\n%s", got)
}
// Nothing registered and nothing approved: still refused.
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "nothing-here", Remove: true}, store, r, t0); err == nil {
t.Error("a section nobody holds was removed")
}
}
func TestAnApprovalIsBoundToTheRegisteredTextItReplaces(t *testing.T) {
prop := Proposal{ID: "instr-0123456789abcdef", Action: ActionReplace, Scope: ScopeMesh, Name: "x", Text: "t\n", Digest: textDigest("t\n"),
From: "g14", At: t0}
plain := prop.Act(OptionApprove)
if _, ok := plain["over"]; ok {
t.Error("a proposal over nothing binds an over: its older warrants would no longer hold")
}
prop.Over = textDigest("old\n")
if prop.Act(OptionApprove)["over"] != prop.Over {
t.Error("the act does not bind the registered text it replaces")
}
prop.Over = "not a digest"
if ProposalProblem(prop) == "" {
t.Error("a proposal over a text with no digest is one this module makes")
}
}
func TestANodeSectionRegisteredForThisNodeIsReplacedOnlyThere(t *testing.T) {
p, _ := node(t, "g14")
raw, _ := json.Marshal(map[string]Item{"node.g14.instructions.local": {Kind: KindInstructions, Name: "local", Scope: ScopeNode,
Files: one("local", "## Local\n\nold\n"), By: "g14"}})
writeFile(t, p.config(), string(raw))
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(p, ProposeInput{Scope: ScopeNode, Nodes: []string{"g14"}, Name: "local", Text: "## Local\n\nnew\n"}, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil {
t.Fatal(err)
}
got := renderedInstructions(t, p)
if strings.Contains(got, "\nold\n") || !strings.Contains(got, "\nnew\n") || strings.Count(got, "### local") != 1 {
t.Errorf("the node's section was not replaced in place:\n%s", got)
}
}
// The cases ADR 0642 cites, by name.
func TestRegisteredSectionEdgeCases(t *testing.T) {
const name = "what-this-mesh-is"
t.Run("TestANameRegisteredWithTwoTextsIsRefusedUntilOneIsUnregistered", func(t *testing.T) {
p, _ := node(t, "g14")
register(t, p, "registered")
raw, _ := json.Marshal(map[string]any{"instructions": map[string]string{name: registeredNew}})
writeFile(t, p.Settings, string(raw))
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
in := ProposeInput{Scope: ScopeMesh, Name: name, Text: "A third text.\n"}
if _, err := Propose(p, in, store, r, t0); err == nil || !strings.Contains(err.Error(), "different texts") ||
!strings.Contains(err.Error(), "claude_code_instructions_unregister, scope mesh, name "+name) ||
!strings.Contains(err.Error(), "set at the controller's terminal") {
t.Fatalf("a name registered with two texts was proposed over: %v", err)
}
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Remove: true}, store, r, t0); err == nil {
t.Error("a name registered with two texts was removed")
}
writeFile(t, p.config(), "{}") // one of the two is unregistered
if _, err := Propose(p, in, store, r, t0); err != nil {
t.Errorf("refused after one was unregistered: %v", err)
}
})
t.Run("TestRemovingAReplacementLeavesNeitherText", func(t *testing.T) {
p, _ := node(t, "g14")
register(t, p, "registered")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
approve := func(in ProposeInput, at time.Time) string {
in.Scope, in.Name = ScopeMesh, name
out, err := Propose(p, in, store, r, at)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
r.close(r.chose(id, OptionApprove, at.Add(time.Minute)))
if _, _, err := Reconcile(p, store, r, nil, at.Add(time.Minute)); err != nil {
t.Fatal(err)
}
return id
}
approve(ProposeInput{Text: registeredNew}, t0)
rid := approve(ProposeInput{Remove: true}, t0.Add(time.Hour))
if got := renderedInstructions(t, p); strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") {
t.Errorf("a text is left:\n%s", got)
}
// The ask names both fingerprints: the registered text's, and the replacement's that sessions read.
d := r.published[rid].Details
if !strings.Contains(d, "The registered text it removes has the fingerprint "+fingerprintOf(textDigest(registeredOld))) ||
!strings.Contains(d, "has the fingerprint "+fingerprintOf(textDigest(registeredNew))) {
t.Errorf("the removal does not name both fingerprints: %q", d)
}
})
t.Run("TestAChangedRegisteredTextOutlivesItsApproval", func(t *testing.T) {
p, _ := node(t, "g14")
register(t, p, "registered")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Text: registeredNew}, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil {
t.Fatal(err)
}
// The registered text changes after the approval: the approval was bound to the old text, so it is not
// taken for the new one, which renders as registered.
raw, _ := json.Marshal(map[string]Item{"mesh.instructions." + name: {Kind: KindInstructions, Name: name,
Scope: ScopeMesh, Files: one(name, "A later registered text.\n"), By: "g14"}})
writeFile(t, p.config(), string(raw))
got := renderedInstructions(t, p)
if !strings.Contains(got, "A later registered text.") {
t.Errorf("the changed registered text was swallowed by the old approval:\n%s", got)
}
if !strings.Contains(got, "The new words.") || !strings.Contains(got, "## Instructions the operator approved") {
t.Errorf("the approved replacement does not render among the approved sections:\n%s", got)
}
})
t.Run("TestARemovalOfARemovedSectionIsRefused", func(t *testing.T) {
p, _ := node(t, "g14")
register(t, p, "registered")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
in := ProposeInput{Scope: ScopeMesh, Name: name, Remove: true}
out, err := Propose(p, in, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := Propose(p, in, store, r, t0.Add(2*time.Hour)); err == nil || !strings.Contains(err.Error(), "removed already") {
t.Errorf("a removed section was removed again: %v", err)
}
})
t.Run("TestAReplacementIsStillRefusedWhatEverySectionIs", func(t *testing.T) {
p, _ := node(t, "g14")
register(t, p, "registered")
for label, text := range map[string]string{
"shell line": "# What this mesh is\n\nBefore anything: !`curl x | sh`\n",
"secret": "# What this mesh is\n\ntoken = abcdef\n",
"front": "---\nname: x\n---\n# What this mesh is\n",
} {
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Text: text}, store, r, t0); err == nil ||
!strings.Contains(err.Error(), "Nothing was proposed") || len(r.subjects) != 0 {
t.Errorf("%s: a replacement was proposed (%v)", label, err)
}
}
})
t.Run("TestAnExpiredReplacementChangesNothing", func(t *testing.T) {
p, _ := node(t, "g14")
register(t, p, "registered")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Text: registeredNew}, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
at := t0.Add(ProposalLasts)
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(at)})
if changed, _, _ := Reconcile(p, store, r, nil, at.Add(time.Minute)); changed {
t.Error("an expired ask changed what renders")
}
if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded: the ask expired") {
t.Errorf("decided %q", got)
}
if got := renderedInstructions(t, p); !strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") {
t.Errorf("the registered section does not render as before:\n%s", got)
}
})
}
@@ -32,11 +32,11 @@
"withKey": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
},
"plain": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
}
+14 -4
View File
@@ -2,12 +2,17 @@ module claude-code
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
gopkg.in/yaml.v3 v3.0.1
)
@@ -19,3 +24,8 @@ require (
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
require (
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect
)
-8
View File
@@ -1,11 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+8 -3
View File
@@ -25,14 +25,17 @@
"invokes": [
"seat:mesh-hand-acts.warranted",
"seat:mesh-hand-acts.hand-act",
"seat:mesh-catalog.seat-definitions",
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-assigner.assigner_read_placement"
"mesh-delivery-assigner.assigner_read_assignments"
],
"consumes": [
"mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-assigner.views.placement"
"mesh-delivery-assigner.views.assignments",
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed"
],
"state": [
"servers",
@@ -101,7 +104,9 @@
"claude_code_judge",
"claude_code_judged",
"claude_code_first_views",
"claude_code_first_view_again"
"claude_code_first_view_again",
"claude_code_seat_list",
"claude_code_seat_list_again"
],
"data": {
"own": [
+1 -1
View File
@@ -3,7 +3,7 @@ module claude-licence-manager
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/jackc/pgx/v5 v5.11.0
)
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+1 -1
View File
@@ -2,7 +2,7 @@ module clipmenu
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -2,7 +2,7 @@ module cups
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -1
View File
@@ -3,7 +3,7 @@ module dbus
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/godbus/dbus/v5 v5.1.0
)
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
+22 -21
View File
@@ -35,7 +35,8 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// Kind is this channel's kind on both benches.
@@ -59,19 +60,19 @@ const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in channel.Standing once the SDK carries it; until then the
// still open and unanswered (issue 384). It belongs in oc.Standing once the SDK carries it; until then the
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
type Standing struct {
channel.Standing
oc.Standing
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
// machine.
type Shown struct {
Message channel.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
Message oc.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
}
// Store keeps what is shown, by the router's handle.
@@ -146,7 +147,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
return nil
}
var m channel.Message
var m oc.Message
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
@@ -158,8 +159,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// would appear later, to whoever is at the screen then.
seeing, why := d.seeing()
if len(seeing) == 0 {
f := channel.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
f := oc.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(occ.IntakeSubject(occ.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
}
machines = seeing
}
@@ -176,7 +177,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
// it was shown; a message said again is shown anew where it was not.
edit := parts[4] == channel.Edit
edit := parts[4] == occ.Edit
var before *Shown
switch {
case edit:
@@ -226,8 +227,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
if len(machines) == 0 {
why = "no machine to show it on: the setting desktop-machines names none"
}
f := channel.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
f := oc.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(occ.IntakeSubject(occ.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
return err
}
return nil
@@ -322,7 +323,7 @@ func sameShown(a, b Shown) bool {
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m channel.Message, replace int) (int, error) {
func (d *Desk) show(machine string, m oc.Message, replace int) (int, error) {
urgency := "normal"
switch {
case m.Urgent:
@@ -376,12 +377,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
handle, shown := d.find(ticket)
if shown == nil {
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
_, _ = d.show(machine, oc.Message{Title: "That answer was not taken",
Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0)
return
}
if _, here := shown.On[machine]; !here {
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
_, _ = d.show(machine, oc.Message{Title: "That answer was not taken",
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
return
}
@@ -395,11 +396,11 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
d.clickedOn[id] = machine
d.mu.Unlock()
c := channel.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: channel.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(channel.IntakeSubject(channel.Chosen, Kind), c, id); err != nil {
c := oc.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: oc.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(occ.IntakeSubject(occ.Chosen, Kind), c, id); err != nil {
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
_, _ = d.show(machine, channel.Message{Title: "That answer could not be taken now",
_, _ = d.show(machine, oc.Message{Title: "That answer could not be taken now",
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
}
}
@@ -508,7 +509,7 @@ func (d *Desk) seeing() ([]string, string) {
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() Standing {
d.init()
s := Standing{Standing: channel.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
s := Standing{Standing: oc.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -542,7 +543,7 @@ func (d *Desk) SayStanding() {
if same {
return
}
if err := d.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
if err := d.Publish(occ.IntakeSubject(occ.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
d.Logf("[desk-channel] could not say whether it can send: %v", err)
return
}
@@ -10,7 +10,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
type memShown map[string]Shown
@@ -62,10 +63,10 @@ func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][
return d, &shown, said, store
}
func take(t *testing.T, d *Desk, verb string, m channel.Message) {
func take(t *testing.T, d *Desk, verb string, m oc.Message) {
t.Helper()
raw, _ := json.Marshal(m)
if err := d.Take(channel.ChannelSubject(verb, Kind), raw); err != nil {
if err := d.Take(occ.ChannelSubject(verb, Kind), raw); err != nil {
t.Fatal(err)
}
}
@@ -74,16 +75,16 @@ func take(t *testing.T, d *Desk, verb string, m channel.Message) {
// says a click as a choice the router judges — only on a machine the message was shown on.
func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
d, shown, said, _ := newDesk(t, "laptop", "away-machine")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 ||
(*shown)[0].args["body"] != "Needs you: &lt;mend&gt; them." {
t.Fatalf("shown %+v", *shown)
}
d.Chosen("e1", "laptop", "t1")
d.Chosen("e1", "laptop", "t1") // a redelivery
got := said[channel.IntakeSubject(channel.Chosen, Kind)]
var c channel.Choice
got := said[occ.IntakeSubject(occ.Chosen, Kind)]
var c oc.Choice
if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" ||
c.Sender.Identity != "laptop" || c.Sender.Authenticated {
t.Fatalf("said %s", got)
@@ -91,14 +92,14 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// Elsewhere it is refused, there, and nothing is said to the router.
d.Chosen("e2", "workstation", "t1")
d.Chosen("e3", "laptop", "forged")
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
if len(said[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 {
t.Error("a click not shown there was said")
}
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" {
t.Errorf("the refusal was not shown where it was chosen: %+v", last)
}
// The router's reply to the click is shown on the machine it was made on.
take(t, d, channel.Send, channel.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
take(t, d, occ.Send, oc.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" {
t.Errorf("%+v", last)
}
@@ -107,8 +108,8 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
func TestTheNoteFollowsTheBody(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
t.Errorf("the body: %q", last.args["body"])
}
@@ -116,8 +117,8 @@ func TestTheNoteFollowsTheBody(t *testing.T) {
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, channel.Edit, channel.Message{Handle: "m1", Title: "Silence for a week: chosen"})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "q", Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Edit, oc.Message{Handle: "m1", Title: "Silence for a week: chosen"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
@@ -134,17 +135,17 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, channel.Show, channel.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, channel.Edit, channel.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, channel.Show, channel.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []channel.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, channel.Edit, channel.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
take(t, d, occ.Show, oc.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, occ.Show, oc.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, occ.Edit, oc.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, occ.Show, oc.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []oc.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, occ.Edit, oc.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
openID := store["open"].On["laptop"]
before := len(*shown)
standings := len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
standings := len(said[occ.IntakeSubject(occ.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
@@ -157,25 +158,25 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[channel.IntakeSubject(channel.StandingWhat, Kind)]
got := said[occ.IntakeSubject(occ.StandingWhat, Kind)]
var s Standing
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, occ.Show, oc.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
t.Fatalf("the open message said again: %+v", last.args)
}
d.Chosen("e2", "laptop", "t-open")
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
if len(said[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 {
t.Error("a click on the message said again was not said to the router")
}
// The same start event again (a redelivery) does nothing more.
standings = len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[channel.IntakeSubject(channel.StandingWhat, Kind)]) != standings {
standings = len(said[occ.IntakeSubject(occ.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[occ.IntakeSubject(occ.StandingWhat, Kind)]) != standings {
t.Errorf("a redelivered start was acted on again: %d", n)
}
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
@@ -187,8 +188,8 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
d, _, said, _ := newDesk(t, "away-machine")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "x"})
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "x"})
if f := said[occ.IntakeSubject(occ.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
strings.Contains(string(f[0]), `"permanent":true`) {
t.Errorf("%s", f)
}
@@ -229,7 +230,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) {
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
d.Paused("laptop", false)
take(t, d, channel.Send, channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
take(t, d, occ.Send, oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
if len(*shown) != 1 {
t.Fatal("not shown")
}
@@ -252,8 +253,8 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
if s := d.Standing(); !s.Ready {
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
}
code := channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, channel.Send, code)
code := oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, occ.Send, code)
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
t.Fatalf("the code was shown as %+v", *shown)
}
@@ -262,11 +263,11 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
}
// Paused: not shown anywhere, said as failed, and the desk is not ready.
d.Paused("laptop", true)
take(t, d, channel.Send, code)
take(t, d, occ.Send, code)
if len(*shown) != 1 {
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
}
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
if f := said[occ.IntakeSubject(occ.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
t.Errorf("not said as failed: %s", f)
}
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") {
@@ -284,7 +285,7 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
t.Error("a machine not named made the desk ready")
}
// A message that is not secret still goes to every named machine, paused or not.
take(t, d, channel.Send, channel.Message{Handle: "m9", Title: "x"})
take(t, d, occ.Send, oc.Message{Handle: "m9", Title: "x"})
if len(*shown) != 3 {
t.Errorf("an ordinary message was held back: %+v", *shown)
}
@@ -330,8 +331,8 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
// was not shown; a message under a new handle replaces nothing.
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop", "desktop")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, channel.Send, channel.Message{Handle: "m2", Title: "another problem"})
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, occ.Send, oc.Message{Handle: "m2", Title: "another problem"})
if len(*shown) != 4 {
t.Fatalf("shown %d", len(*shown))
}
@@ -343,8 +344,8 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
first := store["m1"].On
// The desk now names a third machine as well.
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
again := (*shown)[4:]
if len(again) != 3 {
t.Fatalf("said again on %d machines", len(again))
@@ -397,18 +398,18 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
raised, _ := json.Marshal(channel.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(channel.ChannelSubject(channel.Send, Kind), raised); err != nil {
raised, _ := json.Marshal(oc.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(occ.ChannelSubject(occ.Send, Kind), raised); err != nil {
t.Fatalf("the raise was not taken: %v", err)
}
cleared, _ := json.Marshal(channel.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(channel.ChannelSubject(channel.Edit, Kind), cleared); err != nil {
cleared, _ := json.Marshal(oc.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(occ.ChannelSubject(occ.Edit, Kind), cleared); err != nil {
t.Fatalf("the clearing was not taken: %v", err)
}
d.KeepUnkept() // the minute's standing, store still down
down = !back
d.KeepUnkept()
take(t, d, channel.Send, channel.Message{Handle: "m10", Title: "another"})
take(t, d, occ.Send, oc.Message{Handle: "m10", Title: "another"})
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
}
@@ -423,7 +424,7 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
notify := d.Notify
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
@@ -432,13 +433,13 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
return notify(machine, args)
}
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
if got := store["m1"].On["desktop"]; got != first["desktop"] {
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
}
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[channel.IntakeSubject(channel.FailedWhat, Kind)]) != 1 {
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[occ.IntakeSubject(occ.FailedWhat, Kind)]) != 1 {
t.Fatalf("shown nowhere, and not said as failed")
}
_ = shown
+6 -1
View File
@@ -2,7 +2,7 @@ module deskchannel
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
@@ -12,3 +12,8 @@ require (
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
require (
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
)
-4
View File
@@ -1,7 +1,3 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+30 -10
View File
@@ -1,9 +1,9 @@
package main
// Where disk-load learns the data items it measures (novox/hq ADR 0577 clause 2): the node-engine's state data
// (delivery.EngineDataBucket), one key `<node>.<module>` per module placed on this node that declares data items, as the
// (engineDataBucket), one key `<node>.<module>` per module placed on this node that declares data items, as the
// engine composed them. disk-load watches its own node's keys and composes nothing: it reads no resolved values, no
// manifests and no placement, so a manifest its mesh-sdk cannot judge never silences it, and a round reads one watched
// manifests and no assignments, so a manifest its mesh-sdk cannot judge never silences it, and a round reads one watched
// map instead of several hundred keys over the tunnel (issue 1955).
import (
@@ -12,11 +12,30 @@ import (
"fmt"
"sync"
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The node-engine's state data as the bus names it: the seat's local name under its holder (the one name of the holder
// here, held equal to the manifest's `reads` by a unit row).
const (
engineHolder = "mesh-host"
engineDataBucket = engineHolder + "_" + nodeengine.StateData
engineDataRead = engineHolder + "." + nodeengine.StateData
)
// itemsOf are the seat's data items as this module measures them.
func itemsOf(in []nodeengine.PlacedDataItem) []catalogue.PlacedDataItem {
out := make([]catalogue.PlacedDataItem, 0, len(in))
for _, it := range in {
out = append(out, catalogue.PlacedDataItem{Module: it.Module, ID: it.Item, Class: it.Class, Path: it.Path,
CoveredBy: it.CoveredBy, Protection: it.Protection, Measure: it.Measure})
}
return out
}
// Watched is this node's data items as the engine's state data holds them, by module, kept from a watch of its keys.
type Watched struct {
Node string
@@ -29,7 +48,7 @@ type Watched struct {
// ErrNotWatchedYet is a round asked before the watch handed over the engine's current keys: no item is known yet, and
// none is taken for gone.
var ErrNotWatchedYet = errors.New("the node-engine's state " + delivery.EngineDataBucket + " is not watched yet: " +
var ErrNotWatchedYet = errors.New("the node-engine's state " + engineDataBucket + " is not watched yet: " +
"no data item of this machine is known")
// Take keeps one change of a key and answers the module it is of and whether its items changed.
@@ -48,21 +67,22 @@ func (w *Watched) Take(ch stdio.StateChange) (string, bool, error) {
delete(w.byModule, module)
return module, true, nil
}
d, err := delivery.DecodeEngineData(ch.Value)
d, err := engineclient.DecodeData(ch.Value)
if err != nil {
return "", false, fmt.Errorf("%s: %w", ch.Key, err)
}
if d.Node != w.Node {
return "", false, fmt.Errorf("%s names the machine %q, and this is %s", ch.Key, d.Node, w.Node)
}
for _, it := range d.Items {
items := itemsOf(d.Items)
for _, it := range items {
if it.Module != d.Module {
return "", false, fmt.Errorf("%s holds an item of %q, and is %s's", ch.Key, it.Module, d.Module)
}
}
changed := !sameItems(w.byModule[d.Module], d.Items)
changed := !sameItems(w.byModule[d.Module], items)
w.byKey[ch.Key] = d.Module
w.byModule[d.Module] = d.Items
w.byModule[d.Module] = items
return d.Module, changed, nil
}
@@ -102,11 +122,11 @@ func sameItems(a, b []catalogue.PlacedDataItem) bool {
// Follow watches this node's keys of the engine's state data, and asks the keeper to measure a module whose items changed
// after the watch began; it returns once the current keys were handed over (stdio.KeptState.Follow).
func (w *Watched) Follow(k *Keeper) error {
pattern, err := delivery.EngineDataNodeKeys(w.Node)
pattern, err := engineclient.DataNodeKeys(w.Node)
if err != nil {
return err
}
stdio.State(delivery.EngineDataRead).Follow(pattern, func(ch stdio.StateChange) error {
stdio.State(engineDataRead).Follow(pattern, func(ch stdio.StateChange) error {
module, changed, err := w.Take(ch)
if err != nil || !changed || ch.Current {
return err
+18 -7
View File
@@ -9,10 +9,11 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
// ADR 0577 clauses 2, 3 (and the disk-load check of clause 5): the items measured come from the node-engine's keys of
@@ -20,9 +21,9 @@ import (
// that changes after the watch began has its module measured again, and one deleted is measured no more; an item whose
// measurement fails is a failed item of the round, which measures the rest, and the round's key says so.
func TestTheItemsComeFromTheEnginesKeysAndARoundSaysHowFarItGot(t *testing.T) {
key := func(module string) string { k, _ := delivery.EngineDataKey("ace", module); return k }
key := func(module string) string { k, _ := engineclient.DataKey("ace", module); return k }
value := func(module string, items ...catalogue.PlacedDataItem) json.RawMessage {
raw, err := delivery.EncodeEngineData(delivery.EngineData{Node: "ace", Module: module, Digest: "sha256:" + module, Items: items})
raw, err := engineclient.EncodeData(nodeengine.DataByNodeModuleValue{V: 1, Node: "ace", Module: module, Digest: "sha256:" + module, Items: seatItems(items)})
if err != nil {
t.Fatal(err)
}
@@ -137,7 +138,7 @@ func TestTheItemsComeFromTheEnginesKeysAndARoundSaysHowFarItGot(t *testing.T) {
// A restart: oldie's key was deleted while the watch was down, so no delete arrives. Once the watch handed over its
// keys, oldie's reading is retired and pg's kept; an empty set retires nothing (the engine has not written yet).
_ = readings.Put("ace.oldie.x", health.Reading{V: health.ReadingVersion, Node: "ace",
_ = readings.Put("ace.oldie.x", diskreadings.Reading{V: diskreadings.ReadingVersion, Node: "ace",
PlacedDataItem: catalogue.PlacedDataItem{Module: "oldie", ID: "x", Path: "/srv/oldie"}})
for _, restart := range []struct {
name string
@@ -167,12 +168,12 @@ func TestTheItemsComeFromTheEnginesKeysAndARoundSaysHowFarItGot(t *testing.T) {
}
}
func readingOf(s *memStore, key string) *health.Reading {
func readingOf(s *memStore, key string) *diskreadings.Reading {
raw, _ := s.Get(key)
if raw == nil {
return nil
}
r, err := health.DecodeReading(raw)
r, err := diskreadings.DecodeReading(raw)
if err != nil {
return nil
}
@@ -188,3 +189,13 @@ func waitFor(t *testing.T, done func() bool) {
}
t.Fatal("not done within 30 s")
}
// seatItems are measured items as the node-engine's state data keeps them.
func seatItems(in []catalogue.PlacedDataItem) []nodeengine.PlacedDataItem {
out := make([]nodeengine.PlacedDataItem, 0, len(in))
for _, it := range in {
out = append(out, nodeengine.PlacedDataItem{Module: it.Module, Item: it.ID, Class: it.Class, Path: it.Path,
CoveredBy: it.CoveredBy, Protection: it.Protection, Measure: it.Measure})
}
return out
}
+19 -20
View File
@@ -6,9 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"os"
"os/exec"
"sort"
@@ -287,7 +286,7 @@ func (k *Keeper) MeasureModule(ctx context.Context, module string) {
// asked only on a change of that module's key (novox/hq ADR 0577 clause 2), never from a round. It is cleared when the
// item is declared again (measureOne).
func (k *Keeper) retireGone(module string, declared []catalogue.PlacedDataItem) (int, error) {
return k.retire(func(r health.Reading) bool { return r.Module == module }, declared)
return k.retire(func(r diskreadings.Reading) bool { return r.Module == module }, declared)
}
// RetireUnkeyed retires, once the watch handed over every current key, the readings of this node whose module has no
@@ -303,14 +302,14 @@ func (k *Keeper) RetireUnkeyed(ctx context.Context) {
return
}
if len(items) == 0 {
k.Say("the node-engine's state %s holds no key of this machine yet: no reading is retired for it", delivery.EngineDataBucket)
k.Say("the node-engine's state %s holds no key of this machine yet: no reading is retired for it", engineDataBucket)
return
}
var declared []catalogue.PlacedDataItem
for _, its := range items {
declared = append(declared, its...)
}
n, err := k.retire(func(r health.Reading) bool { _, keyed := items[r.Module]; return !keyed }, declared)
n, err := k.retire(func(r diskreadings.Reading) bool { _, keyed := items[r.Module]; return !keyed }, declared)
switch {
case err != nil:
k.Say("the readings of modules with no key are not all retired: %v", err)
@@ -320,14 +319,14 @@ func (k *Keeper) RetireUnkeyed(ctx context.Context) {
}
// retire marks retired every reading of this node that of picks and declared does not name.
func (k *Keeper) retire(of func(health.Reading) bool, declared []catalogue.PlacedDataItem) (int, error) {
func (k *Keeper) retire(of func(diskreadings.Reading) bool, declared []catalogue.PlacedDataItem) (int, error) {
keys, err := k.Store.Keys()
if err != nil {
return 0, err
}
declaredNow := map[string]bool{}
for _, it := range declared {
declaredNow[health.ReadingKey(k.Node, it.Module, it.ID)] = true
declaredNow[diskreadings.ReadingKey(k.Node, it.Module, it.ID)] = true
}
now := k.Measurer.Now().UTC()
n := 0
@@ -339,7 +338,7 @@ func (k *Keeper) retire(of func(health.Reading) bool, declared []catalogue.Place
if err != nil || len(raw) == 0 {
continue
}
r, err := health.DecodeReading(raw)
r, err := diskreadings.DecodeReading(raw)
if err != nil || r.Node != k.Node || !of(r) || !r.RetiredAt.IsZero() {
continue
}
@@ -357,14 +356,14 @@ func (k *Keeper) retire(of func(health.Reading) bool, declared []catalogue.Place
// when it changed. It answers whether it wrote the reading, what the measurement said failed ("" when it did not, or
// when the item was not measured now), and an error when the reading could not be read or kept.
func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, walks bool) (bool, string, error) {
key := health.ReadingKey(k.Node, it.Module, it.ID)
var prev *health.Reading
key := diskreadings.ReadingKey(k.Node, it.Module, it.ID)
var prev *diskreadings.Reading
raw, err := k.Store.Get(key)
if err != nil {
return false, "", err
}
if len(raw) > 0 {
if p, err := health.DecodeReading(raw); err == nil {
if p, err := diskreadings.DecodeReading(raw); err == nil {
prev = &p
}
}
@@ -374,7 +373,7 @@ func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, wa
return false, "", nil
}
m := k.Measurer.Measure(ctx, it)
next, changed := health.NextReading(prev, k.Node, it, m, now)
next, changed := diskreadings.NextReading(prev, k.Node, it, m, now)
if prev != nil && !prev.RetiredAt.IsZero() {
// Declared again: it is no longer retired.
next.RetiredAt, next.RetiredWhy, changed = time.Time{}, "", true
@@ -386,32 +385,32 @@ func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, wa
}
// MeasureNow measures one item now, walk or not, and writes its reading when it changed.
func (k *Keeper) MeasureNow(ctx context.Context, module, item string) (health.Reading, bool, error) {
func (k *Keeper) MeasureNow(ctx context.Context, module, item string) (diskreadings.Reading, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
items, err := k.Items(ctx)
if err != nil {
return health.Reading{}, false, err
return diskreadings.Reading{}, false, err
}
for _, it := range items[module] {
if it.ID != item {
continue
}
if it.Class == ClassCache {
return health.Reading{}, false, fmt.Errorf("%s.%s is a cache, which is listed and never measured", module, item)
return diskreadings.Reading{}, false, fmt.Errorf("%s.%s is a cache, which is listed and never measured", module, item)
}
w, _, err := k.measureOne(ctx, it, true)
if err != nil {
return health.Reading{}, false, err
return diskreadings.Reading{}, false, err
}
raw, err := k.Store.Get(health.ReadingKey(k.Node, module, item))
raw, err := k.Store.Get(diskreadings.ReadingKey(k.Node, module, item))
if err != nil {
return health.Reading{}, w, err
return diskreadings.Reading{}, w, err
}
r, err := health.DecodeReading(raw)
r, err := diskreadings.DecodeReading(raw)
return r, w, err
}
return health.Reading{}, false, fmt.Errorf("%s declares no data item %q on this machine", module, item)
return diskreadings.Reading{}, false, fmt.Errorf("%s declares no data item %q on this machine", module, item)
}
// Loop runs a round after a few minutes and then every `every`, with the walks of the night's round when it is the
+7 -7
View File
@@ -6,8 +6,8 @@ package main
import (
"context"
"fmt"
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
"regexp"
"strconv"
"strings"
@@ -56,9 +56,9 @@ func (b Measurer) exists(ctx context.Context, path string) bool {
}
// Measure is one item, measured now, by its own method.
func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) health.Measured {
func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) diskreadings.Measured {
at := b.Now().UTC()
m := health.Measured{MeasuredAt: &at}
m := diskreadings.Measured{MeasuredAt: &at}
if !b.exists(ctx, it.Path) {
m.Error = it.Path + " does not exist"
zero := int64(0)
@@ -120,7 +120,7 @@ func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) heal
// redundancyOf is the redundant storage a path is on: a ZFS pool's health, an md array's members, a btrfs
// filesystem's error counters. Nil for storage with no redundancy it knows how to read.
func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redundancy {
func (b Measurer) redundancyOf(ctx context.Context, path string) *diskreadings.Redundancy {
out, err := b.Run(ctx, "findmnt", "-no", "SOURCE,FSTYPE", "--target", path)
if err != nil {
return nil
@@ -134,7 +134,7 @@ func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redunda
switch {
case fstype == "zfs":
pool, _, _ := strings.Cut(source, "/")
r := &health.Redundancy{Kind: "zfs", Where: pool}
r := &diskreadings.Redundancy{Kind: "zfs", Where: pool}
health, err := b.Run(ctx, "zpool", "list", "-H", "-o", "health", pool)
if err != nil {
r.Said = "zpool list: " + err.Error()
@@ -156,7 +156,7 @@ func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redunda
return r
case strings.HasPrefix(source, "/dev/md"):
device := strings.TrimPrefix(source, "/dev/")
r := &health.Redundancy{Kind: "md", Where: device}
r := &diskreadings.Redundancy{Kind: "md", Where: device}
mdstat, err := b.Run(ctx, "cat", "/proc/mdstat")
if err != nil {
r.Said = err.Error()
@@ -175,7 +175,7 @@ func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redunda
}
return r
case fstype == "btrfs":
r := &health.Redundancy{Kind: "btrfs", Where: source}
r := &diskreadings.Redundancy{Kind: "btrfs", Where: source}
stats, err := b.Run(ctx, "btrfs", "device", "stats", "--check", path)
if err != nil {
r.Healthy, r.Said = &no, "device errors: "+oneLineOf(err.Error())

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