Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b93626d2 | ||
|
|
68557b412f | ||
|
|
7a92224886 |
+60
-169
@@ -38,9 +38,8 @@ import (
|
||||
// by that one.
|
||||
//
|
||||
// A merge on the controller's own path (a module whose walk waits for nobody's word) never shares a batch with
|
||||
// one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds
|
||||
// no waiting catalogue walk, and a catalogue walk let go while another walk runs starts once it ended — so no
|
||||
// catalogue batch's walk starts without the word (decided during the build, 2026-10-10).
|
||||
// one that waits for mesh-delivery's: each kind has a batch of its own, so no catalogue delivery skips its turn
|
||||
// behind a controller merge (decided during the build, 2026-10-10).
|
||||
//
|
||||
// The batch, its merges and their times are in the store (batched_merge, and the batch's own plan record in
|
||||
// the state `assembling` or `queued`), so a restarted controller resumes the window where it stood.
|
||||
@@ -182,12 +181,7 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
|
||||
if _, known, err := inv.MergeOf(ctx, repository, m.Commit); err != nil || known {
|
||||
return notNow(err)
|
||||
}
|
||||
var moves []string
|
||||
for _, e := range touches {
|
||||
moves = append(moves, e.Manifest.Module)
|
||||
}
|
||||
sort.Strings(moves)
|
||||
event, err := json.Marshal(keptMerge{SourceMoved: m, Moves: moves})
|
||||
event, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -199,7 +193,7 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
|
||||
// word** (decided during the build, 2026-10-10): batched together, the catalogue's deliveries would start
|
||||
// with the controller's and skip their turn. Each kind has a batch of its own.
|
||||
own := ownPath(touches)
|
||||
if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own, deliverySeatHeld(entries)); err != nil {
|
||||
if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own); err != nil {
|
||||
return notNow(err)
|
||||
} else if ok {
|
||||
heard.Plan = p.ID
|
||||
@@ -257,7 +251,7 @@ func owedLate(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved,
|
||||
// later merge, which contains it. A failed or stopped walk answers nothing more, and a walk folded into
|
||||
// another is followed to that one. False when none does: the merge joins the next batch.
|
||||
func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge,
|
||||
moves []inventory.Entry, own, held bool) (inventory.Plan, bool, error) {
|
||||
moves []inventory.Entry, own bool) (inventory.Plan, bool, error) {
|
||||
later, err := inv.LaterMergesOf(ctx, heard.Repository, heard.Branch, heard.Merged)
|
||||
if err != nil || len(later) == 0 {
|
||||
return inventory.Plan{}, false, err
|
||||
@@ -274,11 +268,7 @@ func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard
|
||||
return p, true, nil
|
||||
}
|
||||
case p.Open() || p.State == inventory.PlanDone:
|
||||
// A walk that waited for nobody's word is not a catalogue merge's to be answered by while the delivery
|
||||
// seat has a holder: its delivery would skip its turn (decided during the build, 2026-10-10). With no
|
||||
// holder on record nothing waits, and any walk that built it answers.
|
||||
waited := p.Delivery != nil && p.Delivery.Awaits != ""
|
||||
if buildsEvery(p, moves) && (own || waited || !held) {
|
||||
if buildsEvery(p, moves) {
|
||||
return p, true, nil
|
||||
}
|
||||
}
|
||||
@@ -441,7 +431,7 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
|
||||
if repo == "" {
|
||||
repo = m.Repository
|
||||
}
|
||||
n := namedOf(m, repo)
|
||||
n := inventory.PlanMerge{Repository: repo, Commit: m.Commit}
|
||||
if l := latest[k]; l.Commit != m.Commit {
|
||||
n.Carried = l.Commit
|
||||
}
|
||||
@@ -453,35 +443,15 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
|
||||
return commits, named
|
||||
}
|
||||
|
||||
// keptMerge is a merge as the record keeps it: the forge's announcement, and the modules it moved when it was
|
||||
// heard. The announcement reads back as a SourceMoved alone, which ignores the rest.
|
||||
type keptMerge struct {
|
||||
link.SourceMoved
|
||||
Moves []string `json:"moves,omitempty"`
|
||||
}
|
||||
|
||||
// announcedOf is a kept merge as the forge announced it, and what it moved; empty where the record says none.
|
||||
func announcedOf(m inventory.BatchedMerge) keptMerge {
|
||||
var k keptMerge
|
||||
_ = json.Unmarshal(m.Event, &k)
|
||||
return k
|
||||
}
|
||||
|
||||
// spelledOf is a kept merge's repository as the forge spelled it; empty when its announcement does not say.
|
||||
func spelledOf(m inventory.BatchedMerge) string {
|
||||
if e := announcedOf(m); e.Owner != "" {
|
||||
var e link.SourceMoved
|
||||
if json.Unmarshal(m.Event, &e) == nil && e.Owner != "" {
|
||||
return e.Owner + "/" + e.Repo
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// namedOf is one merge as a walk or batch names it: its commit, and its pull request and moves as announced.
|
||||
func namedOf(m inventory.BatchedMerge, repository string) inventory.PlanMerge {
|
||||
k := announcedOf(m)
|
||||
return inventory.PlanMerge{Repository: repository, Commit: m.Commit, Number: k.Number, Title: k.Title, Moves: k.Moves,
|
||||
Merged: m.Merged, Heard: m.Heard}
|
||||
}
|
||||
|
||||
// laterMerge says a was merged after b: by the forge's merge time, then by when each was heard.
|
||||
func laterMerge(a, b inventory.BatchedMerge) bool {
|
||||
if !a.Merged.Equal(b.Merged) {
|
||||
@@ -510,11 +480,10 @@ func nameMerges(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan
|
||||
// The walk's own commits stand: a merge heard late is carried by the one of its repository's branch.
|
||||
var named []inventory.PlanMerge
|
||||
for _, m := range merges {
|
||||
repo := m.Repository
|
||||
n := inventory.PlanMerge{Repository: m.Repository, Commit: m.Commit}
|
||||
if e := spelledOf(m); e != "" {
|
||||
repo = e
|
||||
n.Repository = e
|
||||
}
|
||||
n := namedOf(m, repo)
|
||||
if c := p.CommitOn(m.Repository, m.Branch); c != "" && c != m.Commit {
|
||||
n.Carried = c
|
||||
}
|
||||
@@ -610,18 +579,18 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
|
||||
if err := keepAll(""); err != nil {
|
||||
return err
|
||||
}
|
||||
// The oldest batch whose window closed is cut, an own-path one before a catalogue one (it goes first, and
|
||||
// the catalogue batch queues behind it rather than being cut and overtaken); one of each kind at most is
|
||||
// open (theOpenBatch).
|
||||
sort.SliceStable(batches, func(i, j int) bool { return batches[i].OwnPath() && !batches[j].OwnPath() })
|
||||
// The oldest batch whose window closed is cut; one of each kind at most is open (theOpenBatch).
|
||||
for i := range batches {
|
||||
b := &batches[i]
|
||||
if !windowClosed(*b, now) {
|
||||
continue
|
||||
}
|
||||
if b.OwnPath() {
|
||||
// A walk on the controller's own path folds no walk waiting for its word (decided during the build,
|
||||
// 2026-10-10): that walk keeps waiting beside it, and starts once this one ended (advanceOnce).
|
||||
// A walk on the controller's own path folds no walk waiting for its word: those merges are batched
|
||||
// again, behind it (decided during the build, 2026-10-10).
|
||||
if err := rebatchWaiting(ctx, inv, waiting, b.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
waiting = nil
|
||||
}
|
||||
if err := cutBatch(ctx, open, b, waiting, now); err != nil {
|
||||
@@ -639,6 +608,46 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebatchWaiting puts the merges of the walks waiting for their word into the open batch of their kind — a new
|
||||
// one when none is — queued behind the walk about to be cut, and closes the walks as taken over by that batch:
|
||||
// the batch keeps its id when it is cut, so the delivery's owner follows them to the walk that answers them.
|
||||
func rebatchWaiting(ctx context.Context, inv *inventory.Inventory, waiting []inventory.Plan, behind string, now time.Time) error {
|
||||
for i := range waiting {
|
||||
w := waiting[i]
|
||||
merges, err := inv.MergesOf(ctx, w.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(merges) == 0 {
|
||||
continue // nothing of the record's: left waiting
|
||||
}
|
||||
batch, err := theOpenBatch(ctx, inv, merges[0], now, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range merges {
|
||||
if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, batch.ID, m.Alone); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := keepBatch(ctx, inv, &batch, now, behind); err != nil {
|
||||
return err
|
||||
}
|
||||
w.State = inventory.PlanSuperseded
|
||||
if w.Delivery == nil {
|
||||
w.Delivery = &inventory.PlanDelivery{}
|
||||
}
|
||||
w.Delivery.TakenOverBy = batch.ID
|
||||
w.Note = fmt.Sprintf("superseded at tier %d by %s before it started: a walk on the controller's own path "+
|
||||
"(%s) goes first, and that batch answers its merges after it", w.Tier, batch.ID, behind)
|
||||
if err := inv.SavePlan(ctx, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %s is %s\n", w.ID, w.Note)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cutBatch makes a closed batch one walk, folding in the walks that wait for their word: it keeps its id, and
|
||||
// the folded walks name it as the walk that took them over.
|
||||
func cutBatch(ctx context.Context, open *stores, batch *inventory.Plan, waiting []inventory.Plan, now time.Time) error {
|
||||
@@ -745,9 +754,6 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
|
||||
}
|
||||
}
|
||||
}
|
||||
// **Whether it waits for its delivery's word** is read from what its merges moved (novox/hq ADR 0239), never
|
||||
// from what a folded walk carried along, which holds dependents too.
|
||||
awaits := awaitsFor(entries, moved)
|
||||
held := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
held[e.Manifest.Module] = true
|
||||
@@ -767,14 +773,14 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
|
||||
plan.Commits = commits
|
||||
newest := newestCommit(commits)
|
||||
plan.Repository, plan.Branch, plan.Commit, plan.Merged = newest.Repository, newest.Branch, newest.Commit, newest.Merged
|
||||
plan.Delivery = awaits
|
||||
// **Whether it waits for its delivery's word** (novox/hq ADR 0239): while the mesh-delivery seat has a
|
||||
// holder on record, a walk that moves no module on the controller's own path is opened and waits.
|
||||
plan.Delivery = awaitsFor(entries, moved)
|
||||
if plan.Delivery == nil {
|
||||
plan.Delivery = &inventory.PlanDelivery{}
|
||||
}
|
||||
plan.Delivery.Merges = named
|
||||
plan.Delivery.Alone = batch.Delivery != nil && batch.Delivery.Alone
|
||||
// **Its moments and its class** (novox/hq ADR 0282 decision 6): measured, never acted on.
|
||||
plan.Times = walkTimesAtCut(*batch, plan, entries, now)
|
||||
if len(moved) == 0 {
|
||||
plan.State = inventory.PlanDone
|
||||
plan.Tiers = [][]string{}
|
||||
@@ -823,50 +829,6 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkTimesAtCut is a walk's own moments as it is cut (novox/hq ADR 0282 decision 6): when its batch's window
|
||||
// closed — no merge for the window's length, or its maximum, whichever came first — when it was cut, and its
|
||||
// class. A merge walked alone had no window.
|
||||
func walkTimesAtCut(batch, walk inventory.Plan, entries []inventory.Entry, now time.Time) *inventory.PlanTimes {
|
||||
cut := now
|
||||
t := &inventory.PlanTimes{Cut: &cut, Class: classOf(walk, entries)}
|
||||
if batch.Delivery != nil && batch.Delivery.Batch != nil {
|
||||
w := batch.Delivery.Batch
|
||||
closed := w.ClosesAt
|
||||
if !w.AtMost.IsZero() && w.AtMost.Before(closed) {
|
||||
closed = w.AtMost
|
||||
}
|
||||
if !closed.IsZero() {
|
||||
if closed.After(now) {
|
||||
closed = now
|
||||
}
|
||||
t.WindowClosed = &closed
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// resolverSeat is the seat of the mesh's resolver: a module claiming it is a core module (ADR 0282 decision 1).
|
||||
const resolverSeat = "mesh-dns-resolver"
|
||||
|
||||
// classOf is a walk's class (novox/hq ADR 0282 decision 1): core when it walks a module on the controller's own
|
||||
// path or one holding the mesh's resolver, leaf otherwise.
|
||||
func classOf(walk inventory.Plan, entries []inventory.Entry) string {
|
||||
resolvers := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
for _, c := range e.Manifest.Claims {
|
||||
if c.Name == resolverSeat {
|
||||
resolvers[e.Manifest.Module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for m := range walk.Modules {
|
||||
if _, own := onTheControllersPath[m]; own || resolvers[m] {
|
||||
return inventory.ClassCore
|
||||
}
|
||||
}
|
||||
return inventory.ClassLeaf
|
||||
}
|
||||
|
||||
// combinedMerges is a batch's merges as one merge per repository's branch: the latest, with every file the
|
||||
// merges of it changed and said to be a module's — on a linear trunk the latest contains the others. A file is
|
||||
// removed when the last merge of the batch that changed it removed it. merges are in the order they were made.
|
||||
@@ -1126,77 +1088,6 @@ func groupedWords(b inventory.Plan) string {
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// batchLines is a batch's grouped list as `plans` prints it under its line (asked by the operator, 2026-10-10):
|
||||
// one line per repository, its pull request and title, the earlier merges it answers, and what its merges move.
|
||||
func batchLines(b inventory.Plan) []string {
|
||||
var out []string
|
||||
for _, c := range b.Carried() {
|
||||
var head *inventory.PlanMerge
|
||||
var answers []string
|
||||
moves := map[string]bool{}
|
||||
if b.Delivery != nil {
|
||||
for i := range b.Delivery.Merges {
|
||||
m := &b.Delivery.Merges[i]
|
||||
if !strings.EqualFold(m.Repository, c.Repository) {
|
||||
continue
|
||||
}
|
||||
for _, n := range m.Moves {
|
||||
moves[n] = true
|
||||
}
|
||||
switch {
|
||||
case m.Commit == c.Commit:
|
||||
head = m
|
||||
case m.Carried == c.Commit:
|
||||
answers = append(answers, pullWords(*m))
|
||||
}
|
||||
}
|
||||
}
|
||||
line := repoName(c.Repository) + " " + short(c.Commit)
|
||||
if head != nil && head.Number > 0 {
|
||||
line = repoName(c.Repository) + " " + pullWords(*head)
|
||||
}
|
||||
if len(answers) > 0 {
|
||||
line += " (answers " + strings.Join(answers, ", ") + ")"
|
||||
}
|
||||
if len(moves) > 0 {
|
||||
line += " · " + strings.Join(sortedKeysOf(boolsToStrings(moves)), ", ")
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pullWords is a merge as its pull request: "#175 a tap shows its outcome", the title cut at fifty runes; the
|
||||
// commit where the announcement named no pull request.
|
||||
func pullWords(m inventory.PlanMerge) string {
|
||||
if m.Number == 0 {
|
||||
return short(m.Commit)
|
||||
}
|
||||
s := fmt.Sprintf("#%d", m.Number)
|
||||
if t := cutTitle(m.Title, 50); t != "" {
|
||||
s += " " + t
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// cutTitle is a title cut at n runes, with an ellipsis where it was cut.
|
||||
func cutTitle(title string, n int) string {
|
||||
r := []rune(strings.TrimSpace(title))
|
||||
if len(r) <= n {
|
||||
return string(r)
|
||||
}
|
||||
return strings.TrimSpace(string(r[:n-1])) + "…"
|
||||
}
|
||||
|
||||
// boolsToStrings is a set's members, for sortedKeysOf.
|
||||
func boolsToStrings(set map[string]bool) map[string]string {
|
||||
out := make(map[string]string, len(set))
|
||||
for k := range set {
|
||||
out[k] = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// secondsWords is a short wait as a person reads it.
|
||||
func secondsWords(d time.Duration) string {
|
||||
if d < 2*time.Minute {
|
||||
|
||||
@@ -136,7 +136,7 @@ func TestTwoMergesSecondsApartAreOneWalkAtTheLaterCommit(t *testing.T) {
|
||||
}
|
||||
want := []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: claude.Commit, Carried: dunst.Commit},
|
||||
{Repository: "novox/mesh-catalog", Commit: dunst.Commit}}
|
||||
if w.Delivery == nil || !slices.EqualFunc(w.Delivery.Merges, want, sameMerge) {
|
||||
if w.Delivery == nil || !slices.Equal(w.Delivery.Merges, want) {
|
||||
t.Fatalf("the walk answers %+v, want %+v", w.Delivery, want)
|
||||
}
|
||||
if len(*asked) != 2 || (*asked)[0][2] != "main" || (*asked)[1][2] != "main" {
|
||||
@@ -229,28 +229,17 @@ func TestTheAssemblingBatchIsShown(t *testing.T) {
|
||||
inventory.PlanSaved = func(p inventory.Plan) { said = append(said, p) }
|
||||
t.Cleanup(func() { inventory.PlanSaved = was })
|
||||
now := time.Now().UTC()
|
||||
claude := catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second))
|
||||
claude.Number, claude.Title = 174, "claude-code: an agent proposes a section"
|
||||
dunst := catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second))
|
||||
dunst.Number, dunst.Title = 175, "dunst: the font the operator chose"
|
||||
hear(t, open, claude, now.Add(-19*time.Second))
|
||||
hear(t, open, dunst, now.Add(-time.Second))
|
||||
hear(t, open, catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second)), now.Add(-19*time.Second))
|
||||
hear(t, open, catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second)), now.Add(-time.Second))
|
||||
hear(t, open, repoMerge("one", "a6bc0931one", now), now)
|
||||
out := captured(t, func() error { return plansCommand(t.Context(), nil) })
|
||||
lines := strings.Split(out, "\n")
|
||||
first := lines[0]
|
||||
first := strings.SplitN(out, "\n", 2)[0]
|
||||
for _, want := range []string{"assembling: ", " s left (at the latest ", "grouped: novox/mesh-catalog@48bda475 " +
|
||||
"(answers 553b7191), novox/one@a6bc0931; plan not yet calculated"} {
|
||||
if !strings.Contains(first, want) {
|
||||
t.Fatalf("plans' first line %q does not say %q", first, want)
|
||||
}
|
||||
}
|
||||
// Under it, one line per repository: the pull request, what it answers, what it moves.
|
||||
if len(lines) < 3 || strings.TrimSpace(lines[1]) != "mesh-catalog #175 dunst: the font the operator chose (answers "+
|
||||
"#174 claude-code: an agent proposes a section) · app, notes" ||
|
||||
strings.TrimSpace(lines[2]) != "one a6bc0931 · one" {
|
||||
t.Fatalf("the grouped list reads %q", lines[1:4])
|
||||
}
|
||||
if len(said) == 0 || said[len(said)-1].State != inventory.PlanAssembling || said[len(said)-1].Delivery.Batch == nil ||
|
||||
len(said[len(said)-1].Delivery.Merges) != 3 {
|
||||
t.Fatalf("the batch was not said as assembling with its merges: %+v", said)
|
||||
@@ -375,7 +364,7 @@ func TestALateMergeIsAnsweredByTheWalkOfTheLaterOne(t *testing.T) {
|
||||
hear(t, open, catalogueMerge("553b7191early", "notes", t0), t0.Add(15*time.Minute))
|
||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||
if got.State != inventory.PlanDone || len(got.Delivery.Merges) != 2 ||
|
||||
!sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early",
|
||||
got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early",
|
||||
Carried: later.Commit}) {
|
||||
t.Fatalf("the late merge is not named by the walk that carried it: %+v", got.Delivery.Merges)
|
||||
}
|
||||
@@ -415,7 +404,7 @@ func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) {
|
||||
ws, _ = walks(t, open)
|
||||
alone := ws[0]
|
||||
if alone.ID == failed.ID || alone.Commit != middle.Commit || len(alone.Delivery.Merges) != 1 ||
|
||||
!sameMerge(alone.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) {
|
||||
alone.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) {
|
||||
t.Fatalf("the newest earlier merge was not walked alone on its own commit: %+v", alone)
|
||||
}
|
||||
if _, in := alone.Modules["app"]; !in || (*asked)[len(*asked)-1] != [3]string{"novox/mesh-catalog", "modules/app",
|
||||
@@ -436,7 +425,7 @@ func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) {
|
||||
t.Fatalf("the search went on after a merge was delivered: %+v", ws[0])
|
||||
}
|
||||
done, _ := open.inventory.PlanByID(ctx, alone.ID)
|
||||
if len(done.Delivery.Merges) != 2 || !sameMerge(done.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog",
|
||||
if len(done.Delivery.Merges) != 2 || done.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog",
|
||||
Commit: oldest.Commit, Carried: middle.Commit}) {
|
||||
t.Fatalf("the oldest merge is not answered by the walk that delivered the one after it: %+v", done.Delivery.Merges)
|
||||
}
|
||||
@@ -576,7 +565,7 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
|
||||
}
|
||||
hear(t, open, repoMerge("one", "c1", t0.Add(-60*time.Second)), t0.Add(15*time.Minute))
|
||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||
if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one",
|
||||
if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
|
||||
Commit: "c1", Carried: "c2"}) {
|
||||
t.Fatalf("the late merge was not named by the walk that carried it: %+v", got.Delivery.Merges)
|
||||
}
|
||||
@@ -600,11 +589,10 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// One walk at a time holds against the delivery's word too: a waiting walk let go beside a started one takes the
|
||||
// word and starts once that one ended, asking nothing before.
|
||||
func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
|
||||
// One walk at a time holds against the delivery's word too: a waiting walk is not let go while another started.
|
||||
func TestAWaitingWalkIsNotLetGoBesideAStartedOne(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asked := asksWithPaths(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
@@ -615,37 +603,9 @@ func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
|
||||
if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err != nil {
|
||||
t.Fatalf("the word was refused beside %s: %v", started[0].ID, err)
|
||||
}
|
||||
before := len(*asked)
|
||||
advanceHeld(ctx, open)
|
||||
got, _ := open.inventory.PlanByID(ctx, waiting.ID)
|
||||
if len(*asked) != before || !strings.Contains(got.Note, "starts once "+started[0].ID) {
|
||||
t.Fatalf("a walk let go beside a started one asked (%d → %d) or does not say it waits: %q", before, len(*asked), got.Note)
|
||||
}
|
||||
// Read as a wait, an hour on: its note on the line, never LATE, and no tier of it late for S3.
|
||||
later := time.Now().Add(time.Hour)
|
||||
if line := planLine(got, later); !strings.Contains(line, "starts once "+started[0].ID) || strings.Contains(line, "LATE") {
|
||||
t.Fatalf("a deferred walk reads %q", line)
|
||||
}
|
||||
facts, _, err := gatherPlans(ctx, open.inventory, later, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range facts {
|
||||
if f.id == got.ID {
|
||||
t.Fatalf("a deferred walk is watched as a tier running late: %+v", f)
|
||||
}
|
||||
}
|
||||
done := started[0]
|
||||
done.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advanceHeld(ctx, open)
|
||||
if len(*asked) != before+1 {
|
||||
t.Fatalf("the walk did not start once the started one ended: asked %v", *asked)
|
||||
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err == nil ||
|
||||
!strings.Contains(err.Error(), started[0].ID) {
|
||||
t.Fatalf("a waiting walk was let go beside %s: %v", started[0].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +660,7 @@ func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||
if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one",
|
||||
if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
|
||||
Commit: "c1", Carried: "c2"}) {
|
||||
t.Fatalf("the earlier merge the catch-up handed over is not named by the walk that carried it: %+v",
|
||||
got.Delivery.Merges)
|
||||
@@ -746,157 +706,55 @@ func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) {
|
||||
if !strings.Contains(batchWords(ownBatch, t0.Add(30*time.Second)), "own path") {
|
||||
t.Fatalf("the own-path batch does not say so: %q", batchWords(ownBatch, t0.Add(30*time.Second)))
|
||||
}
|
||||
// Both windows closed, the controller's batch is cut first and starts; the catalogue batch queues behind it,
|
||||
// is cut when the controller's walk ended, and waits for its word with both merges.
|
||||
// The catalogue batch, the older, is cut first and waits for its word; the controller's batch is cut next:
|
||||
// the waiting walk is batched again behind it, not folded into it.
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, bs := walks(t, open)
|
||||
if len(ws) != 1 || ws[0].Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k1" {
|
||||
t.Fatalf("the controller's batch was not cut first into a started walk: %+v", ws)
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) != 1 || !ws[0].Waiting() {
|
||||
t.Fatalf("the catalogue batch was not cut into a waiting walk: %+v", ws)
|
||||
}
|
||||
catalogueWalk := ws[0]
|
||||
cutAt(t, open, t0.Add(2*time.Minute+5*time.Second))
|
||||
ws, bs = walks(t, open)
|
||||
var ownWalk inventory.Plan
|
||||
for _, w := range ws {
|
||||
if w.Open() {
|
||||
ownWalk = w
|
||||
}
|
||||
}
|
||||
if ownWalk.ID == "" || ownWalk.Waiting() || ownWalk.CommitOf("novox/mesh-controller") != "k1" {
|
||||
t.Fatalf("the controller's batch was not cut into a started walk: %+v", ws)
|
||||
}
|
||||
ownWalk := ws[0]
|
||||
for _, m := range []string{"app", "notes"} {
|
||||
if _, in := ownWalk.Modules[m]; in {
|
||||
t.Fatalf("the controller's walk builds %s, a catalogue module: it skipped its turn", m)
|
||||
}
|
||||
}
|
||||
if len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() {
|
||||
t.Fatalf("the catalogue batch does not queue behind the controller's walk: %+v", bs)
|
||||
}
|
||||
ownWalk.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cutAt(t, open, t0.Add(3*time.Minute))
|
||||
ws, bs = walks(t, open)
|
||||
if len(bs) != 0 || ws[0].ID != catalogueBatch.ID || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 {
|
||||
t.Fatalf("the catalogue batch was not cut into a waiting walk once the controller's ended: %+v %+v", ws, bs)
|
||||
}
|
||||
for _, m := range []string{"app", "notes"} {
|
||||
if _, in := ws[0].Modules[m]; !in {
|
||||
t.Fatalf("the catalogue walk does not build %s: %v", m, ws[0].Modules)
|
||||
}
|
||||
}
|
||||
for _, a := range *asked {
|
||||
if a[1] == "modules/app" || a[1] == "modules/notes" {
|
||||
t.Fatalf("a catalogue module was asked without the word: %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A catalogue walk waiting for its word when an own-path batch is cut keeps waiting beside the own-path
|
||||
// walk, is not folded into it, and its word is taken meanwhile: it starts once the own-path walk ended.
|
||||
catalogueWalk := ws[0]
|
||||
hear(t, open, repoMerge("mesh-controller", "k2", t0.Add(4*time.Minute)), t0.Add(4*time.Minute))
|
||||
cutAt(t, open, t0.Add(6*time.Minute))
|
||||
ws, _ = walks(t, open)
|
||||
kept, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID)
|
||||
if !kept.Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k2" || ws[0].Waiting() {
|
||||
t.Fatalf("the waiting catalogue walk was not kept waiting beside the controller's walk: %s %+v", kept.State, ws)
|
||||
folded, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID)
|
||||
if folded.State != inventory.PlanSuperseded || len(bs) != 1 || folded.Delivery.TakenOverBy != bs[0].ID ||
|
||||
bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() ||
|
||||
len(bs[0].Delivery.Merges) != 2 {
|
||||
t.Fatalf("the waiting catalogue walk is %s (taken over by %q); batches %+v", folded.State,
|
||||
folded.Delivery.TakenOverBy, bs)
|
||||
}
|
||||
if _, in := ws[0].Modules["app"]; in {
|
||||
t.Fatalf("the controller's walk folded the waiting catalogue walk in: %v", ws[0].Modules)
|
||||
}
|
||||
if _, err := letGo(ctx, open.inventory, catalogueWalk.ID, catalogue.DeliverySeat, "its turn"); err != nil {
|
||||
// The controller's walk done, the catalogue batch is cut and waits for its word with both merges.
|
||||
ownWalk.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(*asked)
|
||||
advanceHeld(ctx, open)
|
||||
if len(*asked) != before {
|
||||
t.Fatalf("the catalogue walk started beside the controller's: asked %v", (*asked)[before:])
|
||||
cutAt(t, open, t0.Add(3*time.Minute))
|
||||
ws, bs = walks(t, open)
|
||||
if len(bs) != 0 || ws[0].ID != folded.Delivery.TakenOverBy || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 {
|
||||
t.Fatalf("the catalogue batch was not cut into a waiting walk once the controller's ended: %+v %+v", ws, bs)
|
||||
}
|
||||
own2 := ws[0]
|
||||
own2.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &own2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advanceHeld(ctx, open)
|
||||
if len(*asked) < before+1 || (*asked)[before][1] != "modules/app" {
|
||||
t.Fatalf("the catalogue walk did not start once the controller's ended: %v", (*asked)[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// `plans` names a walk by what it moves (asked by the operator, 2026-10-10): the repository's pull request and
|
||||
// title, the modules it moves and the machines running them, then the state words; a record naming no pull
|
||||
// request is named by its commit, and a title is cut at fifty runes.
|
||||
func TestAPlanLineNamesWhatItMoves(t *testing.T) {
|
||||
now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
|
||||
p := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1",
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"messenger", "telegram"}}, TierEntered: now.Add(-2 * time.Minute),
|
||||
Modules: map[string]*inventory.PlanModule{"messenger": {State: "asked"}, "telegram": {State: "asked"}},
|
||||
Commits: []inventory.PlanCommit{{Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1"}},
|
||||
Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-catalog",
|
||||
Commit: "c1c1c1c1c1", Number: 175, Title: "a tap shows its outcome", Moves: []string{"telegram", "messenger"}}}}}
|
||||
running := func(module string) []string {
|
||||
if module == "messenger" || module == "telegram" {
|
||||
return []string{"novox"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running),
|
||||
"mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want {
|
||||
t.Fatalf("the line reads %q, want %q", got, want)
|
||||
}
|
||||
if got := planLine(p, now); !strings.HasPrefix(got, "mesh-catalog #175 a tap shows its outcome · messenger, telegram · tier") {
|
||||
t.Fatalf("without the machines the line reads %q", got)
|
||||
}
|
||||
old := p
|
||||
old.Commits, old.Delivery = nil, nil
|
||||
if got := planLine(old, now); !strings.HasPrefix(got, "mesh-catalog c1c1c1c1 · tier 1 of 1") {
|
||||
t.Fatalf("a record naming no pull request reads %q", got)
|
||||
}
|
||||
long := p
|
||||
long.Delivery.Merges[0].Title = strings.Repeat("abcdefghij", 6)
|
||||
if got := planHeadline(long, nil); !strings.Contains(got, "#175 "+strings.Repeat("abcdefghij", 4)+"abcdefghi…") {
|
||||
t.Fatalf("a long title is not cut at fifty runes: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sameMerge compares what a walk names of a merge: its repository, commit and the commit carrying it.
|
||||
func sameMerge(a, b inventory.PlanMerge) bool {
|
||||
return a.Repository == b.Repository && a.Commit == b.Commit && a.Carried == b.Carried
|
||||
}
|
||||
|
||||
// A catalogue merge heard after a later merge of its branch was walked without the word (a merge that touched
|
||||
// the bus too, on the controller's own path) is not answered by that walk while the delivery seat has a holder:
|
||||
// its delivery would skip its turn. It joins the next catalogue batch.
|
||||
func TestALateCatalogueMergeIsNotAnsweredByAnOwnPathWalk(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
for _, m := range []struct {
|
||||
module string
|
||||
claims []catalogue.Claim
|
||||
}{
|
||||
{"nats", nil},
|
||||
{"mesh-delivery", []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}},
|
||||
} {
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m.module, Version: "1", Claims: m.claims},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m.module, Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
for _, m := range []string{"app", "notes"} {
|
||||
if _, in := ws[0].Modules[m]; !in {
|
||||
t.Fatalf("the catalogue walk does not build %s: %v", m, ws[0].Modules)
|
||||
}
|
||||
}
|
||||
if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mixed := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "m1xed", MergedAt: t0.Format(time.RFC3339Nano),
|
||||
Paths: []string{"modules/nats/module.json", "modules/app/module.json"}, ModuleDirs: []string{"modules/nats", "modules/app"},
|
||||
ModuleDirsSaid: true}
|
||||
hear(t, open, mixed, t0.Add(time.Second))
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) != 1 || ws[0].Waiting() {
|
||||
t.Fatalf("the mixed merge's walk waited, or was not cut: %+v", ws)
|
||||
}
|
||||
done := ws[0]
|
||||
done.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, open, catalogueMerge("ear1ier", "app", t0.Add(-30*time.Second)), t0.Add(3*time.Minute))
|
||||
got, _ := open.inventory.PlanByID(ctx, done.ID)
|
||||
_, bs := walks(t, open)
|
||||
if len(got.Delivery.Merges) != 1 || len(bs) != 1 || bs[0].OwnPath() || bs[0].Delivery.Merges[0].Commit != "ear1ier" {
|
||||
t.Fatalf("the late catalogue merge was answered by the own-path walk (%+v) rather than the next catalogue batch (%+v)",
|
||||
got.Delivery.Merges, bs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,44 +169,6 @@ func TestAShrinkOfMoreThanHalfIsUrgent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// THE FALSE ALARM (issue 368), replayed through the store D13 reads: an agent's home moved from the
|
||||
// operator's own home (94.7 MB) to the agent account's fresh one (490 B), and `data-shrank` was raised
|
||||
// for data that was never lost. A moved item is read against its new path only, so nothing is raised —
|
||||
// and a genuine shrink at the new path, a week of history later, still is.
|
||||
func TestAMovedPathIsNoShrinkAndAShrinkThereStillIs(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
shelf := shelfFor(t, houseManifest)
|
||||
declared := []inventory.DeclaredData{{Module: "house", Item: "config", Class: "irreplaceable", Owned: true}}
|
||||
start := time.Now().Add(-6 * time.Hour)
|
||||
measure := func(at time.Time, path string, size int64) []conditions.Observation {
|
||||
t.Helper()
|
||||
if _, err := inv.RecordData(ctx, "home", declared, map[string]map[string]inventory.Measurement{"house": {
|
||||
"config": {Path: path, Size: bytesOf(size), MeasuredAt: when(at), LastWrite: when(at),
|
||||
LastBackup: when(at)}}}, "", at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := inv.Data(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
peaks, err := inv.DataPeaks(ctx, at.Add(-shrinkWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dataFindings(records, peaks, shelf, nil, nil, at)
|
||||
}
|
||||
measure(start, "/home/operator/.claude", 94_700_000)
|
||||
if got := findingsByKind(measure(start.Add(10*time.Minute), "/home/agent/.claude", 490)); got[kindDataShrank].Kind != "" {
|
||||
t.Fatalf("a moved path raised a shrink: %+v", got[kindDataShrank])
|
||||
}
|
||||
measure(start.Add(2*time.Hour), "/home/agent/.claude", 300<<20)
|
||||
got := findingsByKind(measure(start.Add(4*time.Hour), "/home/agent/.claude", 1<<20))[kindDataShrank]
|
||||
if got.Severity != conditions.Urgent || !strings.Contains(got.Summary, "shrank") {
|
||||
t.Fatalf("a genuine shrink at the new path was not raised: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Data said to be written all the time and not written; data with no backup or an old one — urgent when
|
||||
// irreplaceable, a warning when valuable; and a new item given its bound before it is said.
|
||||
func TestQuietDataAndMissingBackupsAreSaidByClass(t *testing.T) {
|
||||
|
||||
@@ -103,8 +103,16 @@ func letGo(ctx context.Context, inv *inventory.Inventory, id, by, why string) (i
|
||||
return p, fmt.Errorf("%s was let go by %s at %s already", p.ID, p.Delivery.By,
|
||||
p.Delivery.Go.Local().Format("15:04:05"))
|
||||
}
|
||||
// **One walk at a time** (novox/hq ADR 0276): the word is taken, and the walk starts once no other walk is
|
||||
// started (advanceOnce), so the delivery's owner says it once and is not refused.
|
||||
// **One walk at a time** (novox/hq ADR 0276): a walk that started is open, so this one waits for its end.
|
||||
open, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
for _, q := range open {
|
||||
if q.ID != p.ID && q.Release == nil && !q.Waiting() {
|
||||
return p, fmt.Errorf("%s is open (%s): one walk at a time — %s is let go once it ended", q.ID, q.Named(), p.ID)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
|
||||
p.Note = "let go by " + by + "; its first tier is asked next"
|
||||
@@ -596,81 +604,12 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Each walk's phases, with the rest's reports (novox/hq ADR 0282 decision 6).
|
||||
now := time.Now()
|
||||
for i := range walks {
|
||||
walks[i].Phases = walks[i].WalkPhases(now, appliedFrom(ctx, inv))
|
||||
}
|
||||
return answer(map[string]any{"held": deliverySeatHeld(entries), "walks": walks,
|
||||
"own-path": sortedKeysOf(ownPathWords())})
|
||||
}
|
||||
return fmt.Errorf("delivery %s: plan, order, check, go, stop or walks", sub)
|
||||
}
|
||||
|
||||
// appliedFrom answers a machine's first report after a send from the controller's `apply` durations; a lookup
|
||||
// that fails is a report not read, which leaves the walk's end unknown rather than wrong.
|
||||
func appliedFrom(ctx context.Context, inv *inventory.Inventory) inventory.AppliedLookup {
|
||||
return func(node string, sent time.Time) (inventory.AppliedReport, bool) {
|
||||
r, ok, err := inv.FirstAppliedAfter(ctx, node, sent)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "the report of %s after %s could not be read: %v\n", node, sent.Format(time.RFC3339), err)
|
||||
return inventory.AppliedReport{}, false
|
||||
}
|
||||
return r, ok
|
||||
}
|
||||
}
|
||||
|
||||
// recordWalkPhases keeps, once, each phase of every walk ended lately whose end is known, as a duration of kind
|
||||
// walk-phase per class (novox/hq ADR 0282 decision 6): what `durations` summarises. A walk whose end is unknown
|
||||
// past ApplySilentAfter keeps its measured phases without its total.
|
||||
func recordWalkPhases(ctx context.Context, inv *inventory.Inventory, now time.Time) error {
|
||||
recent, err := inv.RecentPlans(ctx, 30)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range recent {
|
||||
if p.State != inventory.PlanDone || p.Release != nil || now.Sub(p.Updated) > 2*time.Hour {
|
||||
continue
|
||||
}
|
||||
anyKept, totalKept, err := inv.WalkPhasesKept(ctx, p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if totalKept {
|
||||
continue
|
||||
}
|
||||
ph := p.WalkPhases(now, appliedFrom(ctx, inv))
|
||||
if ph != nil && ph.End == nil && anyKept {
|
||||
continue // kept without its end; kept again only once its end is known
|
||||
}
|
||||
if ph == nil || (ph.End == nil && now.Sub(p.Updated) < inventory.ApplySilentAfter+time.Minute) {
|
||||
continue
|
||||
}
|
||||
class := ph.Class
|
||||
if class == "" {
|
||||
class = "unclassed"
|
||||
}
|
||||
for _, x := range ph.Phases {
|
||||
if x.State != inventory.PhaseMeasured || x.Start == nil {
|
||||
continue
|
||||
}
|
||||
if err := inv.RecordDuration(ctx, inventory.Duration{Kind: inventory.DurationWalkPhase,
|
||||
Subject: class + "/" + x.Name, Ref: fmt.Sprintf("%s/%s/%d", p.ID, x.Name, x.Tier), Started: *x.Start,
|
||||
Took: time.Duration(x.TookMS) * time.Millisecond, Detail: p.Named()}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if ph.End != nil && ph.From != nil {
|
||||
if err := inv.RecordDuration(ctx, inventory.Duration{Kind: inventory.DurationWalkPhase,
|
||||
Subject: class + "/total", Ref: p.ID + "/total", Started: *ph.From,
|
||||
Took: time.Duration(ph.TotalMS) * time.Millisecond, Detail: ph.Said}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownPathWords is the controller's own path as words, for an answer.
|
||||
func ownPathWords() map[string]string { return onTheControllersPath }
|
||||
|
||||
@@ -797,9 +736,6 @@ func sayPlanMoved(ctx context.Context, bus link.Bus, p inventory.Plan) {
|
||||
}
|
||||
|
||||
func publishPlanMoved(ctx context.Context, bus link.Bus, p inventory.Plan) {
|
||||
// Its phases so far (novox/hq ADR 0282 decision 6): the rest's reports come after the walk ends, and are
|
||||
// read by whoever asks for the walk (`delivery walks`).
|
||||
p.Phases = p.WalkPhases(time.Now(), nil)
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -15,23 +15,16 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10, ADR 0277).
|
||||
//
|
||||
// mesh-controller secret ask <node> <module> <name> [--at <desk>]
|
||||
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10).
|
||||
//
|
||||
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
|
||||
// server, calls `secret-ask` (or `give`) with the machine, the module, the secret's name and the desk — never
|
||||
// a value. The controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to
|
||||
// prompt the operator without showing what is typed, and is answered with what was typed **sealed to that
|
||||
// key**: no plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
|
||||
// server, calls `give` with the machine, the module, the secret's name and the desk — never a value. The
|
||||
// controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to prompt the
|
||||
// operator without showing what is typed, and is answered with what was typed **sealed to that key**: no
|
||||
// plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
|
||||
// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the
|
||||
// value was taken, or why not.
|
||||
//
|
||||
// **Bounded, and the prompt says who asked** (ADR 0277): one open prompt per secret and few an hour, read from
|
||||
// the store before the prompt opens (inventory.OpenSecretAsk), so an agent cannot keep a prompt in front of the
|
||||
// operator until they type. The prompt names the module, the secret, the machine and who asked — the caller as
|
||||
// the bus named it, never a word the caller chose — written by the desk's launcher from those names alone.
|
||||
//
|
||||
// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's
|
||||
// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a
|
||||
// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could
|
||||
@@ -61,37 +54,6 @@ type deskGive struct {
|
||||
// announce raises the condition that says a module's own secret was given (secretGivenObservation), on
|
||||
// every channel; nil announces nothing (a test that does not look).
|
||||
announce func(node, module, name, how string) error
|
||||
// askedBy is who asked, as the bus named the caller: said in the prompt and recorded.
|
||||
askedBy string
|
||||
// open records the ask and holds the bounds (one open per secret, few an hour), answering the record's id;
|
||||
// nil keeps no record (a test that does not look). end closes it with how it ended.
|
||||
open func(node, module, name, desk string) (int64, error)
|
||||
end func(id int64, outcome string) error
|
||||
}
|
||||
|
||||
// askedByName is the caller as the prompt names it: the first clause of what the bus said, in the characters
|
||||
// a name has, at most 80 of them. The desk's launcher refuses anything else, so no words of the caller's own
|
||||
// reach the prompt.
|
||||
func askedByName(caller string) string {
|
||||
first, _, _ := strings.Cut(caller, ",")
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(first) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@',
|
||||
r == '-', r == ' ':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
if b.Len() >= 80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(b.String())
|
||||
if name == "" || strings.HasPrefix(name, "-") {
|
||||
return "an unnamed caller"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes.
|
||||
@@ -133,37 +95,20 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
|
||||
}
|
||||
}
|
||||
// The bounds, read and kept before anybody is asked to type (novox/hq ADR 0277): one open prompt per secret,
|
||||
// few an hour. How the ask ends is recorded whatever happens below.
|
||||
outcome := "failed"
|
||||
if d.open != nil {
|
||||
id, err := d.open(node, module, name, desk)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if d.end != nil {
|
||||
_ = d.end(id, outcome)
|
||||
}
|
||||
}()
|
||||
}
|
||||
public, private, err := secrets.Keypair()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
||||
}
|
||||
// By name, never by words: the holder writes the prompt from these, and says the controller asks, which
|
||||
// the bus alone makes true (broker.ControllerOnly). Who asked is the bus's word on the caller, cut to a
|
||||
// name's characters — never an argument of the call.
|
||||
// the bus alone makes true (broker.ControllerOnly).
|
||||
raw, err := d.ask(desk, map[string]any{
|
||||
"module": module,
|
||||
"secret": name,
|
||||
"node": node,
|
||||
"asked_by": askedByName(d.askedBy),
|
||||
"seal_to": public,
|
||||
"timeout_seconds": deskPromptWithin,
|
||||
})
|
||||
if err != nil {
|
||||
outcome = "refused"
|
||||
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
|
||||
}
|
||||
var answer struct {
|
||||
@@ -176,10 +121,8 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
}
|
||||
switch {
|
||||
case answer.TimedOut:
|
||||
outcome = "timed-out"
|
||||
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
|
||||
case answer.Cancelled:
|
||||
outcome = "dismissed"
|
||||
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
|
||||
case answer.Sealed == "":
|
||||
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
|
||||
@@ -194,7 +137,6 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
opened[i] = 0
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
outcome = "empty"
|
||||
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
|
||||
}
|
||||
untilStart, err := d.accept(value)
|
||||
@@ -202,10 +144,8 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
outcome = "given"
|
||||
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
|
||||
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s", name, module, node, desk,
|
||||
askedByName(d.askedBy)),
|
||||
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s", name, module, node, desk),
|
||||
Cause: "given-at-the-desk"}
|
||||
recorded := ""
|
||||
if err := d.record(act); err != nil {
|
||||
@@ -225,23 +165,15 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
return words + recorded, nil
|
||||
}
|
||||
|
||||
// askAtDesk is `secret ask <node> <module> <name> [--at <machine>]`, and the terminal's `secret accept … --at-desk
|
||||
// <machine>`: the desk path, on this controller's stores and bus. The desk is the module's machine unless named.
|
||||
func askAtDesk(ctx context.Context, node, module, name, desk string) error {
|
||||
if desk == "" {
|
||||
desk = node
|
||||
}
|
||||
// giveAtDesk is `secret accept <node> <module> <name> --at-desk <machine>`: the desk path, on this
|
||||
// controller's stores and bus.
|
||||
func giveAtDesk(ctx context.Context, node, module, name, desk string) error {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
d := deskGive{
|
||||
askedBy: link.Caller(),
|
||||
open: func(node, module, name, desk string) (int64, error) {
|
||||
return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk)
|
||||
},
|
||||
end: func(id int64, outcome string) error { return open.inventory.EndSecretAsk(ctx, id, outcome) },
|
||||
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
|
||||
known: func(machine string) error {
|
||||
_, err := open.inventory.NodeByName(ctx, machine)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -125,21 +124,12 @@ func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) {
|
||||
|
||||
func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) {
|
||||
argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
|
||||
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" {
|
||||
if err != nil || strings.Join(argv, " ") != "secret accept anchor telegram telegram-token --at-desk laptop" {
|
||||
t.Fatalf("%v %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil {
|
||||
t.Error("give without a desk was taken")
|
||||
}
|
||||
// secret-ask is the same line, with the desk the module's machine unless named (novox/hq ADR 0277).
|
||||
argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"})
|
||||
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token" {
|
||||
t.Fatalf("%v %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
|
||||
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" {
|
||||
t.Fatalf("%v %v", argv, err)
|
||||
}
|
||||
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -276,22 +266,13 @@ func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The `give` and `secret-ask` verbs' own line passes the terminal-only rule of ADR 0266, and no `secret accept`
|
||||
// does: a value, a file, a provider or an extra word is still the terminal's alone (novox/hq ADR 0277).
|
||||
// The `give` verb's own line passes the terminal-only rule of ADR 0266, and no other `secret accept` does: a
|
||||
// value, a file, a provider or an extra word is still the terminal's alone.
|
||||
func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) {
|
||||
for _, argv := range [][]string{
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop"},
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token"},
|
||||
} {
|
||||
if err := terminalOnly(argv); err != nil {
|
||||
t.Errorf("%v refused: %v", argv, err)
|
||||
}
|
||||
if err := terminalOnly([]string{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"}); err != nil {
|
||||
t.Errorf("give's line refused: %v", err)
|
||||
}
|
||||
for _, argv := range [][]string{
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"},
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop", "--local"},
|
||||
{"secret", "ask", "anchor", "telegram", "--at", "laptop"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop", "--local"},
|
||||
@@ -417,96 +398,3 @@ func TestAGiveNamingAMachineTheMeshDoesNotKnowAsksNobody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0277: the prompt names who asked — the controller's word on the bus's caller, cut to a name's
|
||||
// characters — and never a word the caller chose: there is no argument for it.
|
||||
func TestThePromptNamesWhoAskedFromTheBussWordAlone(t *testing.T) {
|
||||
d, _, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||
d.askedBy = "g14/claude-code, through the mesh-controller seat"
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (*asked)[0]["asked_by"]; got != "g14/claude-code" {
|
||||
t.Errorf("asked_by %q", got)
|
||||
}
|
||||
if len(*acts) != 1 || !strings.Contains((*acts)[0].Why, "asked by g14/claude-code") {
|
||||
t.Errorf("the record: %+v", *acts)
|
||||
}
|
||||
for in, want := range map[string]string{
|
||||
"jochen at a shell on novox": "jochen at a shell on novox",
|
||||
"laptop/agent": "laptop/agent",
|
||||
"Your bank asks\nType your PIN, now": "Your bank asks-Type your PIN",
|
||||
"": "an unnamed caller",
|
||||
"<b>x</b>": "an unnamed caller",
|
||||
strings.Repeat("a", 100): strings.Repeat("a", 80),
|
||||
"--prompt, something else": "an unnamed caller",
|
||||
} {
|
||||
if got := askedByName(in); got != want {
|
||||
t.Errorf("askedByName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// The verb's schema has no argument that reaches the prompt's words.
|
||||
for _, verb := range []string{"give", "secret-ask"} {
|
||||
for _, free := range []string{"asked_by", "prompt", "message", "value", "from"} {
|
||||
if _, err := argvFor(verb, map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token",
|
||||
"at": "laptop", free: "x"}); err == nil {
|
||||
t.Errorf("%s takes %s", verb, free)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An ask for a secret is bounded before anybody is asked to type (ADR 0277): the record refuses it, nothing is
|
||||
// asked; and how every ask ends is recorded.
|
||||
func TestASecretAskIsBoundedAndItsEndRecorded(t *testing.T) {
|
||||
d, accepted, _, asked := aDesk(t, sealedTo(t, typed))
|
||||
var ended []string
|
||||
d.open = func(node, module, name, desk string) (int64, error) { return 7, nil }
|
||||
d.end = func(id int64, outcome string) error {
|
||||
ended = append(ended, fmt.Sprintf("%d %s", id, outcome))
|
||||
return nil
|
||||
}
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.open = func(node, module, name, desk string) (int64, error) {
|
||||
return 0, errors.New("an ask for telegram-token of telegram on anchor is still open")
|
||||
}
|
||||
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "nobody was asked to type anything") || !strings.Contains(err.Error(), "still open") {
|
||||
t.Errorf("a second ask: %v", err)
|
||||
}
|
||||
if len(*asked) != 1 || len(*accepted) != 1 {
|
||||
t.Errorf("asked %d, accepted %d", len(*asked), len(*accepted))
|
||||
}
|
||||
d.open = func(node, module, name, desk string) (int64, error) { return 8, nil }
|
||||
d.ask = func(string, map[string]any) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"cancelled":true}`), nil
|
||||
}
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); !errors.Is(err, errNothingGiven) {
|
||||
t.Errorf("a dismissed prompt: %v", err)
|
||||
}
|
||||
if strings.Join(ended, "; ") != "7 given; 8 dismissed" {
|
||||
t.Errorf("ended: %v", ended)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret ask cannot be turned into a secret read: the line carries no value, the answer carries none, and every
|
||||
// other `secret` line is the terminal's.
|
||||
func TestASecretAskIsNeverASecretRead(t *testing.T) {
|
||||
t.Setenv(verbVar, "mesh-controller.secret-ask")
|
||||
for _, args := range [][]string{
|
||||
{"ask", "anchor", "telegram", "telegram-token", "the-value"},
|
||||
{"ask", "anchor", "telegram"},
|
||||
{"ask", "anchor", "telegram", "telegram-token", "--from", "/dev/null"},
|
||||
} {
|
||||
if err := secretCommand(context.Background(), args); err == nil {
|
||||
t.Errorf("secret %v was taken", args)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"recover", "anchor", "telegram", "telegram-token"}, {"export"}} {
|
||||
if err := terminalOnly(append([]string{"secret"}, args...)); err == nil {
|
||||
t.Errorf("secret %v passed the terminal rule", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +116,6 @@ var probeRegistry = []probe{
|
||||
{ID: probeDeliveriesID, Asserts: "no delivery is held past its state's bound unsaid: mesh-delivery's " +
|
||||
"`stalled`, each with the transition its table lets healer H2 take", From: "ADR 0239",
|
||||
Kind: kindDeliveryStalled, Phase: 3, run: probeDeliveries},
|
||||
// The delivery budgets (novox/hq ADR 0282 decision 7): the newest delivery of each class within its budget,
|
||||
// read from mesh-delivery's `times`; a measurement said, never a delivery held.
|
||||
{ID: probeBudgetsID, Asserts: "the newest delivery of a leaf module ran on every machine within five minutes of " +
|
||||
"its merge, and of a core module within ten: mesh-delivery's `times`", From: "ADR 0282",
|
||||
Kind: kindOverBudget, Phase: 3, run: probeBudgets},
|
||||
// A client of the bus reconnecting in a loop (novox/hq issue 327), from the server's record of closed
|
||||
// connections, which the bus's own module reads.
|
||||
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
|
||||
|
||||
@@ -603,7 +603,6 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
pastBound := now.Sub(*g.Since) > gateBound
|
||||
switch {
|
||||
case worst == healthBroken:
|
||||
g.Read(now, false, g.BrokenWhy)
|
||||
var judging []string
|
||||
for _, m := range modules {
|
||||
if reading[m] != healthBroken && !passedAlone(m) {
|
||||
@@ -622,10 +621,8 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
// Waiting on a provider that is unhealthy: not a pass, and not a failure at the bound either —
|
||||
// the provider's own condition says what is wrong (ADR 0240 rule 5).
|
||||
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
|
||||
g.Read(now, false, why)
|
||||
case worst == healthNotYet:
|
||||
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
|
||||
g.Read(now, false, why)
|
||||
if pastBound {
|
||||
fail(fmt.Sprintf("not healthy within %s of its apply: %s", gateBound, why))
|
||||
}
|
||||
@@ -633,7 +630,6 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
// Healthy, or waiting for a person (ADR 0254): a pass, the wait carried along in the verdict.
|
||||
g.Passes++
|
||||
g.LastPass, g.Last, g.Failing = &now, "", nil
|
||||
g.Read(now, true, "")
|
||||
if g.Passes >= gatePasses && settled {
|
||||
decide(g, inventory.GatePassed, fmt.Sprintf("healthy %d times over %s", g.Passes,
|
||||
now.Sub(*g.Since).Round(time.Second))+waitsSaid(g.Waits), now)
|
||||
|
||||
@@ -311,7 +311,7 @@ func usage() {
|
||||
the self-check: the last verdict, a run now, the probes, the signals' ages
|
||||
healers [--days N] [--json] the healers, what they did lately, and their brake (to-be 45 §7)
|
||||
durations [--kind K] [--days N] [--json]
|
||||
apply, heartbeat, plan-tier, build and walk-phase durations
|
||||
apply, heartbeat, plan-tier and build durations, per machine or module
|
||||
collection [--json] kept archives held/unheld by a manifest, and what the sweep may let go
|
||||
builder issue <name> a broker account for a build machine, scoped to build work,
|
||||
delivered as the builder module's broker secret (module add it first)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A delivery over its budget is loud (novox/hq ADR 0282 decision 7, issue 382): the self-check reads
|
||||
// mesh-delivery's `times` and raises the warning `delivery.<class>.over-budget` when the newest delivery of a
|
||||
// class whose delivery time is known took longer than its class's budget — five minutes for a leaf module, ten
|
||||
// for a core module — naming the delivery and its longest phase. It clears when the next delivery of that class
|
||||
// lands within its budget. Measurement only: the condition says, it never holds or acts on a delivery.
|
||||
|
||||
// probeBudgetsID is the probe that reads the delivery times.
|
||||
const probeBudgetsID = "D16"
|
||||
|
||||
// kindOverBudget is what a delivery over its class's budget raises.
|
||||
const kindOverBudget = "over-budget"
|
||||
|
||||
// deliveryBudgets are the budgets by class (ADR 0282 decision 1); the probe raises nothing for another class.
|
||||
var deliveryBudgets = map[string]time.Duration{inventory.ClassLeaf: 5 * time.Minute, inventory.ClassCore: 10 * time.Minute}
|
||||
|
||||
// timesAnswer is what mesh-delivery's `times` answers, as far as the probe reads it.
|
||||
type timesAnswer struct {
|
||||
Classes []timesClass `json:"classes"`
|
||||
}
|
||||
|
||||
// timesClass is one class's line of `times`.
|
||||
type timesClass struct {
|
||||
Class string `json:"class"`
|
||||
Latest *timesLatest `json:"latest,omitempty"`
|
||||
}
|
||||
|
||||
// timesLatest is the newest delivery of a class whose delivery time is known.
|
||||
type timesLatest struct {
|
||||
ID string `json:"id"`
|
||||
TookMS int64 `json:"took_ms"`
|
||||
Longest string `json:"longest,omitempty"`
|
||||
Landed string `json:"landed,omitempty"`
|
||||
}
|
||||
|
||||
// deliveryTimes is what the delivery's owner says of its delivery times; nothing when no holder is on record or
|
||||
// none answers (D3 says that one).
|
||||
func deliveryTimes(ctx context.Context, conn *nats.Conn, held bool) (*timesAnswer, error) {
|
||||
if !held {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := askDeliveryOwner(ctx, conn, "times", map[string]any{})
|
||||
if errors.Is(err, link.ErrNothingServes) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a timesAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("%s.times answered something unreadable: %w", catalogue.DeliverySeat, err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// overBudgetObservations are the conditions of the classes whose newest delivery took longer than its budget:
|
||||
// strictly longer, so a delivery of exactly its budget is within it.
|
||||
func overBudgetObservations(a *timesAnswer) []conditions.Observation {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
var out []conditions.Observation
|
||||
for _, c := range a.Classes {
|
||||
budget, ok := deliveryBudgets[c.Class]
|
||||
if !ok || c.Latest == nil {
|
||||
continue
|
||||
}
|
||||
took := time.Duration(c.Latest.TookMS) * time.Millisecond
|
||||
if took <= budget {
|
||||
continue
|
||||
}
|
||||
longest := c.Latest.Longest
|
||||
if longest == "" {
|
||||
longest = "not known"
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeDelivery, ID: c.Class, Kind: kindOverBudget,
|
||||
Severity: conditions.Warning,
|
||||
Summary: fmt.Sprintf("the %s delivery %s took %s from its merge to running everywhere, over its budget of %s; "+
|
||||
"its longest phase: %s — `mesh-delivery.times`", c.Class, c.Latest.ID, humanDuration(took),
|
||||
humanDuration(budget), longest),
|
||||
Said: fmt.Sprintf("%s took %s (budget %s), longest phase %s", c.Latest.ID, took.Round(time.Second), budget, longest),
|
||||
Headline: fmt.Sprintf("A %s delivery took %s, over its %s budget", c.Class, humanDuration(took),
|
||||
humanDuration(budget)),
|
||||
Explanation: fmt.Sprintf("The delivery %s took %s from its merge until every machine ran it; a %s module is "+
|
||||
"held to %s (ADR 0282). Most of the time went to %s. Nothing was held or changed because of this: it is "+
|
||||
"a measurement.", c.Latest.ID, humanDuration(took), c.Class, humanDuration(budget), longest),
|
||||
Resolved: fmt.Sprintf("the next %s delivery lands within %s", c.Class, humanDuration(budget)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probeBudgets is D16: the newest delivery of each class lands within its class's budget.
|
||||
func probeBudgets(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
entries, err := d.open.inventory.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var conn *nats.Conn
|
||||
if d.js != nil {
|
||||
conn = d.js.Conn()
|
||||
}
|
||||
a, err := deliveryTimes(ctx, conn, deliverySeatHeld(entries))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return overBudgetObservations(a), nil
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A leaf delivery of 5 minutes 10 seconds raises delivery.leaf.over-budget naming its longest phase; one of
|
||||
// exactly five minutes, a core one of nine and a class without a budget raise nothing (ADR 0282 decision 7).
|
||||
func TestADeliveryOverItsBudgetIsLoud(t *testing.T) {
|
||||
a := ×Answer{Classes: []timesClass{
|
||||
{Class: inventory.ClassLeaf, Latest: ×Latest{ID: "novox/mesh-catalog@abc", TookMS: (5*time.Minute + 10*time.Second).Milliseconds(),
|
||||
Longest: "judgement 2m40s"}},
|
||||
{Class: inventory.ClassCore, Latest: ×Latest{ID: "novox/mesh-controller@def", TookMS: (9 * time.Minute).Milliseconds(),
|
||||
Longest: "build 1m"}},
|
||||
{Class: "unclassed", Latest: ×Latest{ID: "x@y", TookMS: time.Hour.Milliseconds()}},
|
||||
}}
|
||||
obs := overBudgetObservations(a)
|
||||
if len(obs) != 1 {
|
||||
t.Fatalf("one class over its budget, got %d: %+v", len(obs), obs)
|
||||
}
|
||||
o := obs[0]
|
||||
if o.Key() != "delivery.leaf.over-budget" || !strings.Contains(o.Summary, "judgement 2m40s") ||
|
||||
!strings.Contains(o.Summary, "novox/mesh-catalog@abc") {
|
||||
t.Fatalf("the condition names its delivery and longest phase: %s — %s", o.Key(), o.Summary)
|
||||
}
|
||||
// The next leaf delivery within its budget clears it: the probe raises nothing for the class.
|
||||
a.Classes[0].Latest = ×Latest{ID: "novox/mesh-catalog@ghi", TookMS: (5 * time.Minute).Milliseconds()}
|
||||
if obs := overBudgetObservations(a); len(obs) != 0 {
|
||||
t.Fatalf("a delivery of exactly its budget is within it: %+v", obs)
|
||||
}
|
||||
a.Classes[1].Latest.TookMS = (10*time.Minute + time.Second).Milliseconds()
|
||||
if obs := overBudgetObservations(a); len(obs) != 1 || obs[0].Key() != "delivery.core.over-budget" {
|
||||
t.Fatalf("a core delivery over ten minutes: %+v", obs)
|
||||
}
|
||||
if obs := overBudgetObservations(nil); obs != nil {
|
||||
t.Fatal("no answer raises nothing")
|
||||
}
|
||||
// No delivery of a class with a known time yet: nothing said of it.
|
||||
if obs := overBudgetObservations(×Answer{Classes: []timesClass{{Class: inventory.ClassLeaf}}}); len(obs) != 0 {
|
||||
t.Fatalf("a class with no delivery: %+v", obs)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe's question is one the controller's grant names: a question the bus refuses checks nothing.
|
||||
func TestTheControllerMayAskForTheDeliveryTimes(t *testing.T) {
|
||||
found := false
|
||||
for _, v := range broker.VerbsTheControllerAsksTheDeliveryOwner {
|
||||
found = found || (v.Seat == catalogue.DeliverySeat && v.Verb == "times")
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the grant does not name mesh-delivery.times")
|
||||
}
|
||||
}
|
||||
|
||||
// A walk's class is core when it walks a module of the controller's own path or one holding the mesh's resolver,
|
||||
// leaf otherwise; its window closed at its batch's window, or its maximum, never after its cut.
|
||||
func TestAWalksClassAndWindowAreReadAtItsCut(t *testing.T) {
|
||||
entries := []inventory.Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "dnsmasq", Claims: []catalogue.Claim{{Name: resolverSeat}}}},
|
||||
{Manifest: catalogue.Manifest{Module: "gitea"}},
|
||||
}
|
||||
walk := func(modules ...string) inventory.Plan {
|
||||
p := inventory.Plan{Modules: map[string]*inventory.PlanModule{}}
|
||||
for _, m := range modules {
|
||||
p.Modules[m] = &inventory.PlanModule{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
for want, w := range map[string]inventory.Plan{
|
||||
inventory.ClassLeaf: walk("gitea"), inventory.ClassCore: walk("gitea", "mesh-controller"),
|
||||
} {
|
||||
if got := classOf(w, entries); got != want {
|
||||
t.Errorf("%v: %s, want %s", w.Modules, got, want)
|
||||
}
|
||||
}
|
||||
if got := classOf(walk("dnsmasq"), entries); got != inventory.ClassCore {
|
||||
t.Errorf("the resolver's holder is core: %s", got)
|
||||
}
|
||||
now := time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC)
|
||||
batch := inventory.Plan{Delivery: &inventory.PlanDelivery{Batch: &inventory.PlanBatch{
|
||||
ClosesAt: now.Add(-20 * time.Second), AtMost: now.Add(5 * time.Minute)}}}
|
||||
times := walkTimesAtCut(batch, walk("gitea"), entries, now)
|
||||
if times.Cut == nil || !times.Cut.Equal(now) || times.WindowClosed == nil || !times.WindowClosed.Equal(now.Add(-20*time.Second)) ||
|
||||
times.Class != inventory.ClassLeaf {
|
||||
t.Fatalf("times at the cut: %+v", times)
|
||||
}
|
||||
batch.Delivery.Batch.AtMost = now.Add(-time.Minute)
|
||||
if times := walkTimesAtCut(batch, walk("gitea"), entries, now); !times.WindowClosed.Equal(now.Add(-time.Minute)) {
|
||||
t.Fatalf("a window closed at its maximum: %v", times.WindowClosed)
|
||||
}
|
||||
if times := walkTimesAtCut(inventory.Plan{Delivery: &inventory.PlanDelivery{Alone: true}}, walk("gitea"), entries, now); times.WindowClosed != nil {
|
||||
t.Fatalf("a merge walked alone had no window: %v", times.WindowClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// What each machine of the rest was sent is kept only for the machines the send reached.
|
||||
func TestTheRestIsKeptForTheMachinesTheSendReached(t *testing.T) {
|
||||
got := restOf([]string{"ace", "g14"}, []string{"ace", "shanks"}, map[string]inventory.SentDeclaration{"ace": {Digest: "d1"}})
|
||||
if len(got) != 1 || got["ace"].Digest != "d1" {
|
||||
t.Fatalf("rest: %+v", got)
|
||||
}
|
||||
if restOf([]string{"g14"}, []string{"ace"}, nil) != nil {
|
||||
t.Fatal("no machine reached: nothing kept")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ package main
|
||||
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
|
||||
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
|
||||
// waiting for an answer that cannot come.
|
||||
//
|
||||
// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's
|
||||
// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must
|
||||
// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only
|
||||
// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. The message is the
|
||||
// headline, one line per key, who proposed it and when; the fingerprint and how to read the proposal whole are
|
||||
// the Details answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove nothing.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -154,39 +161,39 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = verb + " settings?"
|
||||
}
|
||||
shown, whole := p.change(machines)
|
||||
var b strings.Builder
|
||||
// The message (issue 383): the change, one line per key, then who proposed it and when — the headline says
|
||||
// what is set where, and the router adds what every ask says. The fingerprint and the how-to are Details'.
|
||||
compose := func(change string) string {
|
||||
var b strings.Builder
|
||||
if p.Clear && !p.HadLayer {
|
||||
b.WriteString("There is no layer to remove; approving changes nothing.\n")
|
||||
} else {
|
||||
b.WriteString(change + "\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Proposed by %s at %s.", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
|
||||
return b.String()
|
||||
}
|
||||
shown, shownWhole := p.change(machines, false)
|
||||
whole, _ := p.change(machines, true)
|
||||
var d strings.Builder
|
||||
fmt.Fprintf(&d, "Fingerprint %s.\n", fingerprint(p.Digest))
|
||||
if !shownWhole {
|
||||
d.WriteString("On a channel that does not prove who answers, these values are shown as ‹address›, ‹path› or ‹withheld›.\n")
|
||||
}
|
||||
fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or "+
|
||||
"mesh-controller.settings with proposal %s through the mesh MCP server.\n", id, id)
|
||||
if p.Clear {
|
||||
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where())
|
||||
d.WriteString("Approved, the layer is removed at once and the machine takes it at its next push.")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where())
|
||||
d.WriteString("Approved, the layer is set at once and the machine takes it at its next push.")
|
||||
}
|
||||
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
|
||||
switch {
|
||||
case p.Clear && p.HadLayer:
|
||||
b.WriteString("The layer it removes:\n\n")
|
||||
case p.Clear:
|
||||
b.WriteString("There is no layer to remove; approving changes nothing.")
|
||||
case !p.HadLayer:
|
||||
b.WriteString("There is no layer yet; this is the whole of it:\n\n")
|
||||
default:
|
||||
b.WriteString("The layer is replaced whole; what changes against it:\n\n")
|
||||
}
|
||||
b.WriteString(shown)
|
||||
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest))
|
||||
if !whole {
|
||||
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
|
||||
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
|
||||
"mesh-cli settings proposals " + id + ".")
|
||||
}
|
||||
if p.Clear {
|
||||
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
|
||||
} else {
|
||||
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
|
||||
}
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator,
|
||||
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
About: p.about()}
|
||||
About: p.about(), Details: d.String()}
|
||||
if whole != shown {
|
||||
// Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere.
|
||||
q.Whole = compose(whole)
|
||||
}
|
||||
options := map[string]int{}
|
||||
for i, act := range p.actions(id) {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
@@ -208,13 +215,17 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
||||
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`.
|
||||
const shownMost = 1400
|
||||
|
||||
// change is what changes, line by line, each value shown under the content rule, and whether every value was
|
||||
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
|
||||
// count of keys unchanged.
|
||||
func (p settingsProposal) change(machines []string) (string, bool) {
|
||||
// change is what changes, line by line, and whether every value was shown whole: "+ key: value" added,
|
||||
// "~ key: value (was: old)" changed, "- key (was: old)" removed, and the count of keys unchanged. Each value is
|
||||
// shown under the content rule (sayableValue), or — exact, for a channel that proves who answers — as it is,
|
||||
// withheld only when shaped like a secret (wholeValue).
|
||||
func (p settingsProposal) change(machines []string, exact bool) (string, bool) {
|
||||
whole := true
|
||||
say := func(v any) string {
|
||||
s, w := sayableValue(v, machines)
|
||||
if exact {
|
||||
s, w = wholeValue(v, machines)
|
||||
}
|
||||
whole = whole && w
|
||||
return s
|
||||
}
|
||||
@@ -242,6 +253,8 @@ func (p settingsProposal) change(machines []string) (string, bool) {
|
||||
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
|
||||
case unchanged > 0:
|
||||
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
|
||||
case len(lines) == 0:
|
||||
lines = append(lines, "= the layer has no keys")
|
||||
}
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
@@ -300,6 +313,27 @@ func sayableValue(v any, machines []string) (string, bool) {
|
||||
return out, out == text
|
||||
}
|
||||
|
||||
// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was
|
||||
// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in
|
||||
// part, so no half of a key reaches the phone.
|
||||
func wholeValue(v any, machines []string) (string, bool) {
|
||||
text, ok := v.(string)
|
||||
if !ok {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return markWithheld, false
|
||||
}
|
||||
text = string(raw)
|
||||
}
|
||||
if text == "" {
|
||||
return `""`, true
|
||||
}
|
||||
if _, ok := outward.Secret(text, machines...); !ok {
|
||||
return markWithheld, false
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
|
||||
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
|
||||
// pass is withheld whole.
|
||||
func sayable(text string, machines []string) string {
|
||||
@@ -432,6 +466,12 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
|
||||
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
|
||||
"shown without it; %s", refusal, atTheTerminalInstead(in))
|
||||
}
|
||||
// The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds
|
||||
// one, and the router would refuse the ask if one were left, so it is refused here first, in words.
|
||||
if refusal, ok := outward.Secret(q.Whole, machines...); !ok {
|
||||
return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s",
|
||||
refusal, atTheTerminalInstead(in))
|
||||
}
|
||||
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
|
||||
if pr.routerHere != nil {
|
||||
here, err := pr.routerHere(ctx)
|
||||
|
||||
@@ -116,24 +116,36 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
||||
if q.Options[0].Binds == q.Options[1].Binds {
|
||||
t.Error("Approve and Decline bind the same act")
|
||||
}
|
||||
for _, line := range []string{
|
||||
"Set the settings of mounts on shanks to these values?",
|
||||
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".",
|
||||
"+ sources: recalbox=‹address›@‹path›:ro",
|
||||
"~ shares: library=‹path› (was: none)",
|
||||
"- old (was: x)",
|
||||
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||||
"read it whole, with this fingerprint",
|
||||
} {
|
||||
if !strings.Contains(q.Explanation, line) {
|
||||
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
|
||||
}
|
||||
// The message's shape (issue 383): one line per key, then who proposed it and when — and nothing else: the
|
||||
// fingerprint and the how-to are the Details answer's.
|
||||
proposedBy := "Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") + "."
|
||||
if q.Explanation != "+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)\n"+proposedBy {
|
||||
t.Errorf("the explanation:\n%s", q.Explanation)
|
||||
}
|
||||
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
||||
if strings.Contains(q.Explanation, leak) {
|
||||
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
||||
}
|
||||
}
|
||||
// Where the sender is proven, the values whole: the mount point and the share are what is approved.
|
||||
if q.Whole != "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)\n"+proposedBy {
|
||||
t.Errorf("the whole words:\n%s", q.Whole)
|
||||
}
|
||||
for _, line := range []string{
|
||||
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||||
"On a channel that does not prove who answers, these values are shown as ‹address›, ‹path› or ‹withheld›.",
|
||||
"Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal",
|
||||
"Approved, the layer is set at once and the machine takes it at its next push.",
|
||||
} {
|
||||
if !strings.Contains(q.Details, line) {
|
||||
t.Errorf("Details lack %q:\n%s", line, q.Details)
|
||||
}
|
||||
}
|
||||
for _, howTo := range []string{"ingerprint", "settings proposals", "mesh-controller.settings", "does not prove", "Approved,"} {
|
||||
if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) {
|
||||
t.Errorf("the message carries the how-to %q", howTo)
|
||||
}
|
||||
}
|
||||
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
all = append(all, o.Label, o.Does)
|
||||
@@ -141,6 +153,9 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
||||
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
||||
t.Errorf("the router would refuse the ask: %s", refusal)
|
||||
}
|
||||
if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok {
|
||||
t.Errorf("the router would refuse the whole words: %s", refusal)
|
||||
}
|
||||
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
||||
kept := r.theProposal(t)
|
||||
p := kept.Proposal
|
||||
@@ -173,11 +188,84 @@ func TestAMeshWideLayerIsProposed(t *testing.T) {
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
||||
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") {
|
||||
!strings.HasPrefix(q.Explanation, "+ x: 1\nProposed by ") || q.Whole != "" {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
// kept is the one proposal the rig keeps.
|
||||
func kept(r *proposerRig) asked {
|
||||
for _, a := range r.store {
|
||||
if a.Proposal != nil {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return asked{}
|
||||
}
|
||||
|
||||
// The switch between the masked and the whole change (issue 383): the same change, under the content rule and
|
||||
// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its
|
||||
// key still named; and a change no value of which is masked carries no whole words of its own.
|
||||
func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox",
|
||||
"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----", "font": "Inter 13"}
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
masked, _ := kept(r).Proposal.change([]string{"anchor", "laptop", "shanks"}, false)
|
||||
whole, _ := kept(r).Proposal.change([]string{"anchor", "laptop", "shanks"}, true)
|
||||
if !strings.Contains(q.Explanation, masked) || !strings.Contains(q.Whole, whole) {
|
||||
t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole)
|
||||
}
|
||||
for _, line := range []string{"+ cert: ‹withheld›", "+ font: Inter 13", "+ github: ‹withheld›", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"} {
|
||||
if !strings.Contains(masked, line) {
|
||||
t.Errorf("masked, lacks %q:\n%s", line, masked)
|
||||
}
|
||||
}
|
||||
for _, line := range []string{"+ cert: ‹withheld›", "+ font: Inter 13", "+ github: ‹withheld›", "+ shares: media=/storage/media",
|
||||
"+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"} {
|
||||
if !strings.Contains(whole, line) {
|
||||
t.Errorf("whole, lacks %q:\n%s", line, whole)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} {
|
||||
if strings.Contains(q.Explanation, secret) || strings.Contains(q.Whole, secret) || strings.Contains(q.Details, secret) {
|
||||
t.Errorf("the secret %q reaches the phone", secret)
|
||||
}
|
||||
}
|
||||
if _, ok := outward.Secret(q.Whole, "shanks"); !ok {
|
||||
t.Error("the router would refuse the whole words")
|
||||
}
|
||||
// Mutation: the masked and the whole change differ in exactly the lines whose value was masked.
|
||||
m, w := strings.Split(masked, "\n"), strings.Split(whole, "\n")
|
||||
if len(m) != len(w) {
|
||||
t.Fatalf("masked %d lines, whole %d", len(m), len(w))
|
||||
}
|
||||
differ := 0
|
||||
for i := range m {
|
||||
if m[i] != w[i] {
|
||||
differ++
|
||||
if !strings.Contains(m[i], "‹") || strings.Contains(w[i], "‹") {
|
||||
t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", m[i], w[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if differ != 2 {
|
||||
t.Errorf("%d lines differ, and the mask covered 2", differ)
|
||||
}
|
||||
// No value masked: the message is the same everywhere, and the ask says no whole words.
|
||||
r2 := newProposerRig(t)
|
||||
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasPrefix(q2.Explanation, "+ font-size: 13\n+ width: 500\nProposed by ") ||
|
||||
strings.Contains(q2.Details, "does not prove who answers") {
|
||||
t.Errorf("%+v", q2)
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
||||
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
@@ -205,7 +293,8 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
||||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") {
|
||||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") ||
|
||||
!strings.Contains(q.Details, "Approved, the layer is removed at once") {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
||||
|
||||
@@ -446,47 +446,6 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
|
||||
advanceHeld(ctx, open)
|
||||
}
|
||||
|
||||
// startedBeside is the id of a started walk open beside this one — a merge's walk past its wait, not the
|
||||
// backlog's — or empty: one walk at a time (novox/hq ADR 0276).
|
||||
func startedBeside(ctx context.Context, inv *inventory.Inventory, id string, created time.Time) (string, error) {
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, q := range plans {
|
||||
if q.ID == id || q.Release != nil || q.Waiting() {
|
||||
continue
|
||||
}
|
||||
// Started: a tier asked, or on its way (let go, or waiting for nobody) before this one.
|
||||
if q.Tier > 0 || askedAny(q) || q.Created.Before(created) {
|
||||
return q.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// deferredNote begins the note of a walk deferred behind another.
|
||||
const deferredNote = "let go; starts once "
|
||||
|
||||
// deferred says a walk has its word and has not started: let go while another walk was started, it waits for
|
||||
// that one to end (startedBeside), and its note says so. Read as a wait, not as a tier running late: `plans`
|
||||
// and `status` say its note, and S3 leaves it out. A let-go walk whose first ask failed carries that error as
|
||||
// its note instead, and is watched as before.
|
||||
func deferred(p inventory.Plan) bool {
|
||||
return p.Open() && p.Release == nil && p.Tier == 0 && !askedAny(p) && p.Delivery != nil &&
|
||||
p.Delivery.Awaits != "" && p.Delivery.Go != nil && strings.HasPrefix(p.Note, deferredNote)
|
||||
}
|
||||
|
||||
// askedAny says a plan asked any module.
|
||||
func askedAny(p inventory.Plan) bool {
|
||||
for _, s := range p.Modules {
|
||||
if s != nil && s.State != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
|
||||
// and whose gates are applied gives way to the next; the last tier done is the plan done. Called
|
||||
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
|
||||
@@ -510,14 +469,6 @@ func advancePlans(ctx context.Context, open *stores) {
|
||||
advanceHeld(ctx, open)
|
||||
}
|
||||
|
||||
// keepWalkPhases keeps where the walks that ended lately spent their time (novox/hq ADR 0282), outside the hold
|
||||
// on the plans so it never lengthens it: measured, never acted on, and an error only said.
|
||||
func keepWalkPhases(ctx context.Context, open *stores) {
|
||||
if err := recordWalkPhases(ctx, open.inventory, time.Now()); err != nil {
|
||||
fmt.Printf("plans: the phases of the walks ended lately could not be kept: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// advanceHeld is advancePlans for a caller already holding the plans.
|
||||
func advanceHeld(ctx context.Context, open *stores) {
|
||||
inv := open.inventory
|
||||
@@ -627,18 +578,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
}
|
||||
if unasked == len(tier) {
|
||||
// **One walk at a time** (novox/hq ADR 0276): a walk about to ask its first tier while another started
|
||||
// walk is open waits for that one to end, let go or not, and says so.
|
||||
if p.Tier == 0 {
|
||||
if behind, err := startedBeside(ctx, inv, p.ID, p.Created); err != nil {
|
||||
return false, err
|
||||
} else if behind != "" {
|
||||
note := fmt.Sprintf("%s%s ended — one walk at a time", deferredNote, behind)
|
||||
changed := p.Note != note
|
||||
p.Note = note
|
||||
return changed, nil
|
||||
}
|
||||
}
|
||||
if err := askTier(ctx, inv, p); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -872,12 +811,8 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
strings.Join(machines, ", "), p.Tier, err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
// What each machine of the rest was sent, kept for when it reports it applied (novox/hq ADR 0282 decision
|
||||
// 6): measured, never acted on.
|
||||
sentWhat := sentNow(ctx, open.inventory, sent)
|
||||
for _, m := range rest {
|
||||
p.Modules[m].SentAt = &now
|
||||
p.Modules[m].Rest = restOf(restTo[m], sent, sentWhat)
|
||||
}
|
||||
fmt.Printf("%s: tier %d built; sent %s to %s, one send each\n", p.ID, p.Tier, strings.Join(rest, ", "),
|
||||
strings.Join(sent, ", "))
|
||||
@@ -958,20 +893,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// restOf is, for one module, every machine of its rest that the send reached and what it carried there.
|
||||
func restOf(to, sent []string, what map[string]inventory.SentDeclaration) map[string]inventory.SentDeclaration {
|
||||
out := map[string]inventory.SentDeclaration{}
|
||||
for _, n := range to {
|
||||
if slices.Contains(sent, n) {
|
||||
out[n] = what[n]
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstSend sends one machine, in one send, every module of the plan's tier whose first machine it is
|
||||
// (novox/hq issue 281), and records the send on each: what that machine ran of it before — read once,
|
||||
// before the send, so a module the same send carries is never read as already moved — the machines it
|
||||
@@ -1219,7 +1140,6 @@ func sayUnsent(p *inventory.Plan, rollsOut func(string) bool) {
|
||||
// planTicker advances open plans on a timer, for the steps outcomes alone cannot take.
|
||||
func planTicker(ctx context.Context, open *stores) {
|
||||
advancePlans(ctx, open)
|
||||
keepWalkPhases(ctx, open)
|
||||
tick := time.NewTicker(30 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
@@ -1228,7 +1148,6 @@ func planTicker(ctx context.Context, open *stores) {
|
||||
return
|
||||
case <-tick.C:
|
||||
advancePlans(ctx, open)
|
||||
keepWalkPhases(ctx, open)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1262,38 +1181,27 @@ func inTierSince(p inventory.Plan) time.Time {
|
||||
// planLineWith is planLine knowing whether the build seat is paused (novox/hq ADR 0219): a plan
|
||||
// waiting on builds nobody will take until a person resumes the seat says so, and is not late. bound is
|
||||
// the plan's tier bound, the one its stalled condition is raised at (tierBounds): LATE is that condition
|
||||
// said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn.
|
||||
// said on the line (novox/hq issue 296).
|
||||
func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration) string {
|
||||
return planLineOn(p, now, pause, bound, nil)
|
||||
}
|
||||
|
||||
// planLineOn is planLineWith naming the plan by what it moves and where (planHeadline), given what runs each
|
||||
// module; nil names no machine.
|
||||
func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string) string {
|
||||
name := planHeadline(p, running)
|
||||
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
||||
switch p.State {
|
||||
case inventory.PlanAssembling, inventory.PlanQueued:
|
||||
// A batch not yet a walk (novox/hq ADR 0276): what it holds and how long is left.
|
||||
return batchWords(p, now)
|
||||
case inventory.PlanDone:
|
||||
return fmt.Sprintf("%s · done, %d tier(s)", name, len(p.Tiers))
|
||||
return fmt.Sprintf("%s done, %d tier(s)", p.Named(), len(p.Tiers))
|
||||
case inventory.PlanFailed:
|
||||
return fmt.Sprintf("%s · FAILED at %s: %s", name, where, p.Note)
|
||||
return fmt.Sprintf("%s FAILED at %s: %s", p.Named(), where, p.Note)
|
||||
case inventory.PlanSuperseded:
|
||||
return fmt.Sprintf("%s · %s", name, p.Note)
|
||||
return fmt.Sprintf("%s %s", p.Named(), p.Note)
|
||||
}
|
||||
since := now.Sub(inTierSince(p)).Round(time.Second)
|
||||
if p.Waiting() {
|
||||
// Waiting for its delivery's word is no lateness of the walk's (novox/hq ADR 0239).
|
||||
return fmt.Sprintf("%s · %s, %s, for %s", name, where, waitingNote(p), since)
|
||||
}
|
||||
if deferred(p) {
|
||||
// Nor is waiting for the walk before it to end (one walk at a time, novox/hq ADR 0276).
|
||||
return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, since)
|
||||
return fmt.Sprintf("%s %s, %s, for %s", p.Named(), where, waitingNote(p), since)
|
||||
}
|
||||
if waiting, paused := pausedWaiting(p, pause, now); paused {
|
||||
return fmt.Sprintf("%s · %s, %s", name, where, waiting)
|
||||
return fmt.Sprintf("%s %s, %s", p.Named(), where, waiting)
|
||||
}
|
||||
late := ""
|
||||
if since > bound {
|
||||
@@ -1303,53 +1211,7 @@ func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Dur
|
||||
if p.State == inventory.PlanRolling {
|
||||
what = p.Note
|
||||
}
|
||||
return fmt.Sprintf("%s · %s, %s for %s%s", name, where, what, since, late)
|
||||
}
|
||||
|
||||
// planHeadline names a plan as a person knows it (asked by the operator, 2026-10-10: a plan called by its
|
||||
// repository alone said nothing of what it delivers): each repository as its pull request and title, what the
|
||||
// plan moves, and the machines running that — "mesh-catalog #175 a tap shows its outcome · messenger,
|
||||
// telegram → novox". A record naming no pull request is named by its commit, as before; one naming no moves
|
||||
// says none; running nil names no machine.
|
||||
func planHeadline(p inventory.Plan, running func(string) []string) string {
|
||||
var repos []string
|
||||
moves := map[string]bool{}
|
||||
for _, c := range p.Carried() {
|
||||
seg := repoName(c.Repository) + " " + short(c.Commit)
|
||||
if p.Delivery != nil {
|
||||
for _, m := range p.Delivery.Merges {
|
||||
if !strings.EqualFold(m.Repository, c.Repository) {
|
||||
continue
|
||||
}
|
||||
for _, n := range m.Moves {
|
||||
moves[n] = true
|
||||
}
|
||||
if m.Commit == c.Commit && m.Number > 0 {
|
||||
seg = repoName(c.Repository) + " " + pullWords(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
repos = append(repos, seg)
|
||||
}
|
||||
out := strings.Join(repos, " + ")
|
||||
if len(moves) == 0 {
|
||||
return out
|
||||
}
|
||||
names := sortedKeysOf(boolsToStrings(moves))
|
||||
out += " · " + strings.Join(names, ", ")
|
||||
if running == nil {
|
||||
return out
|
||||
}
|
||||
nodes := map[string]bool{}
|
||||
for _, n := range names {
|
||||
for _, node := range running(n) {
|
||||
nodes[node] = true
|
||||
}
|
||||
}
|
||||
if len(nodes) > 0 {
|
||||
out += " → " + strings.Join(sortedKeysOf(boolsToStrings(nodes)), ", ")
|
||||
}
|
||||
return out
|
||||
return fmt.Sprintf("%s %s, %s for %s%s", p.Named(), where, what, since, late)
|
||||
}
|
||||
|
||||
// planFailedBuild marks the module a failed build was for when the result names no module: by the
|
||||
@@ -1491,11 +1353,8 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
fmt.Printf("%s — %s\n", p.ID, planLineOn(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||
bounds.of(p.Repository), func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}))
|
||||
fmt.Printf("%s — %s\n", p.ID, planLineWith(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||
bounds.of(p.Repository)))
|
||||
if r := p.Release; r != nil {
|
||||
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
||||
fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "),
|
||||
@@ -1631,22 +1490,14 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
for _, b := range batches {
|
||||
fmt.Printf("%-28s %s\n", b.ID, batchWords(b, now))
|
||||
// One line per repository: its pull request, what it answers, what it moves.
|
||||
for _, line := range batchLines(b) {
|
||||
fmt.Printf("%-28s %s\n", "", line)
|
||||
}
|
||||
}
|
||||
pause := buildSeatPause(ctx, inv, plans)
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
running := func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.Batch() {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-28s %s\n", p.ID, planLineOn(p, now, pause, bounds.of(p.Repository), running))
|
||||
fmt.Printf("%-28s %s\n", p.ID, planLineWith(p, now, pause, bounds.of(p.Repository)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -768,22 +768,10 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
}
|
||||
return append(argv, "--json"), nil
|
||||
case "give":
|
||||
// The same line as secret-ask with the desk named (novox/hq ADR 0277): one path, one set of bounds.
|
||||
if err := need("node", "module", "secret", "at"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"secret", "ask", str("node"), str("module"), str("secret"), "--at", str("at")}, nil
|
||||
case "secret-ask":
|
||||
// A module's own secret asked for, typed by the operator at the desk (novox/hq ADR 0277): never a value
|
||||
// in the arguments. The desk is the module's machine unless at names another.
|
||||
if err := need("node", "module", "secret"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := []string{"secret", "ask", str("node"), str("module"), str("secret")}
|
||||
if at := str("at"); at != "" {
|
||||
argv = append(argv, "--at", at)
|
||||
}
|
||||
return argv, nil
|
||||
return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil
|
||||
case "rotate":
|
||||
if p := str("provision"); p != "" {
|
||||
argv := []string{"rotate", p}
|
||||
@@ -1366,7 +1354,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
var words []string
|
||||
var cur strings.Builder
|
||||
inWord := false
|
||||
quote, opened := rune(0), 0
|
||||
quote := rune(0)
|
||||
runes := []rune(line)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
@@ -1387,7 +1375,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
case r == '\'' || r == '"':
|
||||
quote, opened = r, i
|
||||
quote = r
|
||||
inWord = true
|
||||
case r == '\\' && i+1 < len(runes):
|
||||
i++
|
||||
@@ -1405,7 +1393,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
}
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, unclosedQuote(quote, opened)
|
||||
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
||||
}
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
@@ -1413,17 +1401,6 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
return words, nil
|
||||
}
|
||||
|
||||
// unclosedQuote is the refusal of a line whose quote is never closed. Most often an apostrophe inside
|
||||
// a single-quoted value ended that quote early and a later quote was left open, so the refusal says
|
||||
// where the open quote is and how a quote is written inside a quoted value — the shell's own two
|
||||
// ways, which this splitter already reads (novox/hq issue 294). It quotes none of the line: a refusal
|
||||
// is kept on the bus as the call's answer, and the line may carry a setting's value.
|
||||
func unclosedQuote(quote rune, opened int) error {
|
||||
return fmt.Errorf("command has an unclosed %c quote, opened at character %d — an apostrophe "+
|
||||
"inside a single-quoted value ends it; write a ' inside single quotes as '\\'' (it'\\''s), or use "+
|
||||
"double quotes and write \\\" for a \" and \\\\ for a \\ inside them", quote, opened+1)
|
||||
}
|
||||
|
||||
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
|
||||
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
|
||||
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
|
||||
@@ -1561,11 +1538,10 @@ var terminalOnlyCommands = map[string]string{
|
||||
"licence": "the licences' secrets",
|
||||
}
|
||||
|
||||
// givenAtTheDesk is exactly the line the `give` and `secret-ask` verbs compose, and nothing beside it: `secret ask
|
||||
// <node> <module> <secret>`, with `--at <machine>` or no other word — no value, no file, no provider (novox/hq
|
||||
// ADR 0277). The terminal's own `secret accept … --at-desk` is the terminal's.
|
||||
// givenAtTheDesk is exactly the line the `give` verb composes, and nothing beside it: `secret accept <node>
|
||||
// <module> <secret> --at-desk <machine>`, with no other word — no value, no file, no provider.
|
||||
func givenAtTheDesk(argv []string) bool {
|
||||
if (len(argv) != 5 && len(argv) != 7) || argv[0] != "secret" || argv[1] != "ask" {
|
||||
if len(argv) != 7 || argv[0] != "secret" || argv[1] != "accept" || argv[5] != "--at-desk" {
|
||||
return false
|
||||
}
|
||||
for _, w := range argv[2:5] {
|
||||
@@ -1573,10 +1549,7 @@ func givenAtTheDesk(argv []string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(argv) == 5 {
|
||||
return true
|
||||
}
|
||||
return argv[5] == "--at" && argv[6] != "" && !strings.HasPrefix(argv[6], "-")
|
||||
return argv[6] != "" && !strings.HasPrefix(argv[6], "-")
|
||||
}
|
||||
|
||||
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||
|
||||
@@ -276,6 +276,12 @@ var accountedFlags = map[string]map[string]string{
|
||||
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
||||
},
|
||||
// The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call.
|
||||
"secret accept": {
|
||||
"at-desk": "=at",
|
||||
"from": "withheld: a file of the control node's is read at a shell, never named by a call",
|
||||
"provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret",
|
||||
"local": "withheld: it goes with --provider",
|
||||
},
|
||||
"hand-acts": {"json": "set by the verb: the answer is data"},
|
||||
"conditions": {"json": "set by the verb: the answer is data"},
|
||||
"retire": {"json": "set by the verb: the answer is data"},
|
||||
|
||||
@@ -415,48 +415,3 @@ func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) {
|
||||
t.Fatalf("behind %v: %v", behind, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value with a quote in it can be said on a `command` line, as in a shell, and a line whose quote
|
||||
// is left open is refused naming where it opened and how a quote is written, quoting none of it (novox/hq issue 294: an
|
||||
// apostrophe inside a single-quoted JSON value cut the line, and the refusal named no cause).
|
||||
func TestAQuotedValueCanHoldAQuote(t *testing.T) {
|
||||
for _, c := range []struct{ line, want string }{
|
||||
{`settings set claude-code '{"role":"the operator'\''s laptop"}'`, `{"role":"the operator's laptop"}`},
|
||||
{`settings set claude-code "{\"role\":\"the operator's laptop\"}"`, `{"role":"the operator's laptop"}`},
|
||||
{"x \"a \\\\ b\nc\"", "a \\ b\nc"},
|
||||
{`x 'a\b'`, `a\b`},
|
||||
} {
|
||||
argv, err := splitCommandLine(c.line)
|
||||
if err != nil || argv[len(argv)-1] != c.want {
|
||||
t.Errorf("%s: read back %q %v, want %q", c.line, argv, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := splitCommandLine(`settings set claude-code '{"role":"the operator's laptop"}' --node g14`)
|
||||
if err == nil {
|
||||
t.Fatal("an apostrophe that leaves a quote open was accepted")
|
||||
}
|
||||
for _, want := range []string{"character 57", `'\''`, `\"`} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "laptop") || strings.Contains(err.Error(), "g14") {
|
||||
t.Errorf("the refusal quotes the line, which may carry a setting's value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every value reads back as it was when written the way the refusal says: single-quoted with '\”
|
||||
// for each quote, or double-quoted with \ before each " and \ — newlines and backslashes included.
|
||||
func TestAQuotedValueRoundTrips(t *testing.T) {
|
||||
for _, v := range []string{`plain`, `it's`, `say "hi"`, `back\slash\`, "two\nlines", `'"\'\"`, `''`, ``} {
|
||||
single := "x '" + strings.ReplaceAll(v, "'", `'\''`) + "'"
|
||||
double := `x "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + `"`
|
||||
for _, line := range []string{single, double} {
|
||||
argv, err := splitCommandLine(line)
|
||||
if err != nil || len(argv) != 2 || argv[1] != v {
|
||||
t.Errorf("%s: read back %q %v, want %q", line, argv, err, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,6 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
switch args[0] {
|
||||
case "accept":
|
||||
case "ask":
|
||||
return secretAsk(ctx, args[1:])
|
||||
case "rotate":
|
||||
return secretRotate(ctx, args[1:])
|
||||
case "recover":
|
||||
@@ -80,7 +78,7 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
if *from != "" || *provider != "" {
|
||||
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
|
||||
}
|
||||
return askAtDesk(ctx, node, module, name, *desk)
|
||||
return giveAtDesk(ctx, node, module, name, *desk)
|
||||
}
|
||||
|
||||
value, err := valueFor(node, module, name, *from)
|
||||
@@ -150,24 +148,7 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// secretAsk is `secret ask <node> <module> <name> [--at <machine>]` (novox/hq ADR 0277): the operator is asked
|
||||
// for a module's own secret in a prompt at the desk, which only they answer. The one `secret` line a verb may
|
||||
// run beside rotate (givenAtTheDesk): it carries no value and answers none.
|
||||
func secretAsk(ctx context.Context, args []string) error {
|
||||
rest, flags := split(args)
|
||||
set := flag.NewFlagSet("secret ask", flag.ContinueOnError)
|
||||
at := set.String("at", "", "the machine the operator sits at, where the prompt opens; the module's machine when absent")
|
||||
if err := set.Parse(flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rest) != 3 {
|
||||
return errors.New("secret ask <node> <module> <name> [--at <machine>]")
|
||||
}
|
||||
return askAtDesk(ctx, rest[0], rest[1], rest[2], *at)
|
||||
}
|
||||
|
||||
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
|
||||
"secret ask <node> <module> <name> [--at <machine>]\n" +
|
||||
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
|
||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||
"secret export [--out <file>]"
|
||||
|
||||
@@ -490,11 +490,6 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b
|
||||
awaits: p.Delivery.Awaits, since: p.Created, modules: planModules(p), merges: p.Delivery.Merges})
|
||||
continue
|
||||
}
|
||||
// A walk let go and waiting for the walk before it to end (ADR 0276) is no tier late either: the walk
|
||||
// before it is the one S3 watches.
|
||||
if deferred(p) {
|
||||
continue
|
||||
}
|
||||
_, paused := pausedWaiting(p, pause, now)
|
||||
bound := bounds.of(p.Repository)
|
||||
out = append(out, planFacts{id: p.ID, repository: p.Repository, commit: p.Commit, tier: p.Tier,
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812 h1:pzVzwF5VMWaTECxu8+Pd1dNoOHNEm7upC5wPadQTkBw=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812/go.mod h1:K3/xEzVgmrNKLMV2vv4M80MwmPnQNXqvQ4C5Jj0fJT4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -232,11 +232,8 @@ func MaySubscribe(perms Permissions, subject string) bool {
|
||||
//
|
||||
// And, since novox/hq ADR 0259, `release` and `stop`: the controller asks the operator for them about a
|
||||
// delivery held past its bound, and calls them on the operator's warrant, with its why.
|
||||
//
|
||||
// And, since novox/hq ADR 0282, `times`: the self-check reads the delivery times to say a delivery over its budget.
|
||||
var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", Verb: "stalled"},
|
||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
|
||||
{Seat: "mesh-delivery", Verb: "times"}}
|
||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"}}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
|
||||
@@ -18,15 +18,6 @@ func deliveryVerbs() []Verb {
|
||||
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
||||
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
||||
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
||||
// Delivery time against the delivery budgets (novox/hq ADR 0282): what probe D16 reads, optional until its
|
||||
// holder serves it.
|
||||
{Name: "times", Description: "Delivery time: from a merge to every machine of its walk running the build. " +
|
||||
"Each class's delivery budget (a leaf module five minutes, a core module ten), the last days' median and " +
|
||||
"worst, how many within and over, the newest delivery of each class, every delivery over its budget with its " +
|
||||
"longest phase, and the delivered ones whose time is not known. Given a delivery's id, its time phase by phase.",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 7)", "id": "one delivery's id: its phases"}, nil),
|
||||
// Optional until mesh-delivery serves it: its holder lives in the catalogue (design 33 §7).
|
||||
Optional: true},
|
||||
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
||||
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
||||
"steps of its walk, its group and its order.",
|
||||
|
||||
+10
-27
@@ -164,9 +164,8 @@ var ControllerVerbs = []Verb{
|
||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery"},
|
||||
[]string{"plan", "why"})},
|
||||
{Name: "delivery-walks", Description: "The walks the controller keeps (novox/hq ADR 0239): every open one and the " +
|
||||
"last ended ones, each whole — its tiers, each module's state, first machines and first-node gate with its readings, and its " +
|
||||
"phases from its merge to every machine running it (novox/hq ADR 0282) — and whether the delivery seat has a " +
|
||||
"holder on record. Given a plan, that one.",
|
||||
"last ended ones, each whole — its tiers, each module's state, first machines and gate — and whether the " +
|
||||
"delivery seat has a holder on record. Given a plan, that one.",
|
||||
Input: schema(map[string]string{"plan": "one walk's id", "limit": "how many ended walks beside the open ones (default 50)"},
|
||||
nil)},
|
||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
|
||||
@@ -232,32 +231,17 @@ var ControllerVerbs = []Verb{
|
||||
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
||||
}, nil)},
|
||||
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
|
||||
"§10): the same as secret-ask with the desk named. A prompt that does not show what is typed opens on " +
|
||||
"the machine named by at, its answer comes back sealed to this call alone, and is sealed to the " +
|
||||
"module's machine as `secret accept` seals it. The value is never an argument and never in the answer: " +
|
||||
"the answer says it was taken, or why not. Recorded in the hand-act log as a value given at the desk. " +
|
||||
"The prompt waits 25 seconds; dismissed or unanswered, nothing changes. Then push the machine.",
|
||||
"§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " +
|
||||
"back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " +
|
||||
"The value is never an argument and never in the answer: the answer says it was taken, or why not. " +
|
||||
"Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " +
|
||||
"or unanswered, nothing changes. Then push the machine.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine the module runs on, which the secret is sealed to",
|
||||
"module": "the module's name",
|
||||
"secret": "the own secret's name in the module's definition",
|
||||
"at": "the machine the operator sits at, where the prompt opens",
|
||||
}, []string{"node", "module", "secret", "at"})},
|
||||
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
|
||||
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
|
||||
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
|
||||
"and never in a verb's argument; the answer comes back sealed to this call alone and is sealed to the " +
|
||||
"module's machine as `secret accept` seals it. The answer says the value was taken, or why not. " +
|
||||
"Refused for a secret the mesh issues itself (the bus account, one the mesh may make) and for a module " +
|
||||
"that runs as an account of its own, whose value is typed at the controller's terminal. Bounded: one " +
|
||||
"open prompt per secret, three asks an hour. Recorded in the hand-act log, with who asked, and " +
|
||||
"announced on every channel. Then push the machine.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine the module runs on, which the secret is sealed to",
|
||||
"module": "the module's name",
|
||||
"secret": "the own secret's name in the module's definition",
|
||||
"at": "the machine the operator sits at, where the prompt opens; the module's machine when absent",
|
||||
}, []string{"node", "module", "secret"})},
|
||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
||||
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
||||
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
||||
@@ -376,11 +360,10 @@ var ControllerVerbs = []Verb{
|
||||
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
|
||||
{Name: "durations", Description: "How long things take, as the controller measured them: a send to its machine's " +
|
||||
"report (apply), a machine's silence between words (heartbeat-gap), a plan's tier, a build, and each phase of an " +
|
||||
"ended walk per class (walk-phase, novox/hq ADR 0282) — per machine, repository, module or class/phase, with " +
|
||||
"median, p90 and max. What the core's bounds are set from (novox/hq to-be 45 Phase 0).",
|
||||
"report (apply), a machine's silence between words (heartbeat-gap), a plan's tier, a build — per machine, " +
|
||||
"repository or module, with median, p90 and max. What the core's bounds are set from (novox/hq to-be 45 Phase 0).",
|
||||
Input: schema(map[string]string{
|
||||
"kind": "one kind: apply, heartbeat-gap, plan-tier, build or walk-phase; every kind when absent",
|
||||
"kind": "one kind: apply, heartbeat-gap, plan-tier or build; every kind when absent",
|
||||
"days": "how many days back (default 14)",
|
||||
}, nil)},
|
||||
// What is wrong, and the self-check (novox/hq to-be 45 §2, §4).
|
||||
|
||||
@@ -94,9 +94,7 @@ type DataChange struct {
|
||||
}
|
||||
|
||||
// readingEvery is how often a measurement is kept as a reading: the shrink is read over days, and a
|
||||
// row every five minutes would say the same thing sixty times an hour. A reading keeps the item's path
|
||||
// as it stands after the measurement — the holder's, or the last one known when it named none — and an
|
||||
// item at a path with no recent reading is read at once (novox/hq issue 368).
|
||||
// row every five minutes would say the same thing sixty times an hour.
|
||||
const readingEvery = 55 * time.Minute
|
||||
|
||||
// readingsKept is how long readings are kept.
|
||||
@@ -189,14 +187,10 @@ func (i *Inventory) RecordData(ctx context.Context, machine string, declared []D
|
||||
}
|
||||
if m.Size != nil && m.MeasuredAt != nil && Comparable(m.Precision) {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into data_reading (machine, module, item, at, size_bytes, last_write, path)
|
||||
select $1, $2, $3, $4, $5, $6, d.path
|
||||
from data_item d
|
||||
where d.machine = $1 and d.module = $2 and d.item = $3
|
||||
and not exists (select 1 from data_reading
|
||||
where machine = $1 and module = $2 and item = $3 and path = d.path
|
||||
and at > $4::timestamptz - $7::interval)
|
||||
on conflict (machine, module, item, at) do nothing`,
|
||||
insert into data_reading (machine, module, item, at, size_bytes, last_write)
|
||||
select $1, $2, $3, $4, $5, $6
|
||||
where not exists (select 1 from data_reading
|
||||
where machine = $1 and module = $2 and item = $3 and at > $4::timestamptz - $7::interval)`,
|
||||
machine, d.Module, d.Item, *m.MeasuredAt, *m.Size, m.LastWrite,
|
||||
fmt.Sprintf("%d seconds", int(readingEvery.Seconds()))); err != nil {
|
||||
return change, err
|
||||
@@ -287,14 +281,10 @@ func (i *Inventory) DataOf(ctx context.Context, machine, module, item string) (D
|
||||
return r, err
|
||||
}
|
||||
|
||||
// DataPeaks is each item's largest reading since a moment, keyed by DataRecord.Key — read only from
|
||||
// readings at the item's path now (novox/hq issue 368): a directory is never compared with another one
|
||||
// that once held the same item, so a moved item starts its size history again at its new path.
|
||||
// DataPeaks is each item's largest reading since a moment, keyed by DataRecord.Key.
|
||||
func (i *Inventory) DataPeaks(ctx context.Context, since time.Time) (map[string]int64, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select r.machine, r.module, r.item, max(r.size_bytes)
|
||||
from data_reading r
|
||||
join data_item d on d.machine = r.machine and d.module = r.module and d.item = r.item and d.path = r.path
|
||||
where r.at >= $1 group by r.machine, r.module, r.item`, since)
|
||||
rows, err := i.store.Pool().Query(ctx, `select machine, module, item, max(size_bytes) from data_reading
|
||||
where at >= $1 group by machine, module, item`, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -128,66 +128,3 @@ func TestAPartialMeasurementIsNeverAReading(t *testing.T) {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A shrink is read against what the same directory held (novox/hq issue 368): an item whose path moved
|
||||
// — an agent's home moved to its own account — starts its size history again at the new path, and a
|
||||
// genuine shrink at the new path is still read against what that path held.
|
||||
func TestThePeakIsReadAtTheItemsPathOnly(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
now := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||
declared := []DeclaredData{{Module: "claude-code", Item: "agent-home", Class: "valuable", Owned: true}}
|
||||
measure := func(when time.Time, path string, s int64) {
|
||||
t.Helper()
|
||||
if _, err := inv.RecordData(ctx, "novox", declared, map[string]map[string]Measurement{"claude-code": {
|
||||
"agent-home": {Path: path, Size: size(s), MeasuredAt: at(when)}}}, "", when); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
measure(now, "/home/operator/.claude", 94_700_000)
|
||||
// The path moves ten minutes later: the new directory is measured at once, not an hour on.
|
||||
measure(now.Add(10*time.Minute), "/home/agent/.claude", 490)
|
||||
peaks, err := inv.DataPeaks(ctx, now.Add(-time.Hour))
|
||||
if err != nil || peaks["novox/claude-code/agent-home"] != 490 {
|
||||
t.Fatalf("after the path moved the peak is %v (%v), want 490: the old directory's size is no shrink "+
|
||||
"of the new one", peaks, err)
|
||||
}
|
||||
// The new directory grows, then genuinely loses most of it: that is read against the new path's peak.
|
||||
measure(now.Add(2*time.Hour), "/home/agent/.claude", 80_000_000)
|
||||
measure(now.Add(4*time.Hour), "/home/agent/.claude", 1_000)
|
||||
peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour))
|
||||
if err != nil || peaks["novox/claude-code/agent-home"] != 80_000_000 {
|
||||
t.Fatalf("a shrink at the new path is read against %v (%v), want 80000000", peaks, err)
|
||||
}
|
||||
// A measurement that names no path is the item's last known path's, not a new history.
|
||||
measure(now.Add(6*time.Hour), "", 2_000)
|
||||
peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour))
|
||||
if err != nil || peaks["novox/claude-code/agent-home"] != 80_000_000 {
|
||||
t.Fatalf("a measurement with no path started a new history: peak %v (%v)", peaks, err)
|
||||
}
|
||||
// Moving back to a directory measured before reads it against what it held then.
|
||||
measure(now.Add(8*time.Hour), "/home/operator/.claude", 94_000_000)
|
||||
peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour))
|
||||
if err != nil || peaks["novox/claude-code/agent-home"] != 94_700_000 {
|
||||
t.Fatalf("back at the first path the peak is %v (%v), want 94700000", peaks, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two measurements at the same moment at two paths keep one reading and fail nothing: the second
|
||||
// would otherwise break the reading's key and lose the machine's whole record (issue 368 review).
|
||||
func TestTwoPathsAtOneMomentFailNothing(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
now := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||
declared := []DeclaredData{{Module: "claude-code", Item: "agent-home", Class: "valuable", Owned: true}}
|
||||
for _, path := range []string{"/home/operator/.claude", "/home/agent/.claude"} {
|
||||
if _, err := inv.RecordData(ctx, "novox", declared, map[string]map[string]Measurement{"claude-code": {
|
||||
"agent-home": {Path: path, Size: size(100), MeasuredAt: at(now)}}}, "", now); err != nil {
|
||||
t.Fatalf("a measurement at %s failed: %v", path, err)
|
||||
}
|
||||
}
|
||||
r, err := inv.DataOf(ctx, "novox", "claude-code", "agent-home")
|
||||
if err != nil || r.Path != "/home/agent/.claude" {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,10 @@ const (
|
||||
DurationHeartbeatGap = "heartbeat-gap"
|
||||
DurationPlanTier = "plan-tier"
|
||||
DurationBuild = "build"
|
||||
// DurationWalkPhase is one phase of an ended walk, per class (novox/hq ADR 0282 decision 6): subject
|
||||
// "<class>/<phase>", and "<class>/total" for its merge to every machine running it.
|
||||
DurationWalkPhase = "walk-phase"
|
||||
)
|
||||
|
||||
// DurationKinds are every kind, in the order `durations` shows them.
|
||||
var DurationKinds = []string{DurationApply, DurationHeartbeatGap, DurationPlanTier, DurationBuild, DurationWalkPhase}
|
||||
var DurationKinds = []string{DurationApply, DurationHeartbeatGap, DurationPlanTier, DurationBuild}
|
||||
|
||||
// DurationsKeptFor is how long a duration is kept: long enough to set a bound from, and to correct it
|
||||
// in Phase 1's first live week.
|
||||
@@ -103,14 +100,6 @@ func (i *Inventory) Durations(ctx context.Context, kind string, since time.Time)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WalkPhasesKept says whether any phase of a walk is kept as a walk-phase duration, and whether its total is.
|
||||
func (i *Inventory) WalkPhasesKept(ctx context.Context, plan string) (anyKept, totalKept bool, err error) {
|
||||
err = i.store.Pool().QueryRow(ctx,
|
||||
`select count(*) > 0, count(*) filter (where ref = $2) > 0 from duration where kind = $1 and ref like $3`,
|
||||
DurationWalkPhase, plan+"/total", plan+"/%").Scan(&anyKept, &totalKept)
|
||||
return anyKept, totalKept, err
|
||||
}
|
||||
|
||||
// ForgetOldDurations removes what is older than DurationsKeptFor, and says how many.
|
||||
func (i *Inventory) ForgetOldDurations(ctx context.Context) (int64, error) {
|
||||
tag, err := i.store.Pool().Exec(ctx, `delete from duration where recorded < $1`,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
-- A module's own secret asked for at a desk (novox/hq ADR 0277).
|
||||
--
|
||||
-- Every ask for a module's own secret at a desk: who asked, for which secret of which module on which
|
||||
-- machine, at which desk, and how it ended. Read before a prompt opens, so that one open ask per secret and
|
||||
-- few per hour hold: an agent that keeps a prompt in front of the operator until they type is refused.
|
||||
create table secret_ask (
|
||||
id bigserial primary key,
|
||||
node uuid not null references node(id) on delete cascade,
|
||||
module text not null,
|
||||
name text not null,
|
||||
asked_by text not null,
|
||||
desk text not null,
|
||||
opened_at timestamptz not null default now(),
|
||||
ended_at timestamptz,
|
||||
-- given · dismissed · timed-out · empty · refused · failed
|
||||
outcome text
|
||||
);
|
||||
create index secret_ask_by_secret on secret_ask (node, module, name, opened_at desc);
|
||||
@@ -1,21 +0,0 @@
|
||||
-- A reading says the path it was measured at (novox/hq issue 368).
|
||||
--
|
||||
-- A shrink is read against the largest reading of the last seven days. Readings were kept by machine,
|
||||
-- module and item only, so when an item's path moved — on 2026-10-10 an agent's home moved from the
|
||||
-- operator's own home to the agent account's (ADR 0266) — the new, fresh directory was read against
|
||||
-- the old one's size, and `data-shrank` was raised for data that was never lost. A reading now keeps
|
||||
-- its path, and the peak is read only from readings at the item's path now: a moved item starts its
|
||||
-- size history again, and moving back to a path reads it against what that path held.
|
||||
--
|
||||
-- **Every reading kept so far is taken to be at its item's path now.** Where it was really measured
|
||||
-- is not on record; taking the path now keeps every item's history, so a shrink that is real stays
|
||||
-- raised. An item whose path moved before this migration stays compared with its old directory until
|
||||
-- those readings leave the seven-day window. Readings of an item no longer kept keep no path, and are
|
||||
-- read for nothing.
|
||||
--
|
||||
-- Numbered 0092, past 0091, the highest on main or any open branch when this was written.
|
||||
alter table data_reading add column path text;
|
||||
|
||||
update data_reading r set path = d.path
|
||||
from data_item d
|
||||
where d.machine = r.machine and d.module = r.module and d.item = r.item;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- A walk keeps its phases (novox/hq ADR 0282 decision 6, issue 382).
|
||||
--
|
||||
-- A small fix took 45 minutes to reach a node on 2026-10-10, and where the time went was read back from the
|
||||
-- walks by hand: the walk kept when its modules were asked, built, sent first, judged and sent to the rest,
|
||||
-- but not when its batch's window closed or when it was cut, so the window and the wait behind another walk
|
||||
-- could not be told apart. A walk now keeps those moments and its class (core or leaf, read at its cut).
|
||||
-- Its readings, each module's send to the rest and the times of the merges it answers are kept in the plan's
|
||||
-- own records (modules, delivery). Null for a walk kept before this: its phases before the build are said
|
||||
-- unknown.
|
||||
alter table release_plan add column times jsonb;
|
||||
@@ -54,25 +54,6 @@ type Plan struct {
|
||||
// walk can carry several. Repository and Commit above keep one of them, for a reader that knows one. Empty
|
||||
// for a walk kept before it was: Repository and Commit are then the whole of it.
|
||||
Commits []PlanCommit `json:"commits,omitempty"`
|
||||
// Times are the moments of a walk no other field keeps (novox/hq ADR 0282 decision 6): when its batch's
|
||||
// window closed, when it was cut, and its class. Nil for a walk kept before they were, whose phases before
|
||||
// its build are said unknown.
|
||||
Times *PlanTimes `json:"times,omitempty"`
|
||||
// Phases is the walk's time from its merge, phase by phase (ADR 0282 decision 6): never kept, worked out
|
||||
// from the walk's own moments by whoever says the walk (`delivery walks`, `plan-moved`).
|
||||
Phases *WalkPhases `json:"phases,omitempty"`
|
||||
}
|
||||
|
||||
// PlanTimes are a walk's own moments beside its modules' (novox/hq ADR 0282 decision 6).
|
||||
type PlanTimes struct {
|
||||
// WindowClosed is when its batch's merge window closed: no merge for the window's length, or its maximum.
|
||||
// Nil for a walk no window assembled (one merge walked alone).
|
||||
WindowClosed *time.Time `json:"window_closed,omitempty"`
|
||||
// Cut is when the batch became the walk.
|
||||
Cut *time.Time `json:"cut,omitempty"`
|
||||
// Class is the walk's module class, read at its cut: core when it moves a module on the controller's own
|
||||
// path or the mesh's resolver, leaf otherwise (ADR 0282 decision 1).
|
||||
Class string `json:"class,omitempty"`
|
||||
}
|
||||
|
||||
// PlanCommit is one repository's commit a walk carries: the latest merge of its branch in the batch.
|
||||
@@ -90,15 +71,6 @@ type PlanMerge struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Carried string `json:"carried,omitempty"`
|
||||
// Number and Title are the merge's pull request as the forge announced it, and Moves the modules the merge
|
||||
// moved when it was heard: what `plans` names a walk by. Empty where the announcement said none.
|
||||
Number int `json:"number,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Moves []string `json:"moves,omitempty"`
|
||||
// Merged is when the forge made the merge and Heard when the controller heard it (novox/hq ADR 0282): where
|
||||
// its delivery time starts. Zero for a merge named before they were kept.
|
||||
Merged time.Time `json:"merged,omitzero"`
|
||||
Heard time.Time `json:"heard,omitzero"`
|
||||
}
|
||||
|
||||
// PlanBatch is a batch's window while it is one (novox/hq ADR 0276): when it closes unless another merge
|
||||
@@ -243,9 +215,6 @@ type PlanModule struct {
|
||||
// went there in one send (novox/hq issue 281), and one gate judges what one send moved. Empty for
|
||||
// the module the gate is kept on, and for a plan from before tiers were sent whole.
|
||||
GatedBy string `json:"gated_by,omitempty"`
|
||||
// Rest is, per machine of the rest, the declaration the send after the first-node gate carried there (novox/hq ADR
|
||||
// 0282 decision 6): the machine's first report of it, applied, is when the build runs there.
|
||||
Rest map[string]SentDeclaration `json:"rest,omitempty"`
|
||||
}
|
||||
|
||||
// PlanGate is one module's rollout record at its gate (to-be 45 §8): the component, the first machine,
|
||||
@@ -302,35 +271,6 @@ type PlanGate struct {
|
||||
BrokenWhy string `json:"broken_why,omitempty"`
|
||||
// Returned names the broken modules already put back, at once, while the rest of the send is judged.
|
||||
Returned []string `json:"returned,omitempty"`
|
||||
// Readings are the judging's readings with their times (novox/hq ADR 0282 decision 6): every reading that
|
||||
// counted a pass, and the first that did not after one that did. At most maxReadings, the newest kept.
|
||||
Readings []GateReading `json:"readings,omitempty"`
|
||||
}
|
||||
|
||||
// GateReading is one reading of a first-node gate.
|
||||
type GateReading struct {
|
||||
At time.Time `json:"at"`
|
||||
Healthy bool `json:"healthy"`
|
||||
// Said is what a reading that did not pass found wanting.
|
||||
Said string `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// maxReadings bounds a first-node gate's readings: a judging that never passes reads every few seconds for ten minutes.
|
||||
const maxReadings = 24
|
||||
|
||||
// Read keeps one reading: a pass always, and a reading that did not pass only when the one before passed or
|
||||
// there is none, so a judging waiting for a module to start keeps one line of it, not hundreds.
|
||||
func (g *PlanGate) Read(at time.Time, healthy bool, said string) {
|
||||
if !healthy && len(g.Readings) > 0 && !g.Readings[len(g.Readings)-1].Healthy {
|
||||
return
|
||||
}
|
||||
if r := []rune(said); len(r) > 200 {
|
||||
said = string(r[:200])
|
||||
}
|
||||
g.Readings = append(g.Readings, GateReading{At: at, Healthy: healthy, Said: said})
|
||||
if len(g.Readings) > maxReadings {
|
||||
g.Readings = g.Readings[len(g.Readings)-maxReadings:]
|
||||
}
|
||||
}
|
||||
|
||||
// CarriedMove is one module's build moving on a machine with a gated send.
|
||||
@@ -397,12 +337,7 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var release, delivery, commits, times []byte
|
||||
if p.Times != nil {
|
||||
if times, err = json.Marshal(p.Times); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var release, delivery, commits []byte
|
||||
if len(p.Commits) > 0 {
|
||||
if commits, err = json.Marshal(p.Commits); err != nil {
|
||||
return err
|
||||
@@ -433,18 +368,18 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
||||
var revision int64
|
||||
err = tx.QueryRow(ctx,
|
||||
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note,
|
||||
branch, tier_entered, revision, epoch, release, delivery, merged_at, commits, times)
|
||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14, $15, $16, $17, $18)
|
||||
branch, tier_entered, revision, epoch, release, delivery, merged_at, commits)
|
||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14, $15, $16, $17)
|
||||
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
||||
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch,
|
||||
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch,
|
||||
release = excluded.release, delivery = excluded.delivery, repository = excluded.repository,
|
||||
commit_hash = excluded.commit_hash, merged_at = excluded.merged_at, commits = excluded.commits,
|
||||
created = excluded.created, times = excluded.times
|
||||
created = excluded.created
|
||||
where release_plan.revision = $12
|
||||
returning revision`,
|
||||
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered,
|
||||
p.Revision, epoch, release, delivery, mergedAt(p.Merged), commits, times).Scan(&revision)
|
||||
p.Revision, epoch, release, delivery, mergedAt(p.Merged), commits).Scan(&revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// The row is there and at another revision — moved since this was read, or there already
|
||||
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
|
||||
@@ -527,7 +462,7 @@ func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
||||
func (i *Inventory) plans(ctx context.Context, tail string, args ...any) ([]Plan, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch,
|
||||
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release, delivery, merged_at, commits, times
|
||||
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release, delivery, merged_at, commits
|
||||
from release_plan `+tail, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -536,19 +471,14 @@ func (i *Inventory) plans(ctx context.Context, tail string, args ...any) ([]Plan
|
||||
var out []Plan
|
||||
for rows.Next() {
|
||||
var p Plan
|
||||
var tiers, modules, release, delivery, commits, times []byte
|
||||
var tiers, modules, release, delivery, commits []byte
|
||||
var epoch int64
|
||||
var merged *time.Time
|
||||
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
||||
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch, &release,
|
||||
&delivery, &merged, &commits, ×); err != nil {
|
||||
&delivery, &merged, &commits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(times) > 0 {
|
||||
if err := json.Unmarshal(times, &p.Times); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(commits) > 0 {
|
||||
if err := json.Unmarshal(commits, &p.Commits); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An ask for a module's own secret at a desk (novox/hq ADR 0277, migration 0091): every one is recorded
|
||||
// before the prompt opens, and the bounds are read from the record. A verb may ask the operator to type a
|
||||
// secret; it may not keep a prompt in front of them. **One open ask per secret, and few per hour.**
|
||||
|
||||
// The bounds of asking for one secret.
|
||||
const (
|
||||
// SecretAskOpenFor is how long an ask that has not ended counts as open: longer than any prompt waits,
|
||||
// so a process that died with its prompt does not hold the secret for ever.
|
||||
SecretAskOpenFor = 2 * time.Minute
|
||||
// SecretAsksPerHour is how many asks for one secret an hour takes.
|
||||
SecretAsksPerHour = 3
|
||||
)
|
||||
|
||||
// OpenSecretAsk records that an ask for a module's own secret on a machine opens at a desk, or refuses it in
|
||||
// words when one is still open for that secret or the hour's asks are spent. It answers the record's id, which
|
||||
// EndSecretAsk closes.
|
||||
func (i *Inventory) OpenSecretAsk(ctx context.Context, node, module, name, askedBy, desk string) (int64, error) {
|
||||
record, err := i.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
// Serialised per secret, so two asks at once do not both pass the count.
|
||||
if _, err := tx.Exec(ctx, `select pg_advisory_xact_lock(hashtext($1))`, node+"/"+module+"/"+name); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var open int
|
||||
var openBy string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select count(*), coalesce(min(asked_by), '') from secret_ask
|
||||
where node = $1 and module = $2 and name = $3 and ended_at is null and opened_at > now() - $4::interval`,
|
||||
record.ID, module, name, SecretAskOpenFor.String()).Scan(&open, &openBy); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if open > 0 {
|
||||
return 0, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a secret, "+
|
||||
"and this one ends within %s", name, module, node, openBy, SecretAskOpenFor)
|
||||
}
|
||||
var lastHour int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select count(*) from secret_ask
|
||||
where node = $1 and module = $2 and name = $3 and opened_at > now() - interval '1 hour'`,
|
||||
record.ID, module, name).Scan(&lastHour); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if lastHour >= SecretAsksPerHour {
|
||||
return 0, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks for one "+
|
||||
"secret: the operator is not kept at a prompt", name, module, node, lastHour, SecretAsksPerHour)
|
||||
}
|
||||
var id int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`insert into secret_ask (node, module, name, asked_by, desk) values ($1, $2, $3, $4, $5) returning id`,
|
||||
record.ID, module, name, askedBy, desk).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// EndSecretAsk closes an ask with how it ended: given, dismissed, timed-out, empty, refused or failed.
|
||||
func (i *Inventory) EndSecretAsk(ctx context.Context, id int64, outcome string) error {
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`update secret_ask set ended_at = now(), outcome = $2 where id = $1 and ended_at is null`, id, outcome)
|
||||
return err
|
||||
}
|
||||
|
||||
// SecretAsk is one recorded ask for a module's own secret.
|
||||
type SecretAsk struct {
|
||||
ID int64
|
||||
Node string
|
||||
Module string
|
||||
Name string
|
||||
AskedBy string
|
||||
Desk string
|
||||
OpenedAt time.Time
|
||||
EndedAt *time.Time
|
||||
Outcome string
|
||||
}
|
||||
|
||||
// SecretAsks is every ask for secrets since a moment, newest first.
|
||||
func (i *Inventory) SecretAsks(ctx context.Context, since time.Time) ([]SecretAsk, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select a.id, n.name, a.module, a.name, a.asked_by, a.desk, a.opened_at, a.ended_at, coalesce(a.outcome, '')
|
||||
from secret_ask a join node n on n.id = a.node
|
||||
where a.opened_at >= $1 order by a.opened_at desc`, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SecretAsk
|
||||
for rows.Next() {
|
||||
var a SecretAsk
|
||||
if err := rows.Scan(&a.ID, &a.Node, &a.Module, &a.Name, &a.AskedBy, &a.Desk, &a.OpenedAt, &a.EndedAt, &a.Outcome); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An ask for a module's own secret at a desk is bounded by the record (novox/hq ADR 0277): one open per secret,
|
||||
// three an hour, and every one says who asked and how it ended.
|
||||
func TestASecretAskIsOneAtATimeAndFewAnHour(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "shanks"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "laptop/agent", "shanks"); err == nil ||
|
||||
!strings.Contains(err.Error(), "still open") || !strings.Contains(err.Error(), "g14/claude-code") {
|
||||
t.Errorf("a second ask while one is open: %v", err)
|
||||
}
|
||||
// Another secret is its own.
|
||||
other, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "other", "laptop/agent", "shanks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.EndSecretAsk(ctx, other, "dismissed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.EndSecretAsk(ctx, first, "given"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < SecretAsksPerHour-1; i++ {
|
||||
id, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks")
|
||||
if err != nil {
|
||||
t.Fatalf("ask %d: %v", i+2, err)
|
||||
}
|
||||
if err := inv.EndSecretAsk(ctx, id, "timed-out"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks"); err == nil ||
|
||||
!strings.Contains(err.Error(), "times in the last hour") {
|
||||
t.Errorf("a fourth ask in an hour: %v", err)
|
||||
}
|
||||
if _, err := inv.OpenSecretAsk(ctx, "nowhere", "mounts", "smb-credentials", "x", "nowhere"); err == nil {
|
||||
t.Error("a machine the mesh does not know was taken")
|
||||
}
|
||||
asks, err := inv.SecretAsks(ctx, time.Now().Add(-time.Hour))
|
||||
if err != nil || len(asks) != SecretAsksPerHour+1 {
|
||||
t.Fatalf("%d asks, %v", len(asks), err)
|
||||
}
|
||||
if a := asks[len(asks)-1]; a.Node != "shanks" || a.Module != "mounts" || a.Name != "smb-credentials" || a.AskedBy != "g14/claude-code" ||
|
||||
a.Outcome != "given" || a.EndedAt == nil {
|
||||
t.Errorf("the first ask: %+v", a)
|
||||
}
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Where a walk's time went (novox/hq ADR 0282 decision 6, issue 382): from its merge to every machine of its
|
||||
// rest running its builds, phase by phase, worked out from the walk's own moments. Measurement only: nothing
|
||||
// here decides anything about the walk.
|
||||
//
|
||||
// **A phase that cannot be measured is said unknown, never zero.** Where a moment is missing (a walk kept
|
||||
// before it was recorded, a machine not yet reported), the phases around it are unknown, and the span between
|
||||
// the moments on either side is counted as unknown time: the measured phases and the unknown time always add
|
||||
// up to the total. A phase that did not happen (no window for a merge walked alone, no first machine for a
|
||||
// module nobody runs) is said none, with no time.
|
||||
|
||||
// The phases, in the order a walk passes them (ADR 0282's table).
|
||||
const (
|
||||
PhaseWindow = "window" // the merge to its batch's window closing
|
||||
PhaseQueued = "queued" // the window closed to the walk being cut: waiting behind another walk
|
||||
PhaseWord = "word" // the cut to the delivery's word, for a walk that waits for one
|
||||
PhaseBetween = "between-tiers" // one tier's end to the next tier's ask
|
||||
PhaseBuild = "build" // a tier asked to its last module built
|
||||
PhaseSend = "send-first" // built to sent to the first machines
|
||||
PhaseJudge = "judgement" // sent first to the first-node gate's last verdict: its readings
|
||||
PhaseRest = "send-rest" // judged to sent to the rest
|
||||
PhaseApply = "apply" // the last send to every machine of the rest reporting the build applied
|
||||
PhaseOpen = "open" // a walk still running: since its last moment
|
||||
PhaseMeasured = "measured"
|
||||
PhaseUnknown = "unknown"
|
||||
PhaseNone = "none"
|
||||
)
|
||||
|
||||
// The classes of a walk (ADR 0282 decision 1) and their delivery budgets.
|
||||
const (
|
||||
ClassCore = "core"
|
||||
ClassLeaf = "leaf"
|
||||
)
|
||||
|
||||
// ApplySilentAfter is how long after a send to the rest a machine that has said nothing is left out of the
|
||||
// walk's end, as a machine not heard from (ADR 0282 decision 1: a sleeping laptop is listed, not waited for).
|
||||
const ApplySilentAfter = 15 * time.Minute
|
||||
|
||||
// WalkPhases is a walk's time, phase by phase.
|
||||
type WalkPhases struct {
|
||||
Class string `json:"class,omitempty"`
|
||||
// From is the walk's earliest merge; End when its last phase ended: every machine of its rest reported the
|
||||
// build applied. Nil End while that is not known.
|
||||
From *time.Time `json:"from,omitempty"`
|
||||
End *time.Time `json:"end,omitempty"`
|
||||
// TotalMS is End − From; zero while either is unknown, Total its words.
|
||||
TotalMS int64 `json:"total_ms,omitempty"`
|
||||
Total string `json:"total,omitempty"`
|
||||
// UnknownMS is the time within the walk no phase could be measured over.
|
||||
UnknownMS int64 `json:"unknown_ms,omitempty"`
|
||||
Phases []WalkPhase `json:"phases"`
|
||||
Silent []string `json:"silent,omitempty"`
|
||||
Said string `json:"said"`
|
||||
Merges []MergeStart `json:"merges,omitempty"`
|
||||
}
|
||||
|
||||
// MergeStart is one merge the walk answers and when it was made: where that merge's delivery time starts.
|
||||
type MergeStart struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Merged time.Time `json:"merged"`
|
||||
}
|
||||
|
||||
// WalkPhase is one phase of a walk.
|
||||
type WalkPhase struct {
|
||||
Name string `json:"name"`
|
||||
// Tier is the tier a tier's phase belongs to; -1 for a phase of the walk.
|
||||
Tier int `json:"tier"`
|
||||
State string `json:"state"`
|
||||
Start *time.Time `json:"start,omitempty"`
|
||||
End *time.Time `json:"end,omitempty"`
|
||||
TookMS int64 `json:"took_ms,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
Said string `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// AppliedReport is a machine's first report, after a send, that it applied what it was sent.
|
||||
type AppliedReport struct {
|
||||
At time.Time
|
||||
Outcome string
|
||||
}
|
||||
|
||||
// AppliedLookup answers a machine's first report after a send to it; false when it has not reported.
|
||||
type AppliedLookup func(node string, sent time.Time) (AppliedReport, bool)
|
||||
|
||||
// point is one moment of the walk, ending the phase named.
|
||||
type point struct {
|
||||
phase string
|
||||
tier int
|
||||
at *time.Time
|
||||
none bool // the phase did not happen
|
||||
said string // why it is none, or unknown
|
||||
}
|
||||
|
||||
// Phases is the walk's time phase by phase, at now; applied answers the rest's reports (nil: none read).
|
||||
func (p Plan) WalkPhases(now time.Time, applied AppliedLookup) *WalkPhases {
|
||||
if p.Release != nil || p.Batch() {
|
||||
return nil
|
||||
}
|
||||
w := &WalkPhases{}
|
||||
if p.Times != nil {
|
||||
w.Class = p.Times.Class
|
||||
}
|
||||
from := p.firstMerge(w)
|
||||
if from != nil {
|
||||
f := from.Truncate(time.Millisecond)
|
||||
from = &f
|
||||
}
|
||||
if from == nil {
|
||||
w.Said = "when its merge was made is not kept: its delivery time is unknown"
|
||||
} else {
|
||||
w.From = from
|
||||
}
|
||||
points := p.points(now, applied, w)
|
||||
// Walk the moments: each known moment after a known one is a measured phase; a missing moment makes the
|
||||
// phases up to the next known one unknown, and their span unknown time.
|
||||
last := from
|
||||
var pending []int
|
||||
for _, pt := range points {
|
||||
if pt.none {
|
||||
w.Phases = append(w.Phases, WalkPhase{Name: pt.phase, Tier: pt.tier, State: PhaseNone, Said: pt.said})
|
||||
continue
|
||||
}
|
||||
ph := WalkPhase{Name: pt.phase, Tier: pt.tier, Said: pt.said}
|
||||
if pt.at == nil {
|
||||
ph.State = PhaseUnknown
|
||||
w.Phases = append(w.Phases, ph)
|
||||
pending = append(pending, len(w.Phases)-1)
|
||||
continue
|
||||
}
|
||||
at := pt.at.Truncate(time.Millisecond)
|
||||
ph.End = &at
|
||||
if last != nil && len(pending) == 0 {
|
||||
start := *last
|
||||
ph.State, ph.Start = PhaseMeasured, &start
|
||||
ph.TookMS = at.Sub(start).Milliseconds()
|
||||
ph.Took = words(at.Sub(start))
|
||||
} else {
|
||||
ph.State = PhaseUnknown
|
||||
if last != nil {
|
||||
w.UnknownMS += at.Sub(*last).Milliseconds()
|
||||
}
|
||||
}
|
||||
w.Phases = append(w.Phases, ph)
|
||||
pending = nil
|
||||
last = &at
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
// The walk's last moments are unknown: it has no end.
|
||||
if w.Said == "" {
|
||||
w.Said = "its end is unknown: " + w.Phases[pending[0]].Name + " " + orNot(w.Phases[pending[0]].Said)
|
||||
}
|
||||
return w
|
||||
}
|
||||
if last == nil || from == nil {
|
||||
return w
|
||||
}
|
||||
if p.Open() {
|
||||
since := *last
|
||||
w.Phases = append(w.Phases, WalkPhase{Name: PhaseOpen, Tier: -1, State: PhaseOpen, Start: &since,
|
||||
TookMS: now.Sub(since).Milliseconds(), Took: words(now.Sub(since)), Said: "the walk is " + p.State})
|
||||
w.Said = "open: " + p.State + ", " + words(now.Sub(*from)) + " since its merge"
|
||||
return w
|
||||
}
|
||||
if p.State != PlanDone {
|
||||
w.Said = "ended " + p.State + ": no delivery time"
|
||||
return w
|
||||
}
|
||||
end := *last
|
||||
w.End = &end
|
||||
w.TotalMS = end.Sub(*from).Milliseconds()
|
||||
w.Total = words(end.Sub(*from))
|
||||
if w.Said == "" {
|
||||
if w.UnknownMS > 0 {
|
||||
w.Said = fmt.Sprintf("%s from its merge to running everywhere, %s of it unknown", w.Total,
|
||||
words(time.Duration(w.UnknownMS)*time.Millisecond))
|
||||
} else {
|
||||
w.Said = w.Total + " from its merge to running everywhere"
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// firstMerge is when the walk's earliest merge was made, and every merge's start kept on w.
|
||||
func (p Plan) firstMerge(w *WalkPhases) *time.Time {
|
||||
var first *time.Time
|
||||
if p.Delivery != nil {
|
||||
for _, m := range p.Delivery.Merges {
|
||||
if m.Merged.IsZero() {
|
||||
continue
|
||||
}
|
||||
w.Merges = append(w.Merges, MergeStart{Repository: m.Repository, Commit: m.Commit, Merged: m.Merged})
|
||||
if first == nil || m.Merged.Before(*first) {
|
||||
at := m.Merged
|
||||
first = &at
|
||||
}
|
||||
}
|
||||
}
|
||||
if first == nil {
|
||||
for _, c := range p.Carried() {
|
||||
if c.Merged.IsZero() {
|
||||
continue
|
||||
}
|
||||
w.Merges = append(w.Merges, MergeStart{Repository: c.Repository, Commit: c.Commit, Merged: c.Merged})
|
||||
if first == nil || c.Merged.Before(*first) {
|
||||
at := c.Merged
|
||||
first = &at
|
||||
}
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// points are the walk's moments in order.
|
||||
func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []point {
|
||||
var out []point
|
||||
// The window and the wait behind another walk.
|
||||
cut := p.Created
|
||||
if p.Times != nil && p.Times.Cut != nil {
|
||||
cut = *p.Times.Cut
|
||||
}
|
||||
switch {
|
||||
case p.Times == nil:
|
||||
out = append(out, point{phase: PhaseWindow, tier: -1, said: "not kept for a walk made before ADR 0282"},
|
||||
point{phase: PhaseQueued, tier: -1, at: &cut, said: "the window and the wait behind another walk together"})
|
||||
case p.Times.WindowClosed == nil:
|
||||
out = append(out, point{phase: PhaseWindow, tier: -1, none: true, said: "no window: walked on its own"},
|
||||
point{phase: PhaseQueued, tier: -1, at: &cut})
|
||||
default:
|
||||
closed := *p.Times.WindowClosed
|
||||
out = append(out, point{phase: PhaseWindow, tier: -1, at: &closed}, point{phase: PhaseQueued, tier: -1, at: &cut})
|
||||
}
|
||||
if p.Delivery != nil && p.Delivery.Awaits != "" {
|
||||
out = append(out, point{phase: PhaseWord, tier: -1, at: p.Delivery.Go, said: "waiting for " + p.Delivery.Awaits + "'s word"})
|
||||
} else {
|
||||
out = append(out, point{phase: PhaseWord, tier: -1, none: true, said: "waits for no word"})
|
||||
}
|
||||
var lastSends []restSend
|
||||
for t, tier := range p.Tiers {
|
||||
if t > p.Tier || (t == p.Tier && p.Tier < len(p.Tiers) && !askedAnyOf(p, tier)) {
|
||||
break
|
||||
}
|
||||
var asked, built, first, judged, rest *time.Time
|
||||
allBuilt, anyFirst, allJudged, allRest := true, false, true, true
|
||||
for _, m := range tier {
|
||||
s := p.Modules[m]
|
||||
if s == nil {
|
||||
allBuilt, allRest = false, false
|
||||
continue
|
||||
}
|
||||
asked = earliest(asked, s.AskedAt)
|
||||
if s.BuiltAt == nil {
|
||||
if s.State != "deleted" {
|
||||
allBuilt = false
|
||||
}
|
||||
} else {
|
||||
built = latest(built, s.BuiltAt)
|
||||
}
|
||||
if s.FirstAt != nil {
|
||||
anyFirst = true
|
||||
first = earliest(first, s.FirstAt)
|
||||
if s.Gate != nil {
|
||||
if s.Gate.JudgedAt == nil {
|
||||
allJudged = false
|
||||
} else {
|
||||
judged = latest(judged, s.Gate.JudgedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.SentAt == nil {
|
||||
if s.State != "deleted" {
|
||||
allRest = false
|
||||
}
|
||||
} else {
|
||||
rest = latest(rest, s.SentAt)
|
||||
for node := range s.Rest {
|
||||
lastSends = append(lastSends, restSend{module: m, node: node, at: *s.SentAt})
|
||||
}
|
||||
}
|
||||
}
|
||||
if t > 0 {
|
||||
out = append(out, point{phase: PhaseBetween, tier: t, at: asked})
|
||||
}
|
||||
if !allBuilt {
|
||||
built = nil
|
||||
}
|
||||
out = append(out, point{phase: PhaseBuild, tier: t, at: built})
|
||||
if anyFirst {
|
||||
if !allJudged {
|
||||
judged = nil
|
||||
}
|
||||
out = append(out, point{phase: PhaseSend, tier: t, at: first}, point{phase: PhaseJudge, tier: t, at: judged})
|
||||
} else {
|
||||
out = append(out, point{phase: PhaseSend, tier: t, none: true, said: "no first machine: nothing to judge"},
|
||||
point{phase: PhaseJudge, tier: t, none: true, said: "no first machine: nothing to judge"})
|
||||
}
|
||||
if !allRest {
|
||||
rest = nil
|
||||
}
|
||||
out = append(out, point{phase: PhaseRest, tier: t, at: rest})
|
||||
}
|
||||
if p.State != PlanDone {
|
||||
return out
|
||||
}
|
||||
// Every machine of the rest running the build: its first report after the send, applied.
|
||||
if p.Times == nil {
|
||||
return append(out, point{phase: PhaseApply, tier: -1, said: "the rest's sends are not kept for a walk made before ADR 0282"})
|
||||
}
|
||||
if len(lastSends) == 0 {
|
||||
return append(out, point{phase: PhaseApply, tier: -1, none: true,
|
||||
said: "no machine beyond the first: the first-node gate's readings were its run"})
|
||||
}
|
||||
if applied == nil {
|
||||
return append(out, point{phase: PhaseApply, tier: -1, said: "the rest's reports were not read"})
|
||||
}
|
||||
var end *time.Time
|
||||
var waiting, failed []string
|
||||
for _, s := range lastSends {
|
||||
r, ok := applied(s.node, s.at)
|
||||
switch {
|
||||
case !ok && now.Sub(s.at) > ApplySilentAfter, ok && r.At.Sub(s.at) > ApplySilentAfter:
|
||||
w.Silent = appendOnce(w.Silent, s.node)
|
||||
case !ok:
|
||||
waiting = appendOnce(waiting, s.node)
|
||||
case r.Outcome != OutcomeApplied:
|
||||
failed = appendOnce(failed, s.node+" ("+r.Outcome+")")
|
||||
default:
|
||||
at := r.At
|
||||
end = latest(end, &at)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(failed) > 0:
|
||||
return append(out, point{phase: PhaseApply, tier: -1, said: "not applied on " + strings.Join(failed, ", ")})
|
||||
case len(waiting) > 0:
|
||||
return append(out, point{phase: PhaseApply, tier: -1, said: "waiting for " + strings.Join(waiting, ", ") +
|
||||
" to report it applied"})
|
||||
case end == nil:
|
||||
return append(out, point{phase: PhaseApply, tier: -1, said: "no machine of the rest heard from: " +
|
||||
strings.Join(w.Silent, ", ")})
|
||||
}
|
||||
// A machine may have reported before the last tier ended: the walk ends at whichever is later.
|
||||
for i := len(out) - 1; i >= 0; i-- {
|
||||
if out[i].at != nil {
|
||||
if out[i].at.After(*end) {
|
||||
e := *out[i].at
|
||||
end = &e
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
said := ""
|
||||
if len(w.Silent) > 0 {
|
||||
sort.Strings(w.Silent)
|
||||
said = "not waited for, not heard from: " + strings.Join(w.Silent, ", ")
|
||||
}
|
||||
return append(out, point{phase: PhaseApply, tier: -1, at: end, said: said})
|
||||
}
|
||||
|
||||
type restSend struct {
|
||||
module, node string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func askedAnyOf(p Plan, tier []string) bool {
|
||||
for _, m := range tier {
|
||||
if s := p.Modules[m]; s != nil && s.AskedAt != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func earliest(a, b *time.Time) *time.Time {
|
||||
if b == nil {
|
||||
return a
|
||||
}
|
||||
if a == nil || b.Before(*a) {
|
||||
t := *b
|
||||
return &t
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func latest(a, b *time.Time) *time.Time {
|
||||
if b == nil {
|
||||
return a
|
||||
}
|
||||
if a == nil || b.After(*a) {
|
||||
t := *b
|
||||
return &t
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func appendOnce(to []string, s string) []string {
|
||||
for _, x := range to {
|
||||
if x == s {
|
||||
return to
|
||||
}
|
||||
}
|
||||
return append(to, s)
|
||||
}
|
||||
|
||||
func orNot(s string) string {
|
||||
if s == "" {
|
||||
return "is not known"
|
||||
}
|
||||
return "(" + s + ")"
|
||||
}
|
||||
|
||||
// words is a duration as a person reads it.
|
||||
func words(d time.Duration) string {
|
||||
if d < 0 {
|
||||
return "-" + words(-d)
|
||||
}
|
||||
return d.Round(100 * time.Millisecond).String()
|
||||
}
|
||||
|
||||
// FirstAppliedAfter is a machine's first report of a send made at or after a walk's send to it, within
|
||||
// ApplySilentAfter of it — the controller's `apply` durations, which measure every send to its first report (to-be
|
||||
// 45 Phase 0). A declaration sent later carries the build too, so its report counts; one sent past the bound is a
|
||||
// machine that slept, listed as silent and never moving the walk's end (ADR 0282 decision 1).
|
||||
func (i *Inventory) FirstAppliedAfter(ctx context.Context, node string, sent time.Time) (AppliedReport, bool, error) {
|
||||
var started time.Time
|
||||
var ms int64
|
||||
var outcome string
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select started, took_ms, detail from duration
|
||||
where kind = $1 and subject = $2 and started >= $3 and started < $4
|
||||
order by started limit 1`,
|
||||
DurationApply, node, sent.Add(-appliedSlack), sent.Add(ApplySilentAfter)).Scan(&started, &ms, &outcome)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return AppliedReport{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AppliedReport{}, false, err
|
||||
}
|
||||
return AppliedReport{At: started.Add(time.Duration(ms) * time.Millisecond).UTC(), Outcome: outcome}, true, nil
|
||||
}
|
||||
|
||||
// appliedSlack is how much earlier than a walk's record of its send the machine's own record of it may be:
|
||||
// the send is recorded on the machine first, then on the walk.
|
||||
const appliedSlack = 2 * time.Second
|
||||
@@ -1,302 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A walk recorded on the live mesh on 2026-10-10 (mesh-delivery, one tier, one machine), as `delivery walks`
|
||||
// gave it, with the moments ADR 0282 adds: its window closed ninety seconds after its merge was heard, and it was
|
||||
// cut eleven seconds later.
|
||||
const recordedWalk = `{
|
||||
"id": "plan-1791654663505629616", "repository": "novox/mesh-catalog", "branch": "main",
|
||||
"commit": "a14fa306f262d88bdcca83432a70df353d2eceb0", "merged": "2026-10-10T17:50:53Z",
|
||||
"created": "2026-10-10T19:52:34.037755+02:00", "updated": "2026-10-10T19:55:37.974069+02:00",
|
||||
"state": "done", "tier": 1, "tiers": [["mesh-delivery"]],
|
||||
"modules": {"mesh-delivery": {"state": "built",
|
||||
"asked_at": "2026-10-10T17:52:34.209423145Z", "built_at": "2026-10-10T17:52:50.818011314Z",
|
||||
"sent_at": "2026-10-10T17:55:35.894985053Z", "first": ["novox"], "first_at": "2026-10-10T17:53:07.287136827Z",
|
||||
"gate": {"machines": ["novox"], "since": "2026-10-10T17:53:07.287136827Z", "passes": 3,
|
||||
"verdict": "passed", "judged_at": "2026-10-10T17:55:35.894985053Z"}}},
|
||||
"delivery": {"awaits": "", "merges": [{"repository": "novox/mesh-catalog",
|
||||
"commit": "a14fa306f262d88bdcca83432a70df353d2eceb0", "number": 187, "merged": "2026-10-10T17:50:53Z"}]},
|
||||
"times": {"window_closed": "2026-10-10T17:52:23Z", "cut": "2026-10-10T17:52:34.037755Z", "class": "core"}
|
||||
}`
|
||||
|
||||
func walkOf(t *testing.T, raw string) Plan {
|
||||
t.Helper()
|
||||
var p Plan
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// sumsUp checks the phases' measured time and the unknown time add up to the total, to the millisecond.
|
||||
func sumsUp(t *testing.T, w *WalkPhases) {
|
||||
t.Helper()
|
||||
if w.End == nil || w.From == nil {
|
||||
t.Fatalf("the walk has no end: %+v", w)
|
||||
}
|
||||
var sum int64
|
||||
for _, ph := range w.Phases {
|
||||
if ph.State == PhaseMeasured {
|
||||
if ph.Start == nil || ph.End == nil || ph.End.Sub(*ph.Start).Milliseconds() != ph.TookMS {
|
||||
t.Errorf("phase %s %d says %dms between %v and %v", ph.Name, ph.Tier, ph.TookMS, ph.Start, ph.End)
|
||||
}
|
||||
sum += ph.TookMS
|
||||
}
|
||||
if ph.State == PhaseUnknown && ph.TookMS != 0 {
|
||||
t.Errorf("an unknown phase %s says a time: %dms", ph.Name, ph.TookMS)
|
||||
}
|
||||
}
|
||||
if sum+w.UnknownMS != w.TotalMS || w.End.Sub(*w.From).Milliseconds() != w.TotalMS {
|
||||
t.Fatalf("the phases add up to %dms and %dms unknown, the total is %dms (%s): %+v", sum, w.UnknownMS,
|
||||
w.TotalMS, w.End.Sub(*w.From), w.Phases)
|
||||
}
|
||||
}
|
||||
|
||||
func phase(w *WalkPhases, name string, tier int) WalkPhase {
|
||||
for _, ph := range w.Phases {
|
||||
if ph.Name == name && ph.Tier == tier {
|
||||
return ph
|
||||
}
|
||||
}
|
||||
return WalkPhase{}
|
||||
}
|
||||
|
||||
// The phases of a recorded walk add up to its measured total: its merge at 17:50:53 to its verdict on its only
|
||||
// machine at 17:55:35.894, 4m42.894s.
|
||||
func TestARecordedWalksPhasesAddUpToItsTotal(t *testing.T) {
|
||||
w := walkOf(t, recordedWalk).WalkPhases(time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC), nil)
|
||||
sumsUp(t, w)
|
||||
if w.TotalMS != 282894 || w.Class != ClassCore || w.UnknownMS != 0 {
|
||||
t.Fatalf("total %dms (want 282894), class %q, unknown %d", w.TotalMS, w.Class, w.UnknownMS)
|
||||
}
|
||||
for name, want := range map[string]int64{PhaseWindow: 90000, PhaseQueued: 11037, PhaseBuild: 16781,
|
||||
PhaseSend: 16469, PhaseJudge: 148607, PhaseRest: 0} {
|
||||
tier := 0
|
||||
if name == PhaseWindow || name == PhaseQueued {
|
||||
tier = -1
|
||||
}
|
||||
if got := phase(w, name, tier); got.State != PhaseMeasured || got.TookMS != want {
|
||||
t.Errorf("%s: %s %dms, want measured %dms", name, got.State, got.TookMS, want)
|
||||
}
|
||||
}
|
||||
// The cut to the first ask (171ms) is a time no phase of ADR 0282's table names: it is counted in the build.
|
||||
if got := phase(w, PhaseWord, -1); got.State != PhaseNone {
|
||||
t.Errorf("a walk that waits for no word says its word phase %s", got.State)
|
||||
}
|
||||
if got := phase(w, PhaseApply, -1); got.State != PhaseNone || !strings.Contains(got.Said, "no machine beyond the first") {
|
||||
t.Errorf("one machine only: apply %s (%s)", got.State, got.Said)
|
||||
}
|
||||
}
|
||||
|
||||
// Two tiers, a machine of the rest each, both reports read: every phase measured, the walk ends at the last
|
||||
// report applied, and the phases add up.
|
||||
func TestAWalkOfTwoTiersEndsAtItsRestsLastReport(t *testing.T) {
|
||||
at := func(s string) *time.Time {
|
||||
v, err := time.Parse(time.RFC3339Nano, "2026-10-10T18:"+s+"Z")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &v
|
||||
}
|
||||
p := Plan{ID: "plan-2", State: PlanDone, Tier: 2, Tiers: [][]string{{"a"}, {"b"}}, Created: *at("01:40"),
|
||||
Delivery: &PlanDelivery{Merges: []PlanMerge{{Repository: "novox/x", Commit: "c1", Merged: *at("00:00")},
|
||||
{Repository: "novox/x", Commit: "c0", Merged: *at("00:30")}}},
|
||||
Times: &PlanTimes{WindowClosed: at("01:30"), Cut: at("01:40"), Class: ClassLeaf},
|
||||
Modules: map[string]*PlanModule{
|
||||
"a": {State: "built", AskedAt: at("01:41"), BuiltAt: at("02:05"), FirstAt: at("02:20"), SentAt: at("05:00"),
|
||||
Gate: &PlanGate{JudgedAt: at("04:50")}, Rest: map[string]SentDeclaration{"ace": {Digest: "d1"}}},
|
||||
"b": {State: "built", AskedAt: at("05:10"), BuiltAt: at("05:40"), FirstAt: at("05:50"), SentAt: at("08:00.5"),
|
||||
Gate: &PlanGate{JudgedAt: at("07:59")}, Rest: map[string]SentDeclaration{"shanks": {Digest: "d2"}}},
|
||||
}}
|
||||
reports := map[string]AppliedReport{"ace": {At: *at("05:12"), Outcome: OutcomeApplied},
|
||||
"shanks": {At: *at("08:15.25"), Outcome: OutcomeApplied}}
|
||||
w := p.WalkPhases(*at("30:00"), func(node string, _ time.Time) (AppliedReport, bool) {
|
||||
r, ok := reports[node]
|
||||
return r, ok
|
||||
})
|
||||
sumsUp(t, w)
|
||||
if w.TotalMS != (8*time.Minute + 15250*time.Millisecond).Milliseconds() {
|
||||
t.Fatalf("the walk's delivery time runs from its earliest merge to the last report: %s", w.Total)
|
||||
}
|
||||
if got := phase(w, PhaseBetween, 1); got.TookMS != 10000 {
|
||||
t.Errorf("between the tiers: %dms", got.TookMS)
|
||||
}
|
||||
if got := phase(w, PhaseApply, -1); got.State != PhaseMeasured || got.TookMS != 14750 {
|
||||
t.Errorf("apply: %s %dms", got.State, got.TookMS)
|
||||
}
|
||||
if len(w.Merges) != 2 {
|
||||
t.Errorf("each merge's start is said, for its own delivery time: %+v", w.Merges)
|
||||
}
|
||||
// A report not yet in: the walk has no end, and its apply is unknown — never zero.
|
||||
delete(reports, "shanks")
|
||||
w = p.WalkPhases(*at("10:00"), func(node string, _ time.Time) (AppliedReport, bool) {
|
||||
r, ok := reports[node]
|
||||
return r, ok
|
||||
})
|
||||
if w.End != nil || w.TotalMS != 0 {
|
||||
t.Fatalf("a walk whose rest has not reported has no end: %+v", w)
|
||||
}
|
||||
if got := phase(w, PhaseApply, -1); got.State != PhaseUnknown || got.TookMS != 0 || !strings.Contains(got.Said, "shanks") {
|
||||
t.Errorf("apply while shanks has not reported: %+v", got)
|
||||
}
|
||||
// Silent past the bound: listed, not waited for.
|
||||
w = p.WalkPhases(at("08:00.5").Add(ApplySilentAfter+time.Second), func(node string, _ time.Time) (AppliedReport, bool) {
|
||||
r, ok := reports[node]
|
||||
return r, ok
|
||||
})
|
||||
sumsUp(t, w)
|
||||
if len(w.Silent) != 1 || w.Silent[0] != "shanks" {
|
||||
t.Errorf("a machine silent past the bound is said, not waited for: %+v", w.Silent)
|
||||
}
|
||||
// A failed report: the walk has no end, said.
|
||||
reports["shanks"] = AppliedReport{At: *at("08:10"), Outcome: OutcomeFailed}
|
||||
w = p.WalkPhases(*at("30:00"), func(node string, _ time.Time) (AppliedReport, bool) {
|
||||
r, ok := reports[node]
|
||||
return r, ok
|
||||
})
|
||||
if w.End != nil || !strings.Contains(phase(w, PhaseApply, -1).Said, "shanks (failed)") {
|
||||
t.Errorf("a failed apply ends nothing: %+v", phase(w, PhaseApply, -1))
|
||||
}
|
||||
}
|
||||
|
||||
// A moment that is missing makes the phases around it unknown, and their span unknown time: never a zero.
|
||||
func TestAMissingMomentIsUnknownNeverZero(t *testing.T) {
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.Modules["mesh-delivery"].BuiltAt = nil
|
||||
w := p.WalkPhases(time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC), nil)
|
||||
sumsUp(t, w)
|
||||
if got := phase(w, PhaseBuild, 0); got.State != PhaseUnknown || got.TookMS != 0 {
|
||||
t.Errorf("a build without its moment: %+v", got)
|
||||
}
|
||||
if got := phase(w, PhaseSend, 0); got.State != PhaseUnknown {
|
||||
t.Errorf("the send after a build without its moment: %+v", got)
|
||||
}
|
||||
if w.UnknownMS != 16781+16469 {
|
||||
t.Errorf("the unknown time is the span between the moments around it: %dms", w.UnknownMS)
|
||||
}
|
||||
// A walk kept before ADR 0282: its window and its wait together, and its apply unknown.
|
||||
p = walkOf(t, recordedWalk)
|
||||
p.Times = nil
|
||||
w = p.WalkPhases(time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC), nil)
|
||||
if got := phase(w, PhaseWindow, -1); got.State != PhaseUnknown {
|
||||
t.Errorf("a window not kept: %+v", got)
|
||||
}
|
||||
if got := phase(w, PhaseApply, -1); got.State != PhaseUnknown || w.End != nil {
|
||||
t.Errorf("a rest not kept: %+v, end %v", got, w.End)
|
||||
}
|
||||
}
|
||||
|
||||
// An open walk is said open since its last moment, with no end.
|
||||
func TestAnOpenWalkHasNoEnd(t *testing.T) {
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.State, p.Tier = PlanRolling, 0
|
||||
p.Modules["mesh-delivery"].SentAt = nil
|
||||
p.Modules["mesh-delivery"].Gate.JudgedAt = nil
|
||||
w := p.WalkPhases(time.Date(2026, 10, 10, 17, 54, 0, 0, time.UTC), nil)
|
||||
if w.End != nil || !strings.HasPrefix(w.Said, "its end is unknown") && !strings.HasPrefix(w.Said, "open") {
|
||||
t.Fatalf("an open walk: %+v", w)
|
||||
}
|
||||
if got := phase(w, PhaseBuild, 0); got.State != PhaseMeasured {
|
||||
t.Errorf("an open walk's phases so far are measured: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A gate keeps every pass and the first reading after one that did not pass, at most maxReadings.
|
||||
func TestAGateKeepsItsReadings(t *testing.T) {
|
||||
var g PlanGate
|
||||
t0 := time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC)
|
||||
g.Read(t0, false, "starting")
|
||||
g.Read(t0.Add(time.Second), false, "starting")
|
||||
g.Read(t0.Add(40*time.Second), true, "")
|
||||
g.Read(t0.Add(80*time.Second), true, "")
|
||||
g.Read(t0.Add(81*time.Second), false, "gone")
|
||||
g.Read(t0.Add(82*time.Second), false, "gone")
|
||||
if len(g.Readings) != 4 || g.Readings[0].Said != "starting" || !g.Readings[2].Healthy || g.Readings[3].Said != "gone" {
|
||||
t.Fatalf("readings: %+v", g.Readings)
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
g.Read(t0.Add(time.Duration(100+i)*time.Second), true, "")
|
||||
}
|
||||
if len(g.Readings) != maxReadings {
|
||||
t.Fatalf("readings are bounded: %d", len(g.Readings))
|
||||
}
|
||||
}
|
||||
|
||||
// A walk's moments are kept and read back with it; a machine's first report after a send is read from the
|
||||
// controller's apply durations.
|
||||
func TestAWalksMomentsAreKeptAndItsRestsReportRead(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.Revision, p.Epoch = 0, 0
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := inv.PlanByID(ctx, p.ID)
|
||||
if err != nil || back.Times == nil || back.Times.Class != ClassCore || back.Times.WindowClosed == nil ||
|
||||
!back.Times.WindowClosed.Equal(*p.Times.WindowClosed) {
|
||||
t.Fatalf("the walk's moments were not kept: %v %+v", err, back.Times)
|
||||
}
|
||||
if back.Delivery.Merges[0].Merged.IsZero() {
|
||||
t.Fatal("a merge's time was not kept")
|
||||
}
|
||||
sent := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond)
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "ace", sent); err != nil || ok {
|
||||
t.Fatalf("no report yet: %v %v", ok, err)
|
||||
}
|
||||
for _, d := range []Duration{
|
||||
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "old@1", Started: sent.Add(-time.Hour), Took: time.Second, Detail: OutcomeApplied},
|
||||
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "d1@2", Started: sent.Add(-time.Second), Took: 12 * time.Second, Detail: OutcomeApplied},
|
||||
} {
|
||||
if err := inv.RecordDuration(ctx, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r, ok, err := inv.FirstAppliedAfter(ctx, "ace", sent)
|
||||
if err != nil || !ok || r.Outcome != OutcomeApplied || !r.At.Equal(sent.Add(11*time.Second)) {
|
||||
t.Fatalf("the report after the send: %+v %v %v", r, ok, err)
|
||||
}
|
||||
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || anyKept || total {
|
||||
t.Fatalf("nothing kept yet: %v %v %v", anyKept, total, err)
|
||||
}
|
||||
if err := inv.RecordDuration(ctx, Duration{Kind: DurationWalkPhase, Subject: "core/total", Ref: p.ID + "/total",
|
||||
Started: sent, Took: time.Minute}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || !anyKept || !total {
|
||||
t.Fatalf("the total kept: %v %v %v", anyKept, total, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that slept past the bound and reported later is listed silent: its late report never moves the
|
||||
// walk's end; and a report sent past the bound is not read as the walk's.
|
||||
func TestALateReportIsSilentNotTheEnd(t *testing.T) {
|
||||
sent := time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC)
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.Modules["mesh-delivery"].SentAt = &sent
|
||||
p.Modules["mesh-delivery"].Rest = map[string]SentDeclaration{"laptop": {Digest: "d"}, "server": {Digest: "e"}}
|
||||
w := p.WalkPhases(sent.Add(3*time.Hour), func(node string, _ time.Time) (AppliedReport, bool) {
|
||||
if node == "server" {
|
||||
return AppliedReport{At: sent.Add(20 * time.Second), Outcome: OutcomeApplied}, true
|
||||
}
|
||||
return AppliedReport{At: sent.Add(2 * time.Hour), Outcome: OutcomeApplied}, true
|
||||
})
|
||||
if len(w.Silent) != 1 || w.Silent[0] != "laptop" || w.End == nil || !w.End.Equal(sent.Add(20*time.Second)) {
|
||||
t.Fatalf("a late report: silent %v, end %v", w.Silent, w.End)
|
||||
}
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
if err := inv.RecordDuration(ctx, Duration{Kind: DurationApply, Subject: "laptop", Node: "laptop", Ref: "late@1",
|
||||
Started: sent.Add(time.Hour), Took: time.Second, Detail: OutcomeApplied}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "laptop", sent); err != nil || ok {
|
||||
t.Fatalf("a send past the bound read as the walk's: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -210,11 +210,9 @@ type SourceMoved struct {
|
||||
// stands: a directory is a module only when the merge changed its manifest.
|
||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
||||
|
||||
// Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): a delivery
|
||||
// group's `after:` lines, read when its members are merged into one batch and their order becomes the
|
||||
// walk's, and the words `plans` names a walk by.
|
||||
// Number is the pull request's, and Body its description (novox/hq ADR 0276): a delivery group's
|
||||
// `after:` lines, read when its members are merged into one batch and their order becomes the walk's.
|
||||
Number int `json:"number,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
+56
-11
@@ -80,6 +80,57 @@ func Check(text string, machines ...string) (Refusal, bool) {
|
||||
return Refusal{"address", "a hardware address"}, false
|
||||
case reIPv6.MatchString(text):
|
||||
return Refusal{"address", "an IPv6 address"}, false
|
||||
}
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
if reWinPath.MatchString(text) {
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
if refusal, ok := randomRun(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, isSeparator) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// Secret says whether a text carries a secret's shape, the one class that leaves the mesh nowhere — the
|
||||
// messenger's CheckSecret, one for one. It is what an ask's whole words (asks.Ask.Whole, novox/hq issue 383)
|
||||
// are held to: on a channel that proves who answers, a path or an address is what the operator approves and
|
||||
// is shown; a secret never is. A path is read as one: a run with a slash in it is judged piece by piece
|
||||
// between the slashes, so "/mnt/Photos_2024/Jochen" is a path and not a random string (the review of
|
||||
// 2026-10-10); the named shapes hold whatever the run holds.
|
||||
func Secret(text string, machines ...string) (Refusal, bool) {
|
||||
text = withoutMachines(text, machines)
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
return randomRunOutsidePaths(text)
|
||||
}
|
||||
|
||||
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
|
||||
func randomRunOutsidePaths(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
for _, piece := range strings.Split(run, "/") {
|
||||
if len(piece) >= 20 && looksRandom(piece) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// secretShape is the secret shapes a pattern names.
|
||||
func secretShape(text string) (Refusal, bool) {
|
||||
switch {
|
||||
case rePEM.MatchString(text):
|
||||
return Refusal{"secret", "a key block"}, false
|
||||
case reJWT.MatchString(text):
|
||||
@@ -92,23 +143,17 @@ func Check(text string, machines ...string) (Refusal, bool) {
|
||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||
case reHex.MatchString(text):
|
||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||
case reWinPath.MatchString(text):
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// randomRun is a long unbroken run of characters spread as a random string's are.
|
||||
func randomRun(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
if looksRandom(run) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, isSeparator) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -86,3 +86,40 @@ func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) {
|
||||
t.Errorf("a text that passes was changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Secret is the one class that leaves the mesh nowhere (novox/hq issue 383): it refuses every secret's shape
|
||||
// Check refuses, and nothing Check refuses as an address or a path — those an ask's whole words may carry.
|
||||
func TestSecretRefusesOnlyASecretsShape(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
|
||||
"123456789:ABCdefGHIjklMNOpqrSTUvwxYZ0123456789ab",
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
"password=hunter2",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"a key xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC",
|
||||
} {
|
||||
r, ok := Secret(text)
|
||||
if ok || r.Class != "secret" {
|
||||
t.Errorf("not refused as a secret: %q (%s)", text, r)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{
|
||||
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro",
|
||||
"library=/mnt/library",
|
||||
"photos=/mnt/Photos_2024/Jochen",
|
||||
"games=smb://nas.lan/Recalbox_Games2024",
|
||||
"/srv/media/Series_Archive/Season01",
|
||||
"10.77.0.9:53",
|
||||
"jochen@example.com",
|
||||
"C:\\Users\\jo",
|
||||
"anchor and the laptop",
|
||||
} {
|
||||
if r, ok := Secret(text, "anchor"); !ok {
|
||||
t.Errorf("refused as %s: %q", r, text)
|
||||
}
|
||||
if _, ok := Check(text, "anchor"); ok && text != "anchor and the laptop" {
|
||||
t.Errorf("Check lets %q leave, so Secret is not the narrower rule", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
"hand-act",
|
||||
"drill",
|
||||
"warranted",
|
||||
"secret-ask",
|
||||
"hand-acts",
|
||||
"durations",
|
||||
"conditions",
|
||||
|
||||
+16
-1
@@ -154,7 +154,9 @@ func canonical(b *strings.Builder, v string) {
|
||||
//
|
||||
// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC
|
||||
// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask
|
||||
// later changes no digest until it is added here, on purpose.
|
||||
// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow
|
||||
// the options only when the ask gives either: an ask without them digests as it did before they existed, so
|
||||
// a router and an asker of different builds still agree on every such ask.
|
||||
func (a Ask) Digest() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("novox.ask.v1\n")
|
||||
@@ -168,6 +170,10 @@ func (a Ask) Digest() string {
|
||||
canonical(&b, v)
|
||||
}
|
||||
}
|
||||
if a.Whole != "" || a.Details != "" {
|
||||
canonical(&b, a.Whole)
|
||||
canonical(&b, a.Details)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(b.String()))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -190,6 +196,15 @@ type Ask struct {
|
||||
// About is what the ask is about (a condition's key): a newer ask about it replaces the older.
|
||||
About string `json:"about,omitempty"`
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
// Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind
|
||||
// that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the
|
||||
// person approves — a mount point, a share, a private address — must be readable where they approve it.
|
||||
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
|
||||
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
|
||||
Whole string `json:"whole,omitempty"`
|
||||
// Details is what the Details answer on the ask's message shows, line by line under the content rule: a
|
||||
// fingerprint, how to read the proposal whole at the terminal. Empty, an ask's own message offers no Details.
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
# git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
||||
# git.novox.be/novox/mesh-sdk/go v0.1.13
|
||||
## explicit; go 1.22
|
||||
git.novox.be/novox/mesh-sdk/go/asks
|
||||
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
|
||||
|
||||
Reference in New Issue
Block a user