Merge branch 'feat/47-G-recover-small' into feat/47-G-join8

This commit is contained in:
2026-11-12 00:14:17 +01:00
17 changed files with 181 additions and 355 deletions
+3 -6
View File
@@ -83,7 +83,7 @@ must see, a node that joins later included — kept, so it carries no secret eit
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question |
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
@@ -233,11 +233,8 @@ key says waiting, chosen (the option, who answered, on which channel, when), end
refused, cancelled, replaced), or not proven: the answer is read from the router's own record and held to the
question as `questions` keeps it, so a question rewritten after it was asked, a warrant for another ask or a choice
after expiry is never read as a choice. One call waits at most 60 s; the tool asks a session to read a question at
most once a minute, and nothing enforces it. At most two questions wait at once, mesh-wide: each holds one of two
slot keys in `questions`, taken by create or compare-and-set, so two nodes asking at once cannot both take the last,
and a slot that cannot be read refuses the question. Questions alone never fill the router's three open asks, so one
is left for a proposed instruction section while two are open. A question the router holds no record of, its refusal
not heard here, keeps its slot and reads as waiting until it expires: the router may still take it from its queue,
most once a minute, and nothing enforces it. No question is refused for how many wait: the router offers an asker at most three asks at once, holds the oldest back behind newer ones and offers it again when one ends (hq ADR 0666 §12). A question the router holds no record of, its refusal
not heard here, reads as waiting until it expires: the router may still take it from its queue,
and no cancel can overtake it there (the queue offers work again after an error, and a cancel of an ask not yet
recorded is dropped); once expired, the router refuses it if taken late. A refused question keeps none of
its words. An answer is the operator's word to the session and authorises nothing; a question never defaults.
@@ -906,10 +906,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
if why := approverReady(bus); why != "" {
return refuse(why + "; " + AtTheTerminalInstead)
}
if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen {
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
"questions at once; claude_code_proposals lists them", waiting, occ.MostOpen))
}
if problem := ProposalProblem(prop); problem != "" {
return refuse(problem)
}
@@ -956,26 +952,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
}, nil
}
// waitingProposals counts the proposals still open: asked, not decided here, not expired.
func waitingProposals(store ProposalStore, approved Approved, now time.Time) int {
keys, err := store.Keys()
if err != nil {
return 0
}
n := 0
for _, k := range keys {
prop, ok := readProposal(store, k)
if !ok || prop.Asked != "" {
continue
}
if _, decided := approved.Decided[prop.ID]; decided || now.After(prop.At.Add(ProposalLasts)) {
continue
}
n++
}
return n
}
func readProposal(store ProposalStore, key string) (Proposal, bool) {
raw, found, err := store.Get(key)
if err != nil || !found {
@@ -21,7 +21,6 @@ import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@@ -38,26 +37,8 @@ const QuestionsState = "questions"
// QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's.
const QuestionPrefix = "q-"
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
const MostQuestions = occ.MostOpen - 1
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
// when it is taken (oc.Ask.Check), so the slot is free and the question has ended.
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
// slot is what a slot key holds: the question holding it.
type slot struct {
Question string `json:"question"`
At time.Time `json:"at"`
}
// A question is never refused for how many wait: the router offers an asker at most three asks at once and holds the
// oldest back behind newer ones, offered again when one ends (novox/hq ADR 0666 §12), so no question is hidden here.
// The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the
// router's bound for an approval, with the margin a proposal keeps), an hour when none is given.
@@ -372,19 +353,9 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
if err := occ.Check(ask, now); err != nil {
return refuse(err.Error())
}
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
if err := store.Create(q.ID, q); err != nil {
return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err)
}
if open, err := takeSlot(store, bus, q, now); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return refuse(err.Error())
} else if open != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: two questions were open"))
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
}
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return nil, fmt.Errorf("the operator could not be asked: %v", err)
@@ -420,75 +391,6 @@ func labelsOf(q Question) string {
return strings.Join(out, ", ")
}
// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's
// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess.
func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) {
q, ok, err := readQuestionErr(store, id)
if err != nil {
return true, err
}
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
return false, nil
}
entry, err := bus.Record(q.ID)
if err != nil {
return true, err
}
if entry == nil {
return true, nil
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil {
return true, nil
}
return rec.State == "" || rec.State == "open", nil
}
// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer
// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries
// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a
// slot or its question cannot be read.
func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) {
mine := slot{Question: q.ID, At: q.At}
var open []string
for n := 1; n <= MostQuestions; n++ {
key := slotKey(n)
raw, rev, found, err := store.Get(key)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if !found {
if err := store.Create(key, mine); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if raw, rev, found, err = store.Get(key); err != nil || !found {
return nil, fmt.Errorf("the open questions could not be counted, so none more is asked")
}
}
var held slot
if json.Unmarshal(raw, &held) == nil && held.Question != "" {
waits, err := questionOpen(store, bus, held.Question, now)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if waits {
open = append(open, held.Question)
continue
}
}
if err := store.Update(key, mine, rev); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
open = append(open, "one taken just now")
}
sort.Strings(open)
return open, nil
}
// tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and
// why it ended — none of its words, which the router judged unfit to leave the mesh or never saw.
func tombstone(q Question, ended string) Question {
@@ -608,7 +510,7 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
case entry == nil:
out["state"] = AnswerWaiting
out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " +
"refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " +
"refusal being heard here. The operator may still be asked until it expires at " +
q.Expires.Format(time.RFC3339)
case entry == nil, rec.State == "open", rec.Warrant == nil:
out["state"] = AnswerWaiting
@@ -6,7 +6,6 @@ package main
import (
"encoding/json"
"errors"
"strings"
"sync"
"testing"
@@ -182,22 +181,21 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
}
}
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
// ADR 0666 §12: a question is never refused for how many wait; the router holds the oldest back, offered again when one ends.
func TestAThirdQuestionIsAskedNotRefusedAndAProposalIsAskedBesideThem(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
t.Fatalf("a third question: %v", err)
for i := 0; i < 4; i++ {
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err != nil {
t.Fatalf("question %d: %v", i+1, err)
}
}
r.close(r.chose(first, "o1", t0.Add(time.Minute)))
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)); err != nil {
t.Fatalf("a question once one was answered: %v", err)
if len(r.subjects) != 4 {
t.Errorf("the router was asked %d times, want 4", len(r.subjects))
}
// A proposal is still asked beside two waiting questions.
pstore := &memProposals{m: map[string]json.RawMessage{}}
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, pstore, r, t0); err != nil {
t.Errorf("a proposal beside two questions: %v", err)
t.Errorf("a proposal beside the questions: %v", err)
}
}
@@ -234,9 +232,6 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
t.Errorf("read after the refusal: %v %v", out, err)
}
if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil {
t.Errorf("a refused question still counts as waiting: %v %v", waits, err)
}
var kept Question
_ = json.Unmarshal(store.m[refusing.id], &kept)
if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" {
@@ -316,10 +311,6 @@ type memQuestions struct {
m map[string]json.RawMessage
rev map[string]uint64
n uint64
// failGet fails every read of a key with this prefix.
failGet string
// afterGet runs after a read, outside the lock: another node writing between a read and a write.
afterGet func(key string)
}
func newMemQuestions() *memQuestions {
@@ -328,17 +319,9 @@ func newMemQuestions() *memQuestions {
func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
if s.failGet != "" && strings.HasPrefix(key, s.failGet) {
s.mu.Unlock()
return nil, 0, false, errors.New("the bus did not answer")
}
defer s.mu.Unlock()
raw, ok := s.m[key]
rev, after := s.rev[key], s.afterGet
s.mu.Unlock()
if after != nil {
after(key)
}
return raw, rev, ok, nil
return raw, s.rev[key], ok, nil
}
func (s *memQuestions) put(key string, value any) error {
raw, err := json.Marshal(value)
@@ -397,121 +380,3 @@ func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
}
}
}
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
nodes := []string{"g14", "shanks", "ace"}
var wg sync.WaitGroup
errs := make([]error, len(nodes))
for i, name := range nodes {
p, _ := node(t, name)
wg.Add(1)
go func(i int, p Paths) {
defer wg.Done()
in := mergeTonight()
in.By = "fix-589"
_, errs[i] = AskQuestion(p, in, store, r, t0)
}(i, p)
}
wg.Wait()
refused := 0
for _, err := range errs {
if err != nil {
if !strings.Contains(err.Error(), "questions already wait") {
t.Errorf("refused for another reason: %v", err)
}
refused++
}
}
if refused != 1 {
t.Errorf("%d of three questions asked at once were refused; one is", refused)
}
}
// A slot that cannot be read refuses the question: the cap fails closed.
func TestTheCapFailsClosed(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
store.failGet = "slot-"
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
t.Fatalf("asked with the slots unreadable: %v", err)
}
if len(r.subjects) != 0 {
t.Error("the operator was asked anyway")
}
}
// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as
// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends.
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
delete(r.records, first) // the router down or backlogged, or its refusal never heard here
later := t0.Add(QuestionDefault - time.Minute)
if _, err := AskQuestion(p, mergeTonight(), store, r, later); err == nil {
t.Fatal("the slot of a question the router may still show was freed before it expired")
}
out, _ := ReadAnswer(store, r, first, later)
if out["state"] != AnswerWaiting || !strings.Contains(out["words"].(string), "not yet recorded") {
t.Errorf("before expiry read as %v", out)
}
expired := t0.Add(QuestionDefault)
if out, _ := ReadAnswer(store, r, first, expired); out["state"] != AnswerEnded ||
out["now"] != "do what you said: I leave the pull request open" {
t.Errorf("at expiry read as %v", out)
}
if _, err := AskQuestion(p, mergeTonight(), store, r, expired); err != nil {
t.Errorf("the slot of an expired question was not freed: %v", err)
}
}
// A slot is taken only at the revision read: when another node takes both slots between this node's read and its
// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost.
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
// Two questions that ended: their slots are free to take again.
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))})
}
shanks, _ := node(t, "shanks")
other := newRouter()
store.afterGet = func(key string) {
if !strings.HasPrefix(key, "slot-") {
return
}
// Another node asks between this node's read of the slot and its write: its question waits.
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
_ = store.Put(q.ID, q)
_ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID)
r.records[q.ID] = other.records[q.ID]
_ = store.Put(key, slot{Question: q.ID, At: t0})
}
_, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute))
store.afterGet = nil
if err == nil || !strings.Contains(err.Error(), "questions already wait") {
t.Fatalf("a slot lost to another node was taken: %v", err)
}
}
// lockedRouter is the router fake, safe for sessions asking at once.
type lockedRouter struct {
mu sync.Mutex
*router
}
func (l *lockedRouter) Publish(subject string, body any, id string) error {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Publish(subject, body, id)
}
func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Record(id)
}
+3 -2
View File
@@ -18,11 +18,12 @@ one changed byte of the lock or another recipe moves it, and typescript and esbu
## The toolchain directory
`toolchain/package.json` and its committed `package-lock.json` name typescript 5.9.3, esbuild 0.25.12, @types/node 22.20.1 (the types tsc checks node imports against),
@novox/mesh-sdk 0.1.14 and nats 2.29.3. The mesh unpacks both, as the archive artifact `toolchain`, into `<home>/toolchain/` (resource `toolchain-dir`; a
and nats 2.29.3; it names no @novox/mesh-sdk: each TypeScript module is built against the latest release of the SDK the registry
holds, placed in its own `node_modules` by the fetch whatever its lock names, or the group's tagged version (ADR 0573 §2, ADR 0607 §3). The mesh unpacks both, as the archive artifact `toolchain`, into `<home>/toolchain/` (resource `toolchain-dir`; a
manifest names no host, and the lock does, so it is not a template); the program reads the lock from there. When `<cache dir>/toolchain/<recipe version>` is missing
the module runs `npm ci --ignore-scripts` there once (a directory without its `.installed` mark counts as missing).
**How the registry is reached** (the choice of issue 1488): the lock holds @novox/mesh-sdk at the forge's public
**How the registry is reached** (the choice of issue 1488): a lock names an @novox package at the forge's public
address, and npm 12 installs a locked tarball only from the registry it is configured with. The module therefore
requires `npm-package-registry` (provided by gitea, form none: reads are anonymous) and, like the lab module, takes its
binding as a file in its home. The install gets a private npm config file with the `@novox` scope at the binding's
@@ -5,7 +5,8 @@ package main
// TypeScript module in it the packages its package.json names beyond the SDK, installed from its committed lock.
//
// **Only what the module itself depends on is installed.** The SDK and nats come with the toolchain, so a bundle that
// names only @novox/mesh-sdk (and build-time types and typescript) resolves nothing. One that names more must carry a
// names only @novox/mesh-sdk (and build-time types and typescript) resolves nothing beyond the SDK, which is placed at
// its latest published version (ADR 0573 §2). One that names more must carry a
// package-lock.json, or the step fails naming its directory. The install is the build agent's (mesh-controller
// internal/builder/dependencies.go:49-95): in a scratch copy of package.json and the lock with the SDK, devDependencies
// and scripts taken out, `npm ci --omit=dev --omit=peer --ignore-scripts`, the install's copies of the SDK removed, and
@@ -64,6 +65,9 @@ type FetchResult struct {
// Group is the group's package the fetch packed and served to the TypeScript members (the call's `package`):
// "<name>@<version> <integrity>".
Group string `json:"group,omitempty"`
// SDK is the version of @novox/mesh-sdk placed in each TypeScript module, by directory: the latest release, the
// group's tagged version or the group's package (placeSDK; ADR 0573 §2).
SDK map[string]string `json:"sdk,omitempty"`
}
// narrowedMark is the file beside a workspace's tree saying the fetch narrowed it to a module's named paths.
@@ -197,10 +201,14 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group
return nil, err
}
if refused == "" {
// The SDK the module's own lock names, from the group or the registry (the lead's decision L10).
// The latest published SDK, or the group's (ADR 0573 §2, ADR 0607 §3).
if err := m.placeSDK(ctx, dir, d, served, group); err != nil {
return nil, err
}
if res.SDK == nil {
res.SDK = map[string]string{}
}
res.SDK[d] = placedSDK(dir)
}
if refused != "" {
res.Refused = append(res.Refused, Refusal{Module: d, Reason: refused})
@@ -368,7 +376,8 @@ func (m *Module) installOwn(ctx context.Context, root, dir, rel string) (install
return true, "", os.RemoveAll(scratch)
}
// removeSDKCopies removes every copy of the SDK the install pulled in: the toolchain's is the pin.
// removeSDKCopies removes every copy of the SDK the install pulled in: the one placeSDK places, the latest published
// or the group's, is the one the module is built against.
func removeSDKCopies(nm string) error {
var found []string
err := filepath.WalkDir(nm, func(p string, d fs.DirEntry, err error) error {
@@ -111,7 +111,7 @@ func TestAMeshPackageIsNotBeyondTheSdk(t *testing.T) {
}
// 1489/AC2: a module naming lodash with a lock gets npm ci --omit=dev --ignore-scripts (fake runner), its install put
// at its node_modules without the SDK and without any copy of it; without a lock the step fails naming the directory.
// at its node_modules without the install's SDK or any copy of it; without a lock the step fails naming the directory.
func TestAModuleNamingLodashWithALockIsInstalledWithoutTheSdkAndWithoutALockFails(t *testing.T) {
files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6","lodash":"^4"},"devDependencies":{"x":"1"},"scripts":{"test":"y"}}`)
files["modules/m/package-lock.json"] = `{"lockfileVersion":3}`
@@ -133,7 +133,8 @@ func TestAModuleNamingLodashWithALockIsInstalledWithoutTheSdkAndWithoutALockFail
if _, err := os.Stat(filepath.Join(nm, "lodash", "index.js")); err != nil {
t.Errorf("lodash is not at the module's node_modules: %v", err)
}
for _, gone := range []string{"@novox/mesh-sdk", "a/node_modules/@novox/mesh-sdk"} {
// The install's copies are gone; the one at the top is the latest release placed after it (placeSDK).
for _, gone := range []string{"@novox/mesh-sdk/index.js", "a/node_modules/@novox/mesh-sdk"} {
if _, err := os.Stat(filepath.Join(nm, gone)); err == nil {
t.Errorf("a copy of the SDK is left at %s", gone)
}
@@ -161,8 +161,8 @@ func TestTheModuleCarriesItsToolchainLockAndTheManifestPlacesIt(t *testing.T) {
}
}
// 1488/AC1: the lock names the versions of the recipe: typescript 5.9.3, esbuild 0.25.12, @novox/mesh-sdk 0.1.14
// and nats, all by the lock's own hash (read from the file the module carries).
// 1488/AC1: the lock names the versions of the recipe: typescript 5.9.3, esbuild 0.25.12 and nats, all by the lock's
// own hash (read from the file the module carries), and no @novox/mesh-sdk: the SDK is unpinned (ADR 0573 §2).
func TestTheLockNamesTheToolchainVersions(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "toolchain", "package-lock.json"))
if err != nil {
@@ -176,7 +176,7 @@ func TestTheLockNamesTheToolchainVersions(t *testing.T) {
if err := json.Unmarshal(raw, &lock); err != nil {
t.Fatal(err)
}
for pkg, want := range map[string]string{"typescript": "5.9.3", "esbuild": "0.25.12", "@novox/mesh-sdk": "0.1.14", "nats": "2.29.3", "@types/node": "22.20.1"} {
for pkg, want := range map[string]string{"typescript": "5.9.3", "esbuild": "0.25.12", "@novox/mesh-sdk": "", "nats": "2.29.3", "@types/node": "22.20.1"} {
if got := lock.Packages["node_modules/"+pkg].Version; got != want {
t.Errorf("%s = %q in the lock, want %s", pkg, got, want)
}
@@ -27,6 +27,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -292,7 +293,7 @@ func (m *Module) packGroup(ctx context.Context, root string, run forgekey.Runner
}
// sdkLocked is what a module's lock names of the SDK: its version, its integrity and where it was resolved from; ok is
// false when the lock names none (the toolchain's SDK is then the module's, as before).
// false when the lock names none (the latest release is then placed, placeSDK).
func sdkLocked(dir string) (version, integrity string, ok bool, err error) {
raw, err := os.ReadFile(filepath.Join(dir, "package-lock.json"))
if os.IsNotExist(err) {
@@ -317,21 +318,25 @@ func sdkLocked(dir string) (version, integrity string, ok bool, err error) {
return p.Version, p.Integrity, true, nil
}
// placeSDK places in the module's node_modules the SDK version its lock names (the lead's decision L10): the group's
// packed package when it is that version (its integrity must be the lock's), else the registry's; a version neither
// holds is a wait (`waits for:`) when no group package was given, and a failure naming both when one was. Given the
// group's tagged version (tagged, ADR 0607 §3), that version is placed from the registry whatever the lock names, its
// integrity the lock's when the lock names it, else the registry's own; one the registry lacks fails naming it.
// placeSDK places in the module's node_modules the SDK it is built against: the latest release the registry holds,
// whatever its lock names (novox/hq ADR 0573 §2: a module builds against the latest published version of each mesh
// package; the lead's decision L10's version from the lock is gone), or the group's packed package when the lock names
// its version. Every TypeScript module gets it, named in its package.json or not, since its launcher imports the SDK
// (writeLaunchers) and the toolchain holds none. The lock's integrity is checked when the lock names the version placed;
// a registry holding no release is a wait (`waits for:`) when no group package was given, and a failure naming the group
// when one was. Given the group's tagged version (tagged, ADR 0607 §3), that version is placed from the registry
// whatever the lock names, its integrity the lock's when the lock names it, else the registry's; one the registry lacks
// fails naming it.
func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed, tagged *groupPackage) error {
version, integrity, ok, err := sdkLocked(dir)
if err != nil || !ok {
version, integrity, _, err := sdkLocked(dir)
if err != nil {
return err
}
var tgz []byte
switch {
case tagged.tagged():
if version != tagged.Version {
if m.Say != nil {
if m.Say != nil && version != "" {
m.Say("%s: its lock names %s@%s; the group's tagged version %s is placed (ADR 0607 §3)", rel, sdkPackage, version, tagged.Version)
}
version, integrity = tagged.Version, ""
@@ -351,23 +356,39 @@ func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed, t
return fmt.Errorf("the registry's %s is %s, and %s names %s", want, integrityOf(got), rel, integrity)
}
tgz = got
case group != nil && group.Name == sdkPackage && group.Version == version:
case group != nil && group.Name == sdkPackage && version != "" && group.Version == version:
if integrity != "" && integrity != group.Integrity {
return fmt.Errorf("the lock of %s names %s with the integrity %s, and the group's package packs to %s", rel, sdkPackage+"@"+version, integrity, group.Integrity)
}
tgz = group.tgz
default:
latest, err := m.registryLatest(ctx, sdkPackage)
if err != nil {
return err
}
if latest == "" {
if group != nil {
return fmt.Errorf("the registry holds no release of %s and the group's package is %s@%s, which the lock of %s does not name", sdkPackage, group.Name, group.Version, rel)
}
return fmt.Errorf("%s%s", builds.WaitsFor, sdkPackage)
}
if version != latest {
if m.Say != nil && version != "" {
m.Say("%s: its lock names %s@%s; the latest published %s is placed (ADR 0573 §2)", rel, sdkPackage, version, latest)
}
version, integrity = latest, ""
}
want := sdkPackage + "@" + version
got, held, err := m.registryPackage(ctx, sdkPackage, version)
got, registered, held, err := m.registryPackageChecked(ctx, sdkPackage, version)
if err != nil {
return err
}
if !held {
if group != nil {
return fmt.Errorf("the lock of %s names %s, which neither the registry nor the group (%s@%s) holds", rel, want, group.Name, group.Version)
}
return fmt.Errorf("%s%s", builds.WaitsFor, want)
}
if integrity == "" {
integrity = registered
}
if integrity != "" && integrityOf(got) != integrity {
return fmt.Errorf("the registry's %s is %s, and the lock of %s names %s", want, integrityOf(got), rel, integrity)
}
@@ -380,6 +401,47 @@ func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed, t
return unpackPackage(tgz, into)
}
// placedSDK is the version of the SDK placed in the module's node_modules ("" when none is).
func placedSDK(dir string) string {
p, err := readPackageJSON(filepath.Join(dir, "node_modules", filepath.FromSlash(sdkPackage)))
if err != nil {
return ""
}
return p.Version
}
// registryLatest is the latest release of name the registry holds: the highest version with no pre-release, so a
// group's tagged version is never taken for it; "" when the registry holds none.
func (m *Module) registryLatest(ctx context.Context, name string) (string, error) {
p, held, err := m.packumentOf(ctx, name)
if err != nil || !held {
return "", err
}
latest := ""
for v := range p.Versions {
if !npmVersionOK.MatchString(v) || strings.Contains(v, "-") {
continue
}
if latest == "" || releaseLess(latest, v) {
latest = v
}
}
return latest, nil
}
// releaseLess orders two releases (major.minor.patch, no pre-release) by their numbers.
func releaseLess(a, b string) bool {
pa, pb := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < 3; i++ {
x, _ := strconv.Atoi(pa[i])
y, _ := strconv.Atoi(pb[i])
if x != y {
return x < y
}
}
return false
}
// registryPackageChecked is registryPackage with the integrity the registry's document names of name@version.
func (m *Module) registryPackageChecked(ctx context.Context, name, version string) (tgz []byte, integrity string, held bool, err error) {
_, integrity, held, err = m.registryVersion(ctx, name, version)
@@ -408,32 +470,41 @@ type packument struct {
} `json:"versions"`
}
// registryVersion reads what the registry holds of name@version: its packed file's address and integrity, and whether
// it holds it at all.
func (m *Module) registryVersion(ctx context.Context, name, version string) (tarball, integrity string, held bool, err error) {
// packumentOf reads the registry's document of name; held is false when the registry has none.
func (m *Module) packumentOf(ctx context.Context, name string) (packument, bool, error) {
reg, err := m.Toolchain.Registry()
if err != nil {
return "", "", false, err
return packument{}, false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reg.URL+url.PathEscape(name), nil)
if err != nil {
return "", "", false, err
return packument{}, false, err
}
resp, err := m.httpClient().Do(req)
if err != nil {
return "", "", false, fmt.Errorf("asking the registry for %s: %w", name, err)
return packument{}, false, fmt.Errorf("asking the registry for %s: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", "", false, nil
return packument{}, false, nil
}
if resp.StatusCode != http.StatusOK {
said, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", false, fmt.Errorf("the registry answered %s for %s: %s", resp.Status, name, strings.TrimSpace(string(said)))
return packument{}, false, fmt.Errorf("the registry answered %s for %s: %s", resp.Status, name, strings.TrimSpace(string(said)))
}
var p packument
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&p); err != nil {
return "", "", false, fmt.Errorf("the registry's document of %s: %w", name, err)
return packument{}, false, fmt.Errorf("the registry's document of %s: %w", name, err)
}
return p, true, nil
}
// registryVersion reads what the registry holds of name@version: its packed file's address and integrity, and whether
// it holds it at all.
func (m *Module) registryVersion(ctx context.Context, name, version string) (tarball, integrity string, held bool, err error) {
p, held, err := m.packumentOf(ctx, name)
if err != nil || !held {
return "", "", false, err
}
v, ok := p.Versions[version]
if !ok {
@@ -91,29 +91,42 @@ func placedVersion(dir string) string {
return p.Version
}
// The lead's decision L10 (ADR 0500 §6): a member is built against the SDK version its own lock names, downloaded from
// the registry and checked against the lock's integrity; a version the registry does not hold is a wait, `waits for:`.
func TestAMemberGetsTheSDKVersionItsLockNamesOrWaitsForIt(t *testing.T) {
// ADR 0573 §2 (unpinned, the lead's decision L10's lock version gone): a member is built against the latest release the
// registry holds, whatever its lock names or when it names none, checked against the lock's integrity only when the lock
// names that version; a pre-release is never the latest; a registry holding no release is a wait, `waits for:`.
func TestAMemberGetsTheLatestPublishedSDKWhateverItsLockNames(t *testing.T) {
m, _, _ := newMachine(t)
held := tgzOf(t, sdkPackage, "0.1.15")
held, older, pre := tgzOf(t, sdkPackage, "0.1.15"), tgzOf(t, sdkPackage, "0.1.9"), tgzOf(t, sdkPackage, "0.1.16-feat-x.1")
fakeRegistry(t, m, map[string][]byte{"0.1.15": held, "0.1.9": older, "0.1.16-feat-x.1": pre})
for _, c := range []struct {
name, locks, integrity, fails string
}{
{name: "a lock naming the latest", locks: "0.1.15", integrity: integrityOf(held)},
{name: "a lock naming an older version (the pin)", locks: "0.1.9", integrity: integrityOf(older)},
{name: "a lock naming a version the registry lacks", locks: "0.1.16", integrity: "sha512-x"},
{name: "a lock naming none", locks: ""},
{name: "a lock naming the latest with another integrity", locks: "0.1.15", integrity: "sha512-another", fails: "sha512-another"},
} {
dir := memberLocking(t, c.locks, c.integrity)
err := m.placeSDK(context.Background(), dir, "modules/m", nil, nil)
switch {
case c.fails == "" && (err != nil || placedVersion(dir) != "0.1.15"):
t.Errorf("%s: %v, placed %q, want the latest release 0.1.15", c.name, err, placedVersion(dir))
case c.fails != "" && (err == nil || !strings.Contains(err.Error(), c.fails)):
t.Errorf("%s: %v, want it to fail naming %s", c.name, err, c.fails)
}
}
fakeRegistry(t, m, map[string][]byte{"0.1.16-feat-x.1": pre})
if err := m.placeSDK(context.Background(), memberLocking(t, "0.1.15", ""), "modules/m", nil, nil); err == nil || err.Error() != builds.WaitsFor+"@novox/mesh-sdk" {
t.Errorf("a registry with no release answered %v, want %s@novox/mesh-sdk", err, builds.WaitsFor)
}
held = tgzOf(t, sdkPackage, "0.1.15")
fakeRegistry(t, m, map[string][]byte{"0.1.15": held})
dir := memberLocking(t, "0.1.15", integrityOf(held))
if err := m.placeSDK(context.Background(), dir, "modules/m", nil, nil); err != nil || placedVersion(dir) != "0.1.15" {
t.Errorf("the registry's 0.1.15: %v, placed %q", err, placedVersion(dir))
}
dir = memberLocking(t, "0.1.16", "sha512-x")
err := m.placeSDK(context.Background(), dir, "modules/m", nil, nil)
if err == nil || err.Error() != builds.WaitsFor+"@novox/mesh-sdk@0.1.16" {
t.Errorf("a version the registry lacks answered %v, want %s@novox/mesh-sdk@0.1.16", err, builds.WaitsFor)
}
dir = memberLocking(t, "0.1.15", "sha512-another")
if err := m.placeSDK(context.Background(), dir, "modules/m", nil, nil); err == nil || !strings.Contains(err.Error(), "sha512-another") {
t.Errorf("a registry file of another integrity than the lock's answered %v", err)
}
var dir string
// ADR 0607 §3, §5 (design 51 *Tagged versions*): given the group's tagged version, the fetch places it from the
// registry whatever the lock names, against the lock's integrity when the lock names it, else the registry's; one the
// registry lacks fails naming it, no wait (the check waited for its published).
tagged := tgzOf(t, sdkPackage, "0.1.16-feat-x.1")
tagged := pre
fakeRegistry(t, m, map[string][]byte{"0.1.15": held, "0.1.16-feat-x.1": tagged})
group := &groupPackage{Name: sdkPackage, Version: "0.1.16-feat-x.1"}
for _, c := range []struct {
@@ -140,8 +153,8 @@ func TestAMemberGetsTheSDKVersionItsLockNamesOrWaitsForIt(t *testing.T) {
}
// ADR 0500 §5 (S3, S4): the group's packed package is served in place of the registry's when the member's lock names its
// version with its integrity; another integrity, or a version neither the group nor the registry holds, fails naming both.
// A member whose lock names no SDK keeps the toolchain's and asks the registry nothing.
// version with its integrity; another integrity fails naming both. A member locking another version, or none, gets the
// registry's latest release (ADR 0573 §2); with no release in the registry it fails naming the group's, no wait.
func TestTheGroupsPackageIsServedToAMemberLockingItsVersion(t *testing.T) {
m, _, _ := newMachine(t)
asked := fakeRegistry(t, m, map[string][]byte{"0.1.14": tgzOf(t, sdkPackage, "0.1.14")})
@@ -159,14 +172,15 @@ func TestTheGroupsPackageIsServedToAMemberLockingItsVersion(t *testing.T) {
if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err != nil || placedVersion(dir) != "0.1.14" {
t.Errorf("a member locking the registry's 0.1.14 in a group: %v, placed %q", err, placedVersion(dir))
}
dir = memberLocking(t, "0.1.13", "sha512-x")
if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err == nil || !strings.Contains(err.Error(), "0.1.13") || !strings.Contains(err.Error(), "0.1.15") || strings.HasPrefix(err.Error(), builds.WaitsFor) {
t.Errorf("a version neither holds answered %v, want both named and no wait", err)
for _, locks := range []string{"0.1.13", ""} {
dir = memberLocking(t, locks, "sha512-x")
if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err != nil || placedVersion(dir) != "0.1.14" {
t.Errorf("a member in a group locking %q: %v, placed %q, want the registry's latest 0.1.14", locks, err, placedVersion(dir))
}
}
before := asked.Load()
dir = memberLocking(t, "", "")
if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err != nil || placedVersion(dir) != "" || asked.Load() != before {
t.Errorf("a lock naming no SDK: %v, placed %q, the registry asked", err, placedVersion(dir))
fakeRegistry(t, m, nil)
if err := m.placeSDK(context.Background(), memberLocking(t, "0.1.13", "sha512-x"), "modules/m", group, nil); err == nil || !strings.Contains(err.Error(), "0.1.15") || strings.HasPrefix(err.Error(), builds.WaitsFor) {
t.Errorf("no release and a group answered %v, want the group's named and no wait", err)
}
}
@@ -14,7 +14,7 @@ import (
// RecipeSteps is the recipe's own version: changed whenever the steps of fetch, test or build change in a way that
// can change what they produce (ADR 0405 §7). It is one of the two values the recipe version hashes.
const RecipeSteps = "2" // 2: a member is built against the SDK its own lock names (R4e, the lead's decision L10)
const RecipeSteps = "3" // 3: a member is built against the latest published SDK, the toolchain holds none (ADR 0573 §2)
// Recipe is every value that makes up the recipe version.
type Recipe struct {
@@ -3,7 +3,7 @@ package main
// A workspace and the packages a step runs against (novox/hq ADR 0469 §2 and §5; issues 1490, 1491). A workspace is
// <work dir>/<step id>/tree, made by the fetch step. The toolchain's packages are copied to <work dir>/<step id>/
// node_modules, which lies above the tree: node, tsc and esbuild resolve an import by walking up from the importing
// file, so @novox/mesh-sdk and the types are found there while the tree itself, which a bundle is packed from, stays
// file, so the types are found there while the tree itself, which a bundle is packed from, stays
// what the commit holds. A copy and not a link, because the mesh makes no symlink.
import (
-6
View File
@@ -8,7 +8,6 @@
"name": "mesh-build-node-toolchain",
"version": "1.0.0",
"dependencies": {
"@novox/mesh-sdk": "0.1.14",
"nats": "2.29.3"
},
"devDependencies": {
@@ -459,11 +458,6 @@
"node": ">=18"
}
},
"node_modules/@novox/mesh-sdk": {
"version": "0.1.14",
"resolved": "https://git.novox.be/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz",
"integrity": "sha512-ICdIfVicQQXSw6fkLiwzoRx3weiWh0jsIEYG/m98CKgFIlziZzb7V+ZmT/TvPO9bTDFDtfqDLoFu+OXY5+ryQw=="
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@@ -4,7 +4,6 @@
"private": true,
"description": "The Node build module's toolchain: what npm ci installs into <cache>/toolchain/<recipe version> (issue 1488).",
"dependencies": {
"@novox/mesh-sdk": "0.1.14",
"nats": "2.29.3"
},
"devDependencies": {
+1 -1
View File
@@ -46,7 +46,7 @@ names who gave it in `by`, or on the operator's Approve of a proposal.
`action` `propose` (with `value`, or `clear`) judges the value as a set would, keeps it in `mesh-config_proposals`,
and asks the operator under mesh-config's own name on the `operator-channel` seat (its own grant, from `uses`, and
its own cap of three open asks; the same change twice is refused, and a newer proposal for the same level and key
the router holds an asker's oldest ask back behind a fourth, no cap of its own; the same change twice is refused, and a newer proposal for the same level and key
replaces the older). The ask says the level, the key and the value, exact where who answers is proven, and that,
approved, it takes effect at once on every node it reaches; Approve and Decline are both at the level approve and
each binds the proposal's level, key, value and the value it was shown against. On the answer mesh-config reads
@@ -641,8 +641,8 @@ func (c *Config) propose(call Call, action string) (Proposal, oc.Ask, []string,
return fail("the change carries %s, which leaves the mesh nowhere, so the operator could not be shown what "+
"they approve; config holds no secret, and %s", refusal, AtTheTerminalInstead)
}
// **Its own cap of open asks, and the same change once** (ADR 0277 §21); a newer proposal for the same place
// replaces an older one still open (§10).
// **The same change once** (ADR 0277 §21); a newer proposal for the same place replaces an older one still open
// (§10). No cap of open asks of its own: the router offers at most three and holds the oldest back (ADR 0666 §12).
all, err := c.proposals()
if err != nil {
return Proposal{}, oc.Ask{}, nil, err
@@ -662,10 +662,6 @@ func (c *Config) propose(call Call, action string) (Proposal, oc.Ask, []string,
}
open++
}
if open >= occ.MostOpen {
return fail("%d proposals already wait for the operator's answer, and mesh-config asks at most %d at once; "+
"config's proposals lists them", open, occ.MostOpen)
}
if why := approverReady(c.Asks); why != "" {
return fail("%s; %s", why, AtTheTerminalInstead)
}
@@ -484,9 +484,10 @@ func TestAProposalIsJudgedAndRefusedBeforeTheOperatorIsAsked(t *testing.T) {
}
propose(t, c, Call{Key: "font-size", Value: 15.0, Node: "novox"})
propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}})
// A fourth is asked, never refused: the router holds the asker's oldest back (ADR 0666 §12).
if _, err := c.Propose(Call{Action: "propose", Key: "width", Value: 400.0, Node: "g14", Module: "dunst",
Caller: "g", Why: "w"}); err == nil || !strings.Contains(err.Error(), "3 proposals already wait") {
t.Errorf("a fourth open ask: %v", err)
Caller: "g", Why: "w"}); err != nil {
t.Errorf("a fourth open ask was refused: %v", err)
}
if keys, _ := m.Keys(ValuesState); len(keys) != 1 {
t.Errorf("proposals set %v", keys)