diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index 40b6618..f44644f 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -3,8 +3,22 @@ "seat": "mesh-catalog", "scope": "mesh", "grain": "seat-list", - "decision": "novox/hq ADR 0604", + "decision": "novox/hq ADR 0636", "verbs": [ + { + "name": "seat-definitions", + "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "optional": true, + "input": { + "properties": { + "seat": { + "description": "the seat's name; none lists every trunk seat", + "type": "string" + } + }, + "type": "object" + } + }, { "name": "modules", "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", diff --git a/go/seatlist/bootstrap/mesh-conditions.json b/go/seatlist/bootstrap/mesh-conditions.json new file mode 100644 index 0000000..ee5b697 --- /dev/null +++ b/go/seatlist/bootstrap/mesh-conditions.json @@ -0,0 +1,646 @@ +{ + "format": 1, + "seat": "mesh-conditions", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.", + "verbs": [ + { + "name": "conditions", + "description": "What is wrong with the mesh now: every open condition, urgent first, then oldest, raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition lately.", + "input": { + "properties": { + "days": { + "description": "with history: how many days back (default 7, at most 90)", + "type": "string" + }, + "history": { + "description": "\"true\": every raising, change, silence and clearing lately, oldest first", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "key": { + "description": "a condition's key: that one whole; with history, only its transitions", + "type": "string" + }, + "machine": { + "description": "only those about this machine", + "type": "string" + }, + "scope": { + "description": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", + "type": "string" + }, + "severity": { + "description": "only urgent, or only warning", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the open conditions, or one whole, or the history" + } + }, + { + "name": "silence", + "description": "Stop one condition's messages for a while, a hand act with why: it stays open and in status, and the silence is kept as its transition in history.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word (the condition's kind when absent)", + "type": "string" + }, + "for": { + "description": "how long: 30m, 4h, 2d; at most 7d", + "type": "string" + }, + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why, required and recorded", + "type": "string" + } + }, + "required": [ + "key", + "for", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the condition now silenced" + } + }, + { + "name": "resync", + "description": "Read every observer's findings again and raise and clear what differs from the open conditions: the recovery of a keeper that missed a change.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what was raised and cleared again" + } + } + ], + "events": [ + { + "name": "condition-raised", + "body": { + "$ref": "#/defs/ConditionEvent" + } + }, + { + "name": "condition-changed", + "body": { + "$ref": "#/defs/ConditionEvent" + } + }, + { + "name": "condition-cleared", + "body": { + "$ref": "#/defs/ConditionEvent" + } + } + ], + "family": { + "name": "mesh-conditions.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "conditions", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one open condition", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "subject", + "severity", + "raised", + "last-observed" + ] + }, + "description": "one open condition, without its looks it keeps from its finding" + } + ], + "description": "the open conditions the keeper holds, one key each" + }, + { + "name": "findings", + "template": "state", + "writer": "caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "every observer's findings, one key per finding; a key written raises or changes the condition of the same key, a key deleted clears it" + } + ], + "defs": { + "Subject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "Evidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] + }, + "Action": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "ConditionEvent": { + "type": "object", + "description": "a condition and what changed", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + }, + "event": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "change": { + "type": "string", + "enum": [ + "raised", + "reopened", + "severity", + "resolver", + "silenced", + "silence-ended", + "cleared", + "words" + ] + }, + "was": { + "type": "string" + }, + "why": { + "type": "string" + }, + "cleared": { + "type": "string", + "format": "date-time" + }, + "show": { + "type": "string", + "description": "the tool call that shows it" + } + }, + "required": [ + "key", + "event", + "at", + "change" + ] + } + } +} diff --git a/go/seatlist/bootstrap/mesh-config.json b/go/seatlist/bootstrap/mesh-config.json index d095825..640ccbb 100644 --- a/go/seatlist/bootstrap/mesh-config.json +++ b/go/seatlist/bootstrap/mesh-config.json @@ -218,5 +218,54 @@ ] } } + ], + "states": [ + { + "name": "resolved", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "the value a module on a node takes for a config key, and where it came from", + "properties": { + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": {}, + "source": { + "type": "string" + }, + "predefined": { + "type": "string", + "description": "the predefined key the value falls back to" + }, + "required": { + "type": "boolean", + "description": "an unset value is the operator's own to set" + }, + "trusted": { + "type": "boolean" + } + }, + "required": [ + "node", + "module", + "key", + "source" + ] + }, + "description": "one resolved value" + } + ], + "description": "the value each module on each node takes for each config key, built from the sources alone" + } ] } diff --git a/go/seatlist/bootstrap/mesh-hand-acts.json b/go/seatlist/bootstrap/mesh-hand-acts.json index a789dbb..e635d7a 100644 --- a/go/seatlist/bootstrap/mesh-hand-acts.json +++ b/go/seatlist/bootstrap/mesh-hand-acts.json @@ -2,8 +2,9 @@ "format": 1, "seat": "mesh-hand-acts", "scope": "mesh", - "grain": "seat-list", - "decision": "novox/hq ADR 0565", + "grain": "whole", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.", "verbs": [ { "name": "hand-act", @@ -36,6 +37,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the act recorded" } }, { @@ -65,6 +70,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the acts lately" } }, { @@ -86,12 +95,226 @@ "ask" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the warrant recorded" } } ], "events": [ { - "name": "act-recorded" + "name": "act-recorded", + "body": { + "$ref": "#/defs/Act" + } } - ] + ], + "family": { + "name": "mesh-hand-acts.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "acts", + "template": "log", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + }, + "description": "one act" + } + ], + "max-age-days": 400, + "description": "every act, kept 400 days" + }, + { + "name": "warrants", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what a module did on the operator's warrant, made once per ask", + "properties": { + "ask": { + "type": "string" + }, + "asker": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "ask", + "asker" + ] + }, + "description": "one warrant" + } + ], + "description": "warrants recorded, one key per asker and ask" + } + ], + "defs": { + "Act": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + } + } } diff --git a/go/seatlist/bootstrap/mesh-watchdogs.json b/go/seatlist/bootstrap/mesh-watchdogs.json index 3ad9f3c..ecf09a3 100644 --- a/go/seatlist/bootstrap/mesh-watchdogs.json +++ b/go/seatlist/bootstrap/mesh-watchdogs.json @@ -2,8 +2,9 @@ "format": 1, "seat": "mesh-watchdogs", "scope": "mesh", - "grain": "seat-list", - "decision": "novox/hq ADR 0564", + "grain": "whole", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters.", "verbs": [ { "name": "signals", @@ -16,6 +17,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the watchdog table" } }, { @@ -29,6 +34,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "what stopped moving" } }, { @@ -42,6 +51,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "who is connected and who is silent" } }, { @@ -54,7 +67,7 @@ "type": "string" }, "consumer": { - "description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on", + "description": "a consumer's name, or .: only what that one gave up on", "type": "string" }, "deliver": { @@ -79,6 +92,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the dead letters, one whole, or the act done" } }, { @@ -87,7 +104,139 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what was rebuilt" } } + ], + "states": [ + { + "name": "presence", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "whether a node is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a node" + }, + { + "key": "part..", + "value": { + "type": "object", + "description": "whether a part's instance is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a part's instance" + } + ], + "history": 20, + "description": "who is connected; the entry's own time says since when" + }, + { + "name": "pulse", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "a watched key mirrored, its revision, and who owes the next move", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "owes": { + "type": "string" + } + }, + "required": [ + "bucket", + "key", + "revision" + ] + }, + "description": "the move watched at a key of a state" + } + ], + "description": "one key per watched move, living for the move's bound minus its age" + }, + { + "name": "stuck", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what stopped moving", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "waited": { + "type": "string", + "description": "what it waited for, in words" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "moved": { + "type": "boolean", + "description": "it has moved again" + } + }, + "required": [ + "bucket", + "key", + "since" + ] + }, + "description": "a key whose pulse expired" + } + ], + "description": "what stopped moving; no key is removed, one that moves again is written as moved" + } ] } diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json index dfcaeb8..0987936 100644 --- a/go/seatlist/bootstrap/node-engine.json +++ b/go/seatlist/bootstrap/node-engine.json @@ -709,6 +709,367 @@ "name": "node-engine.events", "max-age-days": 7 }, + "states": [ + { + "name": "have", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one module's state on a node: what the engine has built, composed and run", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string", + "description": "the fingerprint held" + }, + "composed": { + "type": "string", + "description": "the composition's digest" + }, + "build": { + "type": "string" + }, + "phase": { + "type": "string", + "description": "how far it has come" + }, + "health": { + "type": "string", + "description": "how it is" + }, + "detail": { + "type": "string" + }, + "user-list": { + "type": "object", + "description": "the user list written" + }, + "judgement": { + "type": "string" + }, + "waits": { + "type": "string", + "description": "what it waits for" + }, + "build-alone": { + "type": "boolean" + }, + "kept": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed-at": { + "type": "object" + }, + "pull-wait": { + "type": "object", + "description": "a pull the engine waits for" + }, + "handed": { + "type": "object", + "description": "the accounts handed" + } + }, + "required": [ + "v", + "phase", + "health" + ] + }, + "description": "one module on a node" + }, + { + "key": ".node", + "value": { + "type": "object", + "description": "a node's own record", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string" + }, + "windows": { + "type": "object" + }, + "busy": { + "type": "object" + }, + "strays": { + "type": "object" + }, + "filters": { + "type": "object" + }, + "profile": { + "type": "object" + }, + "record": { + "type": "object", + "description": "the node's record: its tunnel and certificate" + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed-at": { + "type": "object" + }, + "candidates": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "composes": { + "type": "string", + "description": "own, or the controller's" + }, + "composes-why": { + "type": "string" + } + }, + "required": [ + "v" + ] + }, + "description": "the node's own record" + } + ], + "history": 20, + "description": "what each node has of each module, written by its engine" + }, + { + "name": "composed", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one composition of a module on a node under a fingerprint", + "properties": { + "V": { + "type": "integer" + }, + "Node": { + "type": "string" + }, + "Module": { + "type": "string" + }, + "FP": { + "type": "string" + }, + "Data": { + "type": "boolean" + }, + "Resources": { + "type": "array", + "items": { + "type": "object" + } + }, + "Filled": { + "type": "array", + "items": { + "type": "string" + } + }, + "Own": { + "type": "object", + "description": "the node's own adoption, identities, groups, libraries and provisioner account" + }, + "Plan": { + "type": "object", + "description": "the module's plan" + }, + "Refused": { + "type": "string", + "description": "why the composition was refused" + } + }, + "required": [ + "Node", + "Module", + "FP" + ] + }, + "description": "one composition" + } + ], + "description": "the compositions of one node, kept in a bucket per node named composed-" + }, + { + "name": "data", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the data items a module has on a node", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "node", + "module", + "digest", + "items" + ] + }, + "description": "a module's data on a node" + } + ], + "description": "the data items of each module on each node" + }, + { + "name": "packages", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the packages a node declares", + "properties": { + "node": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "absent": { + "type": "boolean" + } + } + } + } + }, + "required": [ + "node", + "packages" + ] + }, + "description": "a node's declared packages" + } + ], + "description": "the packages each node declares" + }, + { + "name": "health", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "a node's health as its engine reports it", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "network": { + "type": "object" + }, + "peers": { + "type": "array", + "items": { + "type": "object" + } + }, + "units": { + "type": "object" + }, + "relogin": { + "type": "array", + "items": { + "type": "object" + } + }, + "found": { + "type": "array", + "items": { + "type": "object" + } + }, + "fallback": { + "type": "object" + }, + "root": { + "type": "array", + "items": { + "type": "object" + } + }, + "contract": { + "type": "integer" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "node", + "at" + ] + }, + "description": "a node's health" + } + ], + "description": "each node's health as its engine reports it" + } + ], "defs": { "Act": { "type": "object", diff --git a/go/seatlist/bootstrap/operator-channel.json b/go/seatlist/bootstrap/operator-channel.json new file mode 100644 index 0000000..9770349 --- /dev/null +++ b/go/seatlist/bootstrap/operator-channel.json @@ -0,0 +1,567 @@ +{ + "format": 1, + "seat": "operator-channel", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0259", + "holder-declares": true, + "description": "Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).", + "verbs": [ + { + "name": "open", + "description": "What is open now: every message the operator was sent about something still wrong, urgent first, oldest first.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the open messages" + } + }, + { + "name": "history", + "description": "What was said to the operator lately, newest first, the refusals, and what waits to be sent on each channel.", + "input": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "at most this many of each (default 50)" + } + } + }, + "answer": { + "type": "object", + "description": "the sends, refusals and what waits" + } + }, + { + "name": "notify", + "description": "Tell the operator something, as a module that uses the seat: a key, urgent or warning, one line, and what it is about; clear says it is over.", + "input": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "the message's key" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "subject": { + "type": "string", + "description": "what it is about" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "explanation": { + "type": "string", + "description": "one or two plain sentences" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "clear": { + "type": "boolean", + "description": "it is over" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "tell", + "description": "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380).", + "input": { + "type": "object", + "properties": { + "headline": { + "type": "string", + "description": "plain words" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "one or two plain lines" + }, + "subject": { + "type": "string", + "description": "what it is about" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "asks", + "description": "The asks the router holds: open first, then the last month's, each with how it ended.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the asks" + } + } + ], + "accepts": [ + { + "name": "ask", + "by-caller": true, + "body": { + "$ref": "#/defs/Ask" + } + }, + { + "name": "cancel", + "by-caller": true, + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "condition", + "by-caller": true, + "body": { + "$ref": "#/defs/Condition" + } + } + ], + "events": [ + { + "name": "decided", + "by-caller": true, + "body": { + "$ref": "#/defs/Warrant" + } + } + ], + "family": { + "name": "operator-channel.decided", + "max-age-days": 7 + }, + "states": [ + { + "name": "asks", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one ask the router holds and how it stands" + }, + "description": "one ask the router holds and how it stands" + } + ], + "description": "the asks a module raised and the router holds, one key per asker and ask" + } + ], + "holder-events": [ + { + "name": "refused", + "subject": "mesh.mod..event.refused" + }, + { + "name": "offered", + "subject": "mesh.stream..event.offered", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "headline": { + "type": "string", + "description": "its headline" + }, + "channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the channels it was offered on" + } + }, + "required": [ + "id", + "asker" + ] + } + }, + { + "name": "closed", + "subject": "mesh.stream..event.closed", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "how": { + "type": "string", + "enum": [ + "answered", + "expired", + "cancelled", + "replaced", + "unlinked" + ] + } + }, + "required": [ + "id", + "asker", + "how" + ] + } + } + ], + "defs": { + "Ask": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the asker's own id" + }, + "headline": { + "type": "string", + "description": "a few plain words" + }, + "explanation": { + "type": "string", + "description": "what happened and what it means" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/defs/Option" + }, + "description": "the answers it offers" + }, + "who": { + "type": "string", + "description": "who may answer: operator" + }, + "expires": { + "type": "string", + "description": "when it ends unanswered, RFC 3339" + }, + "on-expiry": { + "type": "string", + "description": "what the asker then does" + }, + "about": { + "type": "string", + "description": "what it is about: a newer ask about it replaces the older" + }, + "urgent": { + "type": "boolean", + "description": "urgent" + }, + "whole": { + "type": "string", + "description": "the explanation with its values whole" + }, + "details": { + "type": "string", + "description": "its secondary words" + }, + "whole-details": { + "type": "string", + "description": "the details whole" + } + }, + "required": [ + "id", + "headline", + "explanation", + "options", + "who", + "expires", + "on-expiry" + ] + }, + "Option": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the option's id" + }, + "label": { + "type": "string", + "description": "the button's label" + }, + "does": { + "type": "string", + "description": "what it does, in plain words" + }, + "level": { + "type": "string", + "description": "the proof its answer needs" + }, + "binds": { + "type": "string", + "description": "the digest of exactly what the asker performs when it is chosen" + } + }, + "required": [ + "id", + "label", + "does", + "level" + ] + }, + "Warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string", + "description": "the ask" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "about": { + "type": "string", + "description": "what it was about" + }, + "outcome": { + "type": "string", + "enum": [ + "chosen", + "expired", + "cancelled", + "replaced", + "refused" + ] + }, + "option": { + "type": "string", + "description": "the option chosen" + }, + "label": { + "type": "string", + "description": "its label" + }, + "level": { + "type": "string", + "description": "its level" + }, + "by": { + "$ref": "#/defs/Person" + }, + "channel": { + "type": "string", + "description": "the module it came through" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the proofs present" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + }, + "ask-digest": { + "type": "string", + "description": "the digest of the ask as the router took it" + }, + "words": { + "type": "string", + "description": "why it ended without a choice, or what refused it" + } + }, + "required": [ + "ask", + "asker", + "outcome", + "at" + ] + }, + "Person": { + "type": "object", + "properties": { + "who": { + "type": "string", + "description": "the role" + }, + "kind": { + "type": "string", + "description": "the channel kind" + }, + "identity": { + "type": "string", + "description": "the account" + }, + "display": { + "type": "string", + "description": "the name shown" + }, + "verified": { + "type": "string", + "description": "how the router knew" + } + }, + "required": [ + "who", + "kind", + "identity", + "verified" + ] + }, + "Condition": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the form's version, 1" + }, + "key": { + "type": "string", + "description": "the condition's key" + }, + "severity": { + "type": "string", + "description": "urgent or warning" + }, + "subject": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "scope" + }, + "id": { + "type": "string", + "description": "id" + }, + "machine": { + "type": "string", + "description": "machine" + } + }, + "required": [ + "scope", + "id" + ] + }, + "state": { + "type": "string", + "description": "raised or cleared" + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "needs": { + "type": "string", + "description": "what it needs" + }, + "explanation": { + "type": "string", + "description": "plain words" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "label" + }, + "verb": { + "type": "string", + "description": "verb" + }, + "machine": { + "type": "string", + "description": "machine" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "description": "level" + } + }, + "required": [ + "label", + "verb" + ] + }, + "description": "the answers it offers" + } + }, + "required": [ + "v", + "key", + "subject", + "state" + ] + } + } +} diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index ea5d413..e18878e 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -1,17 +1,21 @@ -// Package seatlist is the runtime's reader of the seat list (novox/hq ADR 0635 §1, §3): the seat definitions the -// indexer keeps on the bus, asked once through the owner's tool and followed on its event `seat-definition-changed`. -// The manifest validator, the node-engine, the tool runner, claude-code, the grants part and every other caller of -// `catalogue` get their seats here and give them to `catalogue` as an input; mesh-sdk compiles no seat list and imports -// no contracts module. +// Package seatlist is the runtime's reader of the seat list (novox/hq ADR 0635 §1, ADR 0636 §1, §3, §4): the seat +// definitions the seat mesh-catalog keeps, read through its verb `seat-definitions` and followed on its events +// `seat-definition-changed` and `seat-definition-removed`. The manifest validator, the node-engine, the tool runner, +// claude-code, the grants part and every other caller of `catalogue` get their seats here and give them to `catalogue` +// as an input; mesh-sdk compiles no seat list and imports no contracts module. // -// **What it asks and hears.** The owner's tool Tool, with no seat for every trunk definition (the answer's shape is -// Listing) and with one seat for that seat's (the answer is the owner's own, read as Answer); the owner's event -// ChangedEvent, whose body names the seat and its source (Changed). Only trunk definitions are held: a pull request's are -// the indexer's to judge. Nothing polls and nothing waits on a timer: a reader that could not ask says so in its -// Status and reads again on the next event or on Reload, which the host module offers as a tool (steering it). +// **What it asks and hears.** The seat's verb with no seat (every trunk definition's name and digest, Listing) and with +// one seat (that definition, Answer); the seat's two events, whose bodies name the seat (Changed). It calls the seat, +// never the module that holds it, and only trunk definitions are held: a pull request's are the indexer's to judge. +// Nothing polls and nothing waits on a timer: a reader that could not ask says so in its Status and reads again on the +// next event or on Reload, which the host offers as a tool (Tools). // -// **What it names.** The tool's and the event's names, which ADR 0635 §1 gives the owner. It names no module and no -// seat: the owner's module is the caller's (Config.Owner), the one its manifest's `invokes` lists. +// **A reader that has not heard acts on nothing new** (ADR 0636 §1): MayAct says what a host may start, compose, grant +// or judge meanwhile, which is only the modules whose seats the bootstrapped definitions name (ADR 0636 §2). It keeps +// running what it runs; a missing or removed definition only refuses a new claim, invoke or grant of that seat (Has). +// +// **What it names.** The one seat that serves the list (Seat and Verb), which ADR 0636 §4 fixes as the runtime's own +// way in; no module, no other seat. package seatlist import ( @@ -22,43 +26,56 @@ import ( "strings" "sync" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" ) const ( - // Tool is the owner's tool that answers a seat definition. - Tool = "indexer_seat_definition" - // ChangedEvent is the owner's event that says a definition moved. + // Seat is the seat that serves the seat list (ADR 0636 §4); the bootstrapped definitions name it (ADR 0636 §2). + Seat = "mesh-catalog" + // Verb is its verb: with no seat, every trunk seat with its digest; with a seat, that definition. + Verb = "seat-definitions" + // ChangedEvent and RemovedEvent are its events: a trunk's definition moved, or went. ChangedEvent = "seat-definition-changed" + RemovedEvent = "seat-definition-removed" // SourceTrunk is the only source this reader holds. SourceTrunk = "trunk" + // SourceBootstrap is what a definition is held from before the seat has named it. + SourceBootstrap = "bootstrap" ) -// Bus is what the reader needs of its host: a call of a tool by its key (`.`) and a subscription to an -// event pattern. StdioBus is the one a bundle has; the node-engine and the tool runner give theirs. +// Key is the address a reader asks (`seat:.`, as stdio.Ask takes it); a host's manifest `invokes` lists the +// verb. Pattern is what it subscribes to for an event (`.`), which the host's manifest `consumes`. +func Key() string { return "seat:" + Seat + "." + Verb } + +// Pattern is the subscription of one of the seat's events. +func Pattern(event string) string { return Seat + "." + event } + +// Bus is what the reader needs of its host: a call by its address and a subscription to an event pattern. StdioBus is +// the one a bundle has; the node-engine and the tool runner give theirs. type Bus interface { Ask(key string, body any) (json.RawMessage, error) Subscribe(pattern string, handle func(key string, body json.RawMessage) error) error } -// Entry is one definition as the owner answers it: the seat, its source, the digest of the file, and the definition -// resolved with the parts it includes. It is the owner's SeatDefinitionEntry, read here by the fields it needs. +// Entry is one definition as the seat answers it: the seat, its source, the digest of the file, and, in an answer for one +// seat, the definition resolved with the parts it includes. type Entry struct { Seat string `json:"seat"` - Source string `json:"source"` + Source string `json:"source,omitempty"` Digest string `json:"digest,omitempty"` Definition json.RawMessage `json:"definition,omitempty"` Unparsed string `json:"unparsed,omitempty"` } -// Listing is the owner's answer to the tool with no seat: every trunk definition and the revision of its state. +// Listing is the seat's answer with no seat: every trunk seat with its digest, and the revision of its state. type Listing struct { - Revision uint64 `json:"revision"` - Definitions []Entry `json:"definitions"` + Revision uint64 `json:"revision"` + Seats []Entry `json:"seats"` } -// Answer is the owner's answer to the tool with one seat. +// Answer is the seat's answer with one seat. type Answer struct { Seat string `json:"seat"` Found bool `json:"found"` @@ -66,20 +83,18 @@ type Answer struct { Revision uint64 `json:"revision"` } -// Changed is the event's body. +// Changed is an event's body. type Changed struct { Seat string `json:"seat"` - Source string `json:"source"` - Digest string `json:"digest"` + Source string `json:"source,omitempty"` + Digest string `json:"digest,omitempty"` } // Config is what a host gives Start. type Config struct { - // Owner is the module that keeps the definitions, as the host's manifest `invokes` names it. - Owner string - // Bus is the host's way to the owner; StdioBus() for a bundle. + // Bus is the host's way to the seat; StdioBus() for a bundle. Bus Bus - // Bootstrap are the definitions known before the owner is heard (ADR 0635 §3); Bootstrapped() is the foundation's. + // Bootstrap are the definitions known before the seat is heard (ADR 0636 §2); Bootstrapped() is the mesh's. Bootstrap []seatdef.Definition // OnChange, when set, is called after the held set changed, with the seats in reading order. It runs on the // goroutine that heard the change: it returns quickly. @@ -90,46 +105,69 @@ type Config struct { type Reader struct { cfg Config - mu sync.Mutex - defs map[string]seatdef.Definition // by seat - boot map[string]bool // seats held from the bootstrap, not yet from the owner - // what the last ask of the owner came to (Status) + mu sync.Mutex + defs map[string]seatdef.Definition // by seat + digests map[string]string // by seat: the digest of the trunk file held + boot map[string]bool // seats held from the bootstrap, not yet from the seat + refused map[string]string // seats whose trunk definition cannot be parsed, with why: the one that stands stays + // what the last ask of the seat came to (Status) spoken bool revision uint64 lastErr string } -// Status is what the reader holds and what it waits for (ADR 0635 §3), for the host's health and its tool: Heard says -// the owner has answered, Waiting are the seats still held from the bootstrap alone (the owner has not named them), -// LastError is why the last ask failed, and Revision is the owner's state revision of the last listing. +// Held is one definition a reader holds: its seat, where from, and the digest of the trunk file it came from. +type Held struct { + Seat string `json:"seat"` + Source string `json:"source"` + Digest string `json:"digest,omitempty"` +} + +// Status is what the reader holds and what it waits for (ADR 0636 §1, §7), for the host's health and its tool: Heard says +// the seat has answered, Waiting are the seats still held from the bootstrap alone (the seat has not named them), Refused +// are the seats whose trunk definition did not parse (the one held stands), LastError is why the last ask failed, and +// Revision is the seat's state revision of the last listing. type Status struct { - Heard bool `json:"heard"` - Held int `json:"held"` - Waiting []string `json:"waiting,omitempty"` - LastError string `json:"last-error,omitempty"` - Revision uint64 `json:"revision,omitempty"` + Heard bool `json:"heard"` + Held []Held `json:"held"` + Waiting []string `json:"waiting,omitempty"` + Refused map[string]string `json:"refused,omitempty"` + LastError string `json:"last-error,omitempty"` + Revision uint64 `json:"revision,omitempty"` } // Status is the reader's state of mind. func (r *Reader) Status() Status { r.mu.Lock() defer r.mu.Unlock() - st := Status{Heard: r.spoken, Held: len(r.defs), LastError: r.lastErr, Revision: r.revision} - for seat := range r.boot { - st.Waiting = append(st.Waiting, seat) + st := Status{Heard: r.spoken, LastError: r.lastErr, Revision: r.revision, Held: []Held{}} + for seat := range r.defs { + h := Held{Seat: seat, Source: SourceTrunk, Digest: r.digests[seat]} + if r.boot[seat] { + h.Source = SourceBootstrap + st.Waiting = append(st.Waiting, seat) + } + st.Held = append(st.Held, h) } + sort.Slice(st.Held, func(i, j int) bool { return st.Held[i].Seat < st.Held[j].Seat }) sort.Strings(st.Waiting) + if len(r.refused) > 0 { + st.Refused = map[string]string{} + for k, v := range r.refused { + st.Refused[k] = v + } + } return st } -// Health is Status as the one line a module's health says: empty when the owner has been heard and nothing waits. +// Health is Status as the one line a module's health says: empty when the seat has been heard and nothing waits. func (st Status) Health() string { switch { case !st.Heard: - return fmt.Sprintf("the seat list is held from the foundation's bootstrap (%d seats): the definitions' owner has not answered%s", - st.Held, withError(st.LastError)) + return fmt.Sprintf("the seat list is held from the bootstrap (%d seats) and nothing new is acted on: the seat %s has not answered%s", + len(st.Held), Seat, withError(st.LastError)) case len(st.Waiting) > 0: - return fmt.Sprintf("%d seats are held from the bootstrap alone, the owner names none of them: %s", len(st.Waiting), strings.Join(st.Waiting, ", ")) + return fmt.Sprintf("%d seats are held from the bootstrap alone, the seat %s names none of them: %s", len(st.Waiting), Seat, strings.Join(st.Waiting, ", ")) } return "" } @@ -141,24 +179,23 @@ func withError(e string) string { return " (" + e + ")" } -// Start returns a reader holding the bootstrap, asks the owner for every definition and follows its event. A first -// ask the owner did not answer is no failure: the reader holds the bootstrap and reads again on the owner's next -// event or on Reload. Start returns the subscription's error alone, which the host may ask again by calling Start again -// on a new reader. +// Start returns a reader holding the bootstrap, asks the seat for every definition and follows its two events. A first +// ask the seat did not answer is no failure: the reader holds the bootstrap, acts on nothing new (MayAct) and reads again +// on the seat's next event or on Reload. Start returns the subscription's error alone, which the host may ask again by +// calling Start again on a new reader. func Start(cfg Config) (*Reader, error) { if cfg.Bus == nil { return nil, errors.New("seatlist: no bus to read the seat list through") } - if cfg.Owner == "" || strings.ContainsAny(cfg.Owner, ". \t") { - return nil, fmt.Errorf("seatlist: the owner %q is not a module name", cfg.Owner) - } - r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, boot: map[string]bool{}} + r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, digests: map[string]string{}, boot: map[string]bool{}, refused: map[string]string{}} for _, d := range cfg.Bootstrap { r.defs[d.Seat] = d r.boot[d.Seat] = true } - if err := cfg.Bus.Subscribe("*."+ChangedEvent, r.heard); err != nil { - return r, fmt.Errorf("seatlist: following %s: %w", ChangedEvent, err) + for _, ev := range []string{ChangedEvent, RemovedEvent} { + if err := cfg.Bus.Subscribe(Pattern(ev), r.heard); err != nil { + return r, fmt.Errorf("seatlist: following %s: %w", ev, err) + } } _ = r.Reload() return r, nil @@ -182,6 +219,35 @@ func (r *Reader) Definitions() map[string]seatdef.Definition { return out } +// Has says whether the reader holds a definition of the seat: a claim, invoke or grant of a seat it does not hold is +// refused, and nothing already running is stopped for it (ADR 0636 §3). +func (r *Reader) Has(seat string) bool { + r.mu.Lock() + defer r.mu.Unlock() + _, ok := r.defs[seat] + return ok +} + +// MayAct says whether a host may start, compose, grant or judge a module that claims, invokes or provides the given seats +// (ADR 0636 §1): once the seat list has been read, any; before, only a module whose seats are all bootstrapped +// ones (ADR 0636 §2), and at least one. It changes nothing a module already runs. +func (r *Reader) MayAct(seats ...string) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.spoken { + return true + } + if len(seats) == 0 { + return false + } + for _, s := range seats { + if !r.boot[s] { + return false + } + } + return true +} + func (r *Reader) seatsLocked() []catalogue.Seat { ds := make([]seatdef.Definition, 0, len(r.defs)) for _, d := range r.defs { @@ -190,80 +256,112 @@ func (r *Reader) seatsLocked() []catalogue.Seat { return catalogue.SeatsFromDefinitions(ds) } -// Reload asks the owner for every trunk definition and holds what it answers, a seat the owner holds replacing the -// bootstrap's. An answer that cannot be read is returned and changes nothing. +// Reload asks the seat for its trunk seats and digests and then for each definition it holds a different digest of, +// and drops the trunk seats it no longer lists; a bootstrapped seat the seat does not list stays and waits. An answer that +// cannot be read is returned and changes nothing; one definition that does not parse leaves the one held standing and is +// said in Status. func (r *Reader) Reload() error { - raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{}) + raw, err := r.cfg.Bus.Ask(Key(), map[string]any{}) if err != nil { - return r.failed(fmt.Errorf("seatlist: asking %s.%s: %w", r.cfg.Owner, Tool, err)) + return r.failed(fmt.Errorf("seatlist: asking %s: %w", Key(), err)) } var l Listing if err := json.Unmarshal(raw, &l); err != nil { - return r.failed(fmt.Errorf("seatlist: %s.%s answered what is not a listing: %w", r.cfg.Owner, Tool, err)) + return r.failed(fmt.Errorf("seatlist: %s answered what is not a listing: %w", Key(), err)) } - parsed := map[string]seatdef.Definition{} - for _, e := range l.Definitions { - if e.Source != SourceTrunk || e.Unparsed != "" || len(e.Definition) == 0 { + listed := map[string]bool{} + var first error + for _, e := range l.Seats { + if e.Seat == "" || (e.Source != "" && e.Source != SourceTrunk) { continue } - d, err := seatdef.Parse(e.Definition) - if err != nil { - return r.failed(fmt.Errorf("seatlist: the definition of %s does not parse: %w", e.Seat, err)) + listed[e.Seat] = true + r.mu.Lock() + same := !r.boot[e.Seat] && e.Digest != "" && r.digests[e.Seat] == e.Digest + r.mu.Unlock() + if same { + continue + } + if err := r.fetch(e.Seat); err != nil && first == nil { + first = err } - parsed[d.Seat] = d } r.mu.Lock() - r.spoken, r.revision, r.lastErr = true, l.Revision, "" - for seat := range r.defs { - if !r.boot[seat] { - delete(r.defs, seat) - } + r.spoken, r.revision = true, l.Revision + if first == nil { + r.lastErr = "" } - for seat, d := range parsed { - r.defs[seat] = d - delete(r.boot, seat) + for seat := range r.defs { + if !r.boot[seat] && !listed[seat] { + r.dropLocked(seat) + } } r.mu.Unlock() r.changed() + if first != nil { + return r.failed(first) + } return nil } -// heard takes the owner's event: a trunk definition moved, so that seat is asked again. An error is returned so the -// bus offers the event again. -func (r *Reader) heard(_ string, body json.RawMessage) error { +// fetch asks the seat for one definition and holds it, or drops it when the seat holds none. +func (r *Reader) fetch(seat string) error { + raw, err := r.cfg.Bus.Ask(Key(), map[string]any{"seat": seat}) + if err != nil { + return fmt.Errorf("seatlist: asking %s for %s: %w", Key(), seat, err) + } + var a Answer + if err := json.Unmarshal(raw, &a); err != nil { + return fmt.Errorf("seatlist: %s answered for %s what is not an answer: %w", Key(), seat, err) + } + r.mu.Lock() + defer r.mu.Unlock() + switch { + case !a.Found || a.Entry == nil || (a.Entry.Source != "" && a.Entry.Source != SourceTrunk): + r.dropLocked(seat) + case a.Entry.Unparsed != "" || len(a.Entry.Definition) == 0: + r.refused[seat] = a.Entry.Unparsed // the definition that stands stays: a trunk's that does not parse is never written over it + default: + d, err := seatdef.Parse(a.Entry.Definition) + if err != nil { + r.refused[seat] = err.Error() + return nil + } + r.defs[seat], r.digests[seat] = d, a.Entry.Digest + delete(r.boot, seat) + delete(r.refused, seat) + } + return nil +} + +// dropLocked forgets a seat: its definition is gone, so a new claim of it is refused (ADR 0636 §3). +func (r *Reader) dropLocked(seat string) { + delete(r.defs, seat) + delete(r.digests, seat) + delete(r.boot, seat) + delete(r.refused, seat) +} + +// heard takes one of the seat's events: a trunk definition moved, so that seat is asked again, or went, so it is dropped. +// An error is returned so the bus offers the event again. +func (r *Reader) heard(key string, body json.RawMessage) error { var c Changed if err := json.Unmarshal(body, &c); err != nil || c.Seat == "" { return nil } - if c.Source != SourceTrunk { + if c.Source != "" && c.Source != SourceTrunk { return nil } - raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{"seat": c.Seat}) - if err != nil { + if strings.HasSuffix(key, "."+RemovedEvent) { + r.mu.Lock() + r.dropLocked(c.Seat) + r.mu.Unlock() + r.changed() + return nil + } + if err := r.fetch(c.Seat); err != nil { return r.failed(err) } - var a Answer - if err := json.Unmarshal(raw, &a); err != nil { - return err - } - r.mu.Lock() - switch { - case !a.Found || a.Entry == nil || a.Entry.Source != SourceTrunk: - if !r.boot[c.Seat] { - delete(r.defs, c.Seat) - } - case a.Entry.Unparsed != "" || len(a.Entry.Definition) == 0: - // the definition that stands stays: a trunk's that does not parse is never written over it - default: - d, err := seatdef.Parse(a.Entry.Definition) - if err != nil { - r.mu.Unlock() - return fmt.Errorf("seatlist: the definition of %s does not parse: %w", c.Seat, err) - } - r.defs[c.Seat] = d - delete(r.boot, c.Seat) - } - r.mu.Unlock() r.changed() return nil } @@ -280,3 +378,45 @@ func (r *Reader) changed() { r.cfg.OnChange(r.Seats()) } } + +// Tools are the reader's two runtime tools (ADR 0636 §7), for a host's bundle: read, which says what the reader holds +// (every definition, its source and digest) and what it waits for; and again, which makes it read the list now, and +// answers only to the callers the host's list names (the operator and the healers). The host names the tools and the list; +// again records nothing but the read it asked. +func Tools(r *Reader, read, again string, callers stdio.Callers) []stdio.Tool { + return []stdio.Tool{ + {Name: read, + Description: "The seat list this part holds (novox/hq ADR 0636): each seat's definition with its source (trunk or bootstrap) and " + + "the digest of the file it came from, the seats still held from the bootstrap alone, definitions refused because they " + + "do not parse, whether the seat " + Seat + " has answered and why not.", + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(map[string]any) (any, error) { + return map[string]any{"status": r.Status(), "health": r.Status().Health()}, nil + }}, + {Name: again, + Description: "Read the seat list again now, from the seat " + Seat + " (a hand act for a part that waits or holds a stale definition), " + + "and answer what it holds afterwards. Answered only to the operator and the healers.", + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(args map[string]any) (any, error) { + if err := Refusal(again, callers, stdio.CallerOf(args)); err != nil { + return nil, err + } + err := r.Reload() + st := r.Status() + out := map[string]any{"status": st, "health": st.Health()} + if err != nil { + out["error"] = err.Error() + } + return out, nil + }}, + } +} + +// Refusal is why caller may not make the reader read again, or nil: the caller's proof, never an argument, against the +// list of the operator and the healers. +func Refusal(tool string, callers stdio.Callers, caller string) error { + if callers.Allows(caller) { + return nil + } + return fmt.Errorf("%s is refused to %q: it answers only %s (novox/hq ADR 0636 §7)", tool, caller, strings.Join(callers, ", ")) +} diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index e22d060..f845b66 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -3,50 +3,57 @@ package seatlist import ( "encoding/json" "errors" + "fmt" "strings" "testing" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" ) -// fakeOwner is the definitions' owner: the tool answers from its table and the event is delivered by the test. +// fakeOwner is the seat mesh-catalog: its verb answers from its table and the events are delivered by the test. type fakeOwner struct { - table map[string]string // seat -> definition JSON, trunk - down bool - asked []string - handler func(string, json.RawMessage) error - pattern string + table map[string]string // seat -> definition JSON, trunk + down bool + asked []string // "key seat" + handlers map[string]func(string, json.RawMessage) error } func (f *fakeOwner) Ask(key string, body any) (json.RawMessage, error) { - f.asked = append(f.asked, key) + args, _ := body.(map[string]any) + seat, _ := args["seat"].(string) + f.asked = append(f.asked, key+" "+seat) if f.down { return nil, errors.New("no answer") } - args, _ := body.(map[string]any) - if seat, _ := args["seat"].(string); seat != "" { + if seat != "" { raw, ok := f.table[seat] if !ok { return json.Marshal(Answer{Seat: seat}) } - return json.Marshal(Answer{Seat: seat, Found: true, Entry: &Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)}}) + return json.Marshal(Answer{Seat: seat, Found: true, Entry: &Entry{Seat: seat, Source: SourceTrunk, Digest: digest(raw), Definition: json.RawMessage(raw)}}) } l := Listing{Revision: 7} for seat, raw := range f.table { - l.Definitions = append(l.Definitions, Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)}) + l.Seats = append(l.Seats, Entry{Seat: seat, Source: SourceTrunk, Digest: digest(raw)}) } return json.Marshal(l) } +func digest(raw string) string { return fmt.Sprintf("%d", len(raw)) } + func (f *fakeOwner) Subscribe(pattern string, h func(string, json.RawMessage) error) error { - f.pattern, f.handler = pattern, h + if f.handlers == nil { + f.handlers = map[string]func(string, json.RawMessage) error{} + } + f.handlers[pattern] = h return nil } -func (f *fakeOwner) say(seat, source string) error { +func (f *fakeOwner) say(event, seat, source string) error { b, _ := json.Marshal(Changed{Seat: seat, Source: source, Digest: "d"}) - return f.handler("mesh-owner."+ChangedEvent, b) + return f.handlers[Pattern(event)](Pattern(event), b) } func def(seat, scope, verb string) string { @@ -62,41 +69,51 @@ func names(r *Reader) string { return strings.Join(out, ",") } -// ADR 0635 §1, §3: the seat list comes from the owner on the bus, starting from the bootstrap, and follows its event. -func TestTheSeatListIsReadFromTheOwnerAndFollowsItsEvent(t *testing.T) { +// ADR 0636 §1, §3, §4: the seat list is read through the seat's verb, starting from the bootstrap, and follows its two events. +func TestTheSeatListIsReadFromTheSeatAndFollowsItsEvents(t *testing.T) { boot, err := seatdef.Parse([]byte(def("boot-seat", "mesh", "go"))) if err != nil { t.Fatal(err) } owner := &fakeOwner{down: true, table: map[string]string{ "node-b": def("node-b", "node", "put"), "mesh-a": def("mesh-a", "mesh", "get"), "boot-seat": def("boot-seat", "mesh", "other")}} - var told [][]catalogue.Seat - r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: []seatdef.Definition{boot}, - OnChange: func(s []catalogue.Seat) { told = append(told, s) }}) + var told int + r, err := Start(Config{Bus: owner, Bootstrap: []seatdef.Definition{boot}, OnChange: func([]catalogue.Seat) { told++ }}) if err != nil { t.Fatal(err) } - if owner.pattern != "*."+ChangedEvent { - t.Fatalf("subscribed to %q", owner.pattern) + if owner.handlers[Pattern(ChangedEvent)] == nil || owner.handlers[Pattern(RemovedEvent)] == nil || Pattern(ChangedEvent) != "mesh-catalog.seat-definition-changed" { + t.Fatalf("subscribed to %v", owner.handlers) + } + if owner.asked[0] != "seat:mesh-catalog.seat-definitions " { + t.Fatalf("first asked %q: the seat's verb, never a module", owner.asked[0]) } steps := []struct { name string act func() error want string err string + asks int // asks the step made }{ - {"the owner has not answered: the bootstrap stands", func() error { return nil }, "boot-seat", ""}, - {"an event while the owner is down is offered again", func() error { return owner.say("mesh-a", SourceTrunk) }, "boot-seat", "no answer"}, - {"the owner answers: its seats in reading order, mesh seats first, the owner's definition replacing the bootstrap's", - func() error { owner.down = false; return r.Reload() }, "boot-seat,mesh-a,node-b", ""}, + {"the seat has not answered: the bootstrap stands", func() error { return nil }, "boot-seat", "", 0}, + {"an event while the seat is down is offered again", func() error { return owner.say(ChangedEvent, "mesh-a", SourceTrunk) }, "boot-seat", "no answer", 1}, + {"the seat answers: its seats in reading order, its definition replacing the bootstrap's, each fetched once", + func() error { owner.down = false; return r.Reload() }, "boot-seat,mesh-a,node-b", "", 4}, + {"a read again fetches nothing whose digest it holds", func() error { return r.Reload() }, "boot-seat,mesh-a,node-b", "", 1}, {"a moved definition is asked again", func() error { owner.table["mesh-a"] = def("mesh-a", "mesh", "renamed") - return owner.say("mesh-a", SourceTrunk) - }, "boot-seat,mesh-a,node-b", ""}, - {"a seat the owner holds no more goes", func() error { delete(owner.table, "node-b"); return owner.say("node-b", SourceTrunk) }, "boot-seat,mesh-a", ""}, - {"a pull request's definition is not held", func() error { owner.asked = nil; return owner.say("mesh-z", "pull") }, "boot-seat,mesh-a", ""}, + return owner.say(ChangedEvent, "mesh-a", SourceTrunk) + }, "boot-seat,mesh-a,node-b", "", 1}, + {"a definition that does not parse leaves the one that stands", func() error { + owner.table["mesh-a"] = `{"format":1,"seat":"mesh-a","verbs":"no"}` + return owner.say(ChangedEvent, "mesh-a", SourceTrunk) + }, "boot-seat,mesh-a,node-b", "", 1}, + {"a seat the seat removes goes", func() error { delete(owner.table, "node-b"); return owner.say(RemovedEvent, "node-b", SourceTrunk) }, "boot-seat,mesh-a", "", 0}, + {"a pull request's definition is not held and not asked", func() error { return owner.say(ChangedEvent, "mesh-z", "pull") }, "boot-seat,mesh-a", "", 0}, + {"a listing that drops a trunk seat drops it", func() error { delete(owner.table, "mesh-a"); return r.Reload() }, "boot-seat", "", 1}, } for _, s := range steps { + before := len(owner.asked) err := s.act() if (err == nil) != (s.err == "") || (err != nil && !strings.Contains(err.Error(), s.err)) { t.Fatalf("%s: error %v, want %q", s.name, err, s.err) @@ -104,47 +121,63 @@ func TestTheSeatListIsReadFromTheOwnerAndFollowsItsEvent(t *testing.T) { if got := names(r); got != s.want { t.Fatalf("%s: seats %q, want %q", s.name, got, s.want) } + if got := len(owner.asked) - before; got != s.asks { + t.Fatalf("%s: asked %d times (%v), want %d", s.name, got, owner.asked[before:], s.asks) + } } - if len(owner.asked) != 0 { - t.Fatalf("a pull request's event asked the owner: %v", owner.asked) + if st := r.Status(); len(st.Refused) != 0 || !r.Has("boot-seat") || r.Has("mesh-a") { + t.Fatalf("status %+v, has mesh-a %v", st, r.Has("mesh-a")) } - if v := r.Seats()[1].Serves[0]; v.Name != "renamed" || v.Input["type"] != "object" { - t.Fatalf("the moved definition's verb is %+v, want renamed ", v) - } - if len(told) != 3 { - t.Fatalf("OnChange was told %d times", len(told)) + if told < 5 { + t.Fatalf("OnChange was told %d times", told) } } func TestStartRefusesWhatCannotRead(t *testing.T) { - for name, cfg := range map[string]Config{ - "no bus": {Owner: "m"}, - "an owner with a dot": {Owner: "m.tool", Bus: &fakeOwner{}}, - "no owner": {Bus: &fakeOwner{}}, - } { - if _, err := Start(cfg); err == nil { - t.Fatalf("%s: started", name) - } + if _, err := Start(Config{}); err == nil { + t.Fatal("started without a bus") } } -// ADR 0635 §3: before the owner is heard the reader holds the foundation's definitions and says it waits. -func TestTheBootstrapParsesAndIsHeldUntilTheOwnerAnswers(t *testing.T) { +// ADR 0636 §1, §2: before the seat is heard the reader holds the bootstrapped definitions, says it waits and lets a host act +// on the modules those definitions name only. +func TestTheBootstrapIsHeldAndNothingNewIsActedOnUntilTheSeatAnswers(t *testing.T) { boot := Bootstrapped() - if len(boot) == 0 { - t.Fatal("no bootstrapped definition") + held := map[string]bool{} + for _, d := range boot { + held[d.Seat] = true + } + for _, seat := range []string{"mesh-catalog", "mesh-conditions", "operator-channel", "node-engine", "mesh-broker", "mesh-config"} { + if !held[seat] { + t.Fatalf("the bootstrap lacks %s (ADR 0636 §2)", seat) + } } owner := &fakeOwner{down: true} - r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: boot}) + r, err := Start(Config{Bus: owner, Bootstrap: boot}) if err != nil { t.Fatal(err) } - if got := len(r.Seats()); got != len(boot) { - t.Fatalf("the reader holds %d seats, the bootstrap has %d", got, len(boot)) + if got := len(r.Definitions()); got != len(boot) { + t.Fatalf("the reader holds %d definitions, the bootstrap has %d", got, len(boot)) } st := r.Status() - if st.Heard || len(st.Waiting) != len(boot) || st.LastError == "" { - t.Fatalf("status %+v: want the owner not heard, every bootstrapped seat waiting and the error said", st) + if st.Heard || len(st.Waiting) != len(boot) || st.LastError == "" || st.Health() == "" { + t.Fatalf("status %+v: want the seat not heard, every bootstrapped seat waiting, the error and the health said", st) + } + acts := []struct { + seats []string + want bool + }{ + {[]string{"node-engine"}, true}, + {[]string{"mesh-catalog", "mesh-config"}, true}, + {[]string{"node-engine", "node-files"}, false}, + {[]string{"node-files"}, false}, + {nil, false}, + } + for _, a := range acts { + if got := r.MayAct(a.seats...); got != a.want { + t.Fatalf("waiting: MayAct(%v) = %v, want %v", a.seats, got, a.want) + } } owner.down = false owner.table = map[string]string{"node-b": def("node-b", "node", "put")} @@ -152,6 +185,36 @@ func TestTheBootstrapParsesAndIsHeldUntilTheOwnerAnswers(t *testing.T) { t.Fatal(err) } if st := r.Status(); !st.Heard || len(st.Waiting) != len(boot) || st.LastError != "" || st.Revision != 7 { - t.Fatalf("status after the owner answered %+v: want heard, the bootstrap's seats the owner does not hold still waiting, no error, revision 7", st) + t.Fatalf("status after the seat answered %+v: want heard, the bootstrap's seats it does not hold still waiting, no error, revision 7", st) + } + if !r.MayAct("node-files") || !r.MayAct() { + t.Fatal("after the seat answered, a host acts on any module") + } +} + +// ADR 0636 §7: the two tools read what is held and read again now, the second to its callers only. +func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { + owner := &fakeOwner{down: true, table: map[string]string{"node-b": def("node-b", "node", "put")}} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + callers := stdio.Callers{"person.jo", "mesh-healers"} + tools := Tools(r, "seat_list", "seat_list_again", callers) + out, err := tools[0].Run(map[string]any{}) + if err != nil || out.(map[string]any)["health"] == "" || !strings.Contains(fmt.Sprint(out.(map[string]any)["status"]), "bootstrap") { + t.Fatalf("read: %v %v", out, err) + } + for caller, allowed := range map[string]bool{"person.jo": true, "ace.mesh-healers": true, "ace.mesh-other": false, "person.al": false, stdio.Unproven: false} { + if err := Refusal("seat_list_again", callers, caller); (err == nil) != allowed { + t.Fatalf("%s: %v, allowed %v", caller, err, allowed) + } + } + if _, err := tools[1].Run(map[string]any{}); err == nil || !strings.Contains(err.Error(), "refused") || len(owner.asked) != 1 { + t.Fatalf("a call without a proven caller read the list again: %v, asks %v", err, owner.asked) + } + owner.down = false + if err := r.Reload(); err != nil || !r.Status().Heard || len(r.Status().Held) != len(Bootstrapped())+1 { + t.Fatalf("again: %v %+v", err, r.Status()) } } diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index bf10265..4919957 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -48,3 +48,4 @@ internal/beside/beside.go: mesh-catalog overlay/overlay.go: the-private-network provisions/provisions.go: mesh-provisioner provisions/provisions.go: mesh-vault +seatlist/seatlist.go: mesh-catalog