Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d30b5de13 | ||
|
|
c97942d591 | ||
|
|
9c69b9da17 | ||
|
|
16362e1bbd | ||
|
|
3496b58f66 | ||
|
|
04fcce2fcc |
+122
-60
@@ -38,8 +38,9 @@ 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, so no catalogue delivery skips its turn
|
||||
// behind a controller merge (decided during the build, 2026-10-10).
|
||||
// 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).
|
||||
//
|
||||
// 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.
|
||||
@@ -181,7 +182,12 @@ 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)
|
||||
}
|
||||
event, err := json.Marshal(m)
|
||||
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})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -193,7 +199,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); err != nil {
|
||||
if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own, deliverySeatHeld(entries)); err != nil {
|
||||
return notNow(err)
|
||||
} else if ok {
|
||||
heard.Plan = p.ID
|
||||
@@ -251,7 +257,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 bool) (inventory.Plan, bool, error) {
|
||||
moves []inventory.Entry, own, held 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
|
||||
@@ -268,7 +274,11 @@ func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard
|
||||
return p, true, nil
|
||||
}
|
||||
case p.Open() || p.State == inventory.PlanDone:
|
||||
if buildsEvery(p, moves) {
|
||||
// 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) {
|
||||
return p, true, nil
|
||||
}
|
||||
}
|
||||
@@ -431,7 +441,7 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
|
||||
if repo == "" {
|
||||
repo = m.Repository
|
||||
}
|
||||
n := inventory.PlanMerge{Repository: repo, Commit: m.Commit}
|
||||
n := namedOf(m, repo)
|
||||
if l := latest[k]; l.Commit != m.Commit {
|
||||
n.Carried = l.Commit
|
||||
}
|
||||
@@ -443,15 +453,34 @@ 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 {
|
||||
var e link.SourceMoved
|
||||
if json.Unmarshal(m.Event, &e) == nil && e.Owner != "" {
|
||||
if e := announcedOf(m); 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}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -480,10 +509,11 @@ 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 {
|
||||
n := inventory.PlanMerge{Repository: m.Repository, Commit: m.Commit}
|
||||
repo := m.Repository
|
||||
if e := spelledOf(m); e != "" {
|
||||
n.Repository = e
|
||||
repo = e
|
||||
}
|
||||
n := namedOf(m, repo)
|
||||
if c := p.CommitOn(m.Repository, m.Branch); c != "" && c != m.Commit {
|
||||
n.Carried = c
|
||||
}
|
||||
@@ -579,18 +609,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; one of each kind at most is open (theOpenBatch).
|
||||
// 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() })
|
||||
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: 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
|
||||
}
|
||||
// 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).
|
||||
waiting = nil
|
||||
}
|
||||
if err := cutBatch(ctx, open, b, waiting, now); err != nil {
|
||||
@@ -608,46 +638,6 @@ 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 {
|
||||
@@ -754,6 +744,9 @@ 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
|
||||
@@ -773,9 +766,7 @@ 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
|
||||
// **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)
|
||||
plan.Delivery = awaits
|
||||
if plan.Delivery == nil {
|
||||
plan.Delivery = &inventory.PlanDelivery{}
|
||||
}
|
||||
@@ -1088,6 +1079,77 @@ 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.Equal(w.Delivery.Merges, want) {
|
||||
if w.Delivery == nil || !slices.EqualFunc(w.Delivery.Merges, want, sameMerge) {
|
||||
t.Fatalf("the walk answers %+v, want %+v", w.Delivery, want)
|
||||
}
|
||||
if len(*asked) != 2 || (*asked)[0][2] != "main" || (*asked)[1][2] != "main" {
|
||||
@@ -229,17 +229,28 @@ 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()
|
||||
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))
|
||||
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, repoMerge("one", "a6bc0931one", now), now)
|
||||
out := captured(t, func() error { return plansCommand(t.Context(), nil) })
|
||||
first := strings.SplitN(out, "\n", 2)[0]
|
||||
lines := strings.Split(out, "\n")
|
||||
first := lines[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)
|
||||
@@ -364,7 +375,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 ||
|
||||
got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early",
|
||||
!sameMerge(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)
|
||||
}
|
||||
@@ -404,7 +415,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 ||
|
||||
alone.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) {
|
||||
!sameMerge(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",
|
||||
@@ -425,7 +436,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 || done.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog",
|
||||
if len(done.Delivery.Merges) != 2 || !sameMerge(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)
|
||||
}
|
||||
@@ -565,7 +576,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 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
|
||||
if len(got.Delivery.Merges) != 2 || !sameMerge(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)
|
||||
}
|
||||
@@ -589,10 +600,11 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(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) {
|
||||
// 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) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
asked := asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
@@ -603,9 +615,37 @@ func TestAWaitingWalkIsNotLetGoBesideAStartedOne(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 ||
|
||||
!strings.Contains(err.Error(), started[0].ID) {
|
||||
t.Fatalf("a waiting walk was let go beside %s: %v", started[0].ID, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +700,7 @@ func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||
if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
|
||||
if len(got.Delivery.Merges) != 2 || !sameMerge(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)
|
||||
@@ -706,50 +746,29 @@ 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)))
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
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 {
|
||||
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"} {
|
||||
@@ -757,4 +776,127 @@ func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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:])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,16 +103,8 @@ 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): 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)
|
||||
}
|
||||
}
|
||||
// **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.
|
||||
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"
|
||||
|
||||
@@ -15,16 +15,23 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10).
|
||||
// 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>]
|
||||
//
|
||||
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -54,6 +61,37 @@ 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.
|
||||
@@ -95,20 +133,37 @@ 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).
|
||||
// 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.
|
||||
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 {
|
||||
@@ -121,8 +176,10 @@ 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)
|
||||
@@ -137,6 +194,7 @@ 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)
|
||||
@@ -144,8 +202,10 @@ 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", name, module, node, 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)),
|
||||
Cause: "given-at-the-desk"}
|
||||
recorded := ""
|
||||
if err := d.record(act); err != nil {
|
||||
@@ -165,15 +225,23 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
return words + recorded, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
}
|
||||
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,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -124,12 +125,21 @@ 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 accept anchor telegram telegram-token --at-desk laptop" {
|
||||
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at 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)
|
||||
@@ -266,13 +276,22 @@ func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) {
|
||||
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", "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)
|
||||
}
|
||||
}
|
||||
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"},
|
||||
@@ -398,3 +417,96 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,47 @@ 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.
|
||||
@@ -578,6 +619,18 @@ 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
|
||||
}
|
||||
@@ -1181,27 +1234,38 @@ 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).
|
||||
// said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn.
|
||||
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)", p.Named(), len(p.Tiers))
|
||||
return fmt.Sprintf("%s · done, %d tier(s)", name, len(p.Tiers))
|
||||
case inventory.PlanFailed:
|
||||
return fmt.Sprintf("%s FAILED at %s: %s", p.Named(), where, p.Note)
|
||||
return fmt.Sprintf("%s · FAILED at %s: %s", name, where, p.Note)
|
||||
case inventory.PlanSuperseded:
|
||||
return fmt.Sprintf("%s %s", p.Named(), p.Note)
|
||||
return fmt.Sprintf("%s · %s", name, 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", p.Named(), where, waitingNote(p), since)
|
||||
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)
|
||||
}
|
||||
if waiting, paused := pausedWaiting(p, pause, now); paused {
|
||||
return fmt.Sprintf("%s %s, %s", p.Named(), where, waiting)
|
||||
return fmt.Sprintf("%s · %s, %s", name, where, waiting)
|
||||
}
|
||||
late := ""
|
||||
if since > bound {
|
||||
@@ -1211,7 +1275,53 @@ func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.D
|
||||
if p.State == inventory.PlanRolling {
|
||||
what = p.Note
|
||||
}
|
||||
return fmt.Sprintf("%s %s, %s for %s%s", p.Named(), where, what, since, late)
|
||||
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
|
||||
}
|
||||
|
||||
// planFailedBuild marks the module a failed build was for when the result names no module: by the
|
||||
@@ -1353,8 +1463,11 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
fmt.Printf("%s — %s\n", p.ID, planLineWith(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||
bounds.of(p.Repository)))
|
||||
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
|
||||
}))
|
||||
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, ", "),
|
||||
@@ -1490,14 +1603,22 @@ 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, planLineWith(p, now, pause, bounds.of(p.Repository)))
|
||||
fmt.Printf("%-28s %s\n", p.ID, planLineOn(p, now, pause, bounds.of(p.Repository), running))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -768,10 +768,22 @@ 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", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil
|
||||
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
|
||||
case "rotate":
|
||||
if p := str("provision"); p != "" {
|
||||
argv := []string{"rotate", p}
|
||||
@@ -1538,10 +1550,11 @@ var terminalOnlyCommands = map[string]string{
|
||||
"licence": "the licences' secrets",
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func givenAtTheDesk(argv []string) bool {
|
||||
if len(argv) != 7 || argv[0] != "secret" || argv[1] != "accept" || argv[5] != "--at-desk" {
|
||||
if (len(argv) != 5 && len(argv) != 7) || argv[0] != "secret" || argv[1] != "ask" {
|
||||
return false
|
||||
}
|
||||
for _, w := range argv[2:5] {
|
||||
@@ -1549,7 +1562,10 @@ func givenAtTheDesk(argv []string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return argv[6] != "" && !strings.HasPrefix(argv[6], "-")
|
||||
if len(argv) == 5 {
|
||||
return true
|
||||
}
|
||||
return argv[5] == "--at" && argv[6] != "" && !strings.HasPrefix(argv[6], "-")
|
||||
}
|
||||
|
||||
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||
|
||||
@@ -276,12 +276,6 @@ 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"},
|
||||
|
||||
@@ -39,6 +39,8 @@ 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":
|
||||
@@ -78,7 +80,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 giveAtDesk(ctx, node, module, name, *desk)
|
||||
return askAtDesk(ctx, node, module, name, *desk)
|
||||
}
|
||||
|
||||
value, err := valueFor(node, module, name, *from)
|
||||
@@ -148,7 +150,24 @@ 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,6 +490,11 @@ 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,
|
||||
|
||||
@@ -231,17 +231,32 @@ 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): 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): 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.",
|
||||
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.",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 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);
|
||||
@@ -71,6 +71,11 @@ 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"`
|
||||
}
|
||||
|
||||
// PlanBatch is a batch's window while it is one (novox/hq ADR 0276): when it closes unless another merge
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -210,9 +210,11 @@ 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, 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 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 int `json:"number,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"hand-act",
|
||||
"drill",
|
||||
"warranted",
|
||||
"secret-ask",
|
||||
"hand-acts",
|
||||
"durations",
|
||||
"conditions",
|
||||
|
||||
Reference in New Issue
Block a user