From e1cdeebcdf66dbcdb0e69bc4d7c8e73c2579c1bd Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 21:54:21 +0100 Subject: [PATCH] Add mesh-hand-acts, holder of the seat mesh-hand-acts, so the hand-act log has a home the controller's lease does not hold (issue 1953) Acts are kept 400 days under the runtime-proven caller and said as act-recorded; a warrant is read from the router's own record and recorded once per ask; hand-acts answers over both logs. The act layout is a copy of mesh-sdk's until L5-S2 lands. --- modules/mesh-hand-acts/.gitignore | 2 + modules/mesh-hand-acts/README.md | 23 + .../mesh-hand-acts/cmd/mesh-hand-acts/acts.go | 399 ++++++++++++++++++ .../cmd/mesh-hand-acts/acts_test.go | 213 ++++++++++ .../mesh-hand-acts/cmd/mesh-hand-acts/main.go | 51 +++ .../cmd/mesh-hand-acts/manifest_test.go | 65 +++ .../cmd/mesh-hand-acts/reads-outside | 2 + .../cmd/mesh-hand-acts/tools.go | 110 +++++ .../mesh-hand-acts/whole_integration_test.go | 327 ++++++++++++++ modules/mesh-hand-acts/go.mod | 22 + modules/mesh-hand-acts/go.sum | 27 ++ modules/mesh-hand-acts/module.json | 69 +++ 12 files changed, 1310 insertions(+) create mode 100644 modules/mesh-hand-acts/.gitignore create mode 100644 modules/mesh-hand-acts/README.md create mode 100644 modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go create mode 100644 modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go create mode 100644 modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go create mode 100644 modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go create mode 100644 modules/mesh-hand-acts/cmd/mesh-hand-acts/reads-outside create mode 100644 modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go create mode 100644 modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go create mode 100644 modules/mesh-hand-acts/go.mod create mode 100644 modules/mesh-hand-acts/go.sum create mode 100644 modules/mesh-hand-acts/module.json diff --git a/modules/mesh-hand-acts/.gitignore b/modules/mesh-hand-acts/.gitignore new file mode 100644 index 00000000..2a60d0a0 --- /dev/null +++ b/modules/mesh-hand-acts/.gitignore @@ -0,0 +1,2 @@ +/mesh-hand-acts +/cmd/mesh-hand-acts/mesh-hand-acts diff --git a/modules/mesh-hand-acts/README.md b/modules/mesh-hand-acts/README.md new file mode 100644 index 00000000..c7085dfd --- /dev/null +++ b/modules/mesh-hand-acts/README.md @@ -0,0 +1,23 @@ +# mesh-hand-acts + +Holds the seat `mesh-hand-acts` (novox/hq ADR 0565 §10, §11): the hand-act log, moved out of the controller. Go, one +binary (`cmd/mesh-hand-acts`), served by a runtime of its own on its own bus account (`runs-as`), so that its account +alone writes the log. + +## What it keeps + +- `mesh-hand-acts_acts`: every act, one key `act--` (`warrant--` for a warrant) in the layout of the + controller's log, kept 400 days by the bucket's age. Each act is said as `act-recorded`. +- `mesh-hand-acts_warrants`: one key `.` per warrant recorded, made once by create, naming its act. + +## Its verbs + +- `mesh-hand-acts.hand-act`: what, why, cause, the condition addressed, and the act's verb (a repair when not given; + `hand-act drill` for a drill, whose cause is always `drill`). Who did it is the caller the runtime proves. +- `mesh-hand-acts.warranted`: asker and ask. Read from the router's own record of that ask (`messenger.asks`), never + from the caller, and recorded once per ask; an ask still open, ended without a choice or another asker's is refused. +- `mesh-hand-acts.hand-acts`: both logs, this one and the controller's `mesh-controller_hand-acts` until its lease + ends, newest first, with the causes repaired by hand twice within a fortnight; `warrants=true` lists the warrants. + +No act is written to both logs: every recorder calls this seat from its delivery, and the controller keeps its own +acts in its own bucket until it loses its lease. diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go new file mode 100644 index 00000000..26969cb1 --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go @@ -0,0 +1,399 @@ +package main + +// The hand-act log (novox/hq ADR 0565 §10, §11): every act done by hand, with who, why and its cause, so a repair a +// person keeps making is found (S15) and a person's decision is read back. mesh-hand-acts holds the seat +// mesh-hand-acts, once for the mesh, and is the one writer of its state `acts` (each act a key that the bucket keeps +// 400 days) and `warrants` (one key `.` made once by create). Each act is said as `act-recorded`. +// +// **Who did it is the runtime's word, never the caller's**: `by` is the bus user the runtime proved. A warrant is the +// router's word, never the caller's: `warranted` reads the router's own record of the asker's ask and records who +// chose, through which channel, with which proofs and which answer, once per ask. The controller's bucket +// `mesh-controller_hand-acts` stays its own until it loses its lease and is read, never written: `hand-acts` answers +// over both logs, and no act is written to both (ADR 0565 §11). + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Module and Seat are this module and the seat it holds once for the mesh. +const ( + Module = "mesh-hand-acts" + Seat = "mesh-hand-acts" +) + +// The states it keeps and reads, and the event it says. +const ( + ActsState = "acts" + WarrantsState = "warrants" + // ControllerActs is the controller's log, read until its lease ends. + ControllerActs = "mesh-controller.hand-acts" + // RouterRecords is the router's record of every ask, `.` (ADR 0259): the warrant is read there. + RouterRecords = "messenger.asks" + RecordedEvent = "act-recorded" +) + +// The verbs an act is recorded under, as the controller's log names them: what S15 reads to tell a repair from a +// person's decision by design. +const ( + VerbRecord = "hand-act record" + VerbDrill = "hand-act drill" + VerbWarrant = "warrant" + // CauseDrill is a drill's cause, and the one cause a repair may not give. + CauseDrill = "drill" + // CauseOperatorAnswer is an act the operator chose on a warrant. + CauseOperatorAnswer = "operator-answer" +) + +// RepeatedWithin is how far back a cause counts as repeated (S15). +const RepeatedWithin = 14 * 24 * time.Hour + +// Act is one entry of the log, in the layout of the controller's (mesh-controller internal/link HandAct), so one +// reader reads both logs alike. **A copy of the layout mesh-sdk holds for an act (issue 1953, L5-S2)**, replaced by it +// when it lands. +type Act struct { + ID string `json:"id"` + At time.Time `json:"at"` + By string `json:"by"` + Verb string `json:"verb"` + Args []string `json:"arguments,omitempty"` + Why string `json:"why"` + Cause string `json:"cause"` + Condition string `json:"condition,omitempty"` + Kind string `json:"kind,omitempty"` + Carried []string `json:"carried,omitempty"` + Via string `json:"via,omitempty"` + Ask string `json:"ask,omitempty"` + Proofs []string `json:"proofs,omitempty"` + RequestedBy string `json:"requested-by,omitempty"` + Outcome string `json:"outcome,omitempty"` + // Log is which log holds it, set when it is read: this seat's, or the controller's. Never written. + Log string `json:"log,omitempty"` +} + +// Store is the bus's buckets as this module reaches them, through its runtime. Create writes a key only when it has +// none, and says ErrExists otherwise. +type Store interface { + Get(state, key string) ([]byte, bool, error) + Put(state, key string, value any) error + Create(state, key string, value any) error + Keys(state string) ([]string, error) +} + +// ErrExists is a create of a key another writer made first. +var ErrExists = errors.New("the key was made by another writer first") + +// Log is mesh-hand-acts: the log and what records into it. +type Log struct { + Store Store + Emit func(event string, body any) error + Now func() time.Time + Logf func(format string, args ...any) +} + +var seq atomic.Uint64 + +// actKey is an act's key: its time and a sequence, so the keys list in order, as the controller's do. +func actKey(at time.Time) string { + return "act-" + strconv.FormatInt(at.UnixNano(), 10) + "-" + strconv.FormatUint(seq.Add(1), 10) +} + +// Recorded is what a recorder is answered: the act as kept. +type Recorded struct { + Act Act `json:"act"` + Said string `json:"said"` +} + +// HandAct records one act a caller says it did, by hand or on purpose: what, why, its cause and the condition it +// addresses. by is the caller the runtime proved. verb is the act's verb as the log names it: a repair by default, a +// drill with VerbDrill, or a recorder's own (`conditions silence`, `config set`), which S15 reads as it reads the +// controller's. +func (l *Log) HandAct(by, verb, what, why, cause, condition string) (Recorded, error) { + what, why, cause, verb = strings.TrimSpace(what), strings.TrimSpace(why), strings.TrimSpace(cause), strings.TrimSpace(verb) + switch { + case !proven(by): + return Recorded{}, fmt.Errorf("who acts is the caller the runtime proves, and it proved none (%q): nothing was recorded", by) + case what == "": + return Recorded{}, errors.New("a hand act says what was done: nothing was recorded") + case why == "": + return Recorded{}, errors.New("a hand act says why: nothing was recorded") + } + if verb == "" { + verb = VerbRecord + } + if verb == VerbDrill { + cause = CauseDrill + } + switch { + case cause == "": + return Recorded{}, errors.New("a hand act says its cause, in a word or a condition's kind: the word a second act " + + "for the same reason uses, which is how a repair done twice is found. Nothing was recorded") + case strings.EqualFold(cause, CauseDrill) && verb != VerbDrill: + return Recorded{}, errors.New("a drill is not recorded as a repair: record it with verb " + VerbDrill + + ", which no healer is wanted for. Nothing was recorded") + } + at := l.Now().UTC() + act := Act{ID: actKey(at), At: at, By: by, Verb: verb, Args: []string{what}, Why: why, Cause: cause, + Condition: strings.TrimSpace(condition)} + if err := l.record(act); err != nil { + return Recorded{}, err + } + return Recorded{Act: act, Said: fmt.Sprintf("recorded as %s: %s did %q, because %q (cause: %s)", act.ID, by, what, + why, cause)}, nil +} + +// record writes one act and says it. An act not said is still kept, and the log says so. +func (l *Log) record(act Act) error { + if err := l.Store.Create(ActsState, act.ID, act); err != nil { + return fmt.Errorf("the act could not be recorded: %w", err) + } + if l.Emit != nil { + if err := l.Emit(RecordedEvent, act); err != nil { + l.Logf("[%s] %s is recorded and could NOT be said as %s: %v", Module, act.ID, RecordedEvent, err) + } + } + return nil +} + +// routerRecord is what warranted reads of the router's record of an ask. +type routerRecord struct { + State string `json:"state"` + Warrant *asks.Warrant `json:"warrant"` + Ask map[string]any `json:"ask"` +} + +var askerModule = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +// Warrant is one ask's warrant as recorded: the key `.` of `warrants`, naming the act it recorded. +type Warrant struct { + Act string `json:"act"` + At time.Time `json:"at"` + By string `json:"recorded-at-the-word-of"` +} + +// Warranted records an asker's act on the operator's warrant for its ask, read from the router's record, once per ask +// however often it is asked (ADR 0274, ADR 0259). The caller gives which ask and nothing else. +func (l *Log) Warranted(caller, asker, ask string) (Recorded, error) { + switch { + case !proven(caller): + return Recorded{}, fmt.Errorf("who asks is the caller the runtime proves, and it proved none (%q): nothing was recorded", caller) + case !askerModule.MatchString(asker): + return Recorded{}, fmt.Errorf("%q is not a module's name", asker) + case !asks.UsableID(ask): + return Recorded{}, fmt.Errorf("%q is not an ask's id", ask) + } + key := asker + "." + ask + if raw, found, err := l.Store.Get(WarrantsState, key); err != nil { + return Recorded{}, err + } else if found { + return l.already(key, raw) + } + raw, found, err := l.Store.Get(RouterRecords, key) + if err != nil { + return Recorded{}, fmt.Errorf("the router's record of %s's ask %s cannot be read: %w", asker, ask, err) + } + var rec routerRecord + if found { + if err := json.Unmarshal(raw, &rec); err != nil { + return Recorded{}, fmt.Errorf("the router's record of %s's ask %s is not readable: %w", asker, ask, err) + } + } + w := rec.Warrant + switch { + case rec.State == "open" || rec.State == "held": + return Recorded{}, fmt.Errorf("%s's ask %s is still %s: nobody has answered it. Nothing was recorded", asker, ask, rec.State) + case !found || rec.State == "" || w == nil: + return Recorded{}, fmt.Errorf("the router holds no closed record of %s's ask %s. Nothing was recorded", asker, ask) + case w.Asker != asker || w.Ask != ask: + return Recorded{}, fmt.Errorf("the router's record is for %s's ask %s. Nothing was recorded", w.Asker, w.Ask) + case w.Outcome != asks.OutcomeChosen || w.By == nil: + return Recorded{}, fmt.Errorf("%s's ask %s ended %s: no person chose, so there is no warrant to record. Nothing was "+ + "recorded", asker, ask, w.Outcome) + case w.AskDigest == "": + return Recorded{}, fmt.Errorf("the router's warrant for %s's ask %s names no ask digest. Nothing was recorded", asker, ask) + } + at := l.Now().UTC() + act := Act{ID: "warrant-" + asker + "-" + ask, At: w.At.UTC(), Verb: VerbWarrant, + Args: []string{fmt.Sprintf("the operator chose %s on %s's ask %s", w.Label, asker, ask)}, + Why: fmt.Sprintf("%s (ask %s of %s)", w.Says(), ask, asker), + By: fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity), + Cause: CauseOperatorAnswer, Via: via(*w), Ask: ask, Proofs: w.Proofs, + RequestedBy: asker + ", recorded at the word of " + caller, + Outcome: "chosen; what " + asker + " did with it is in its own record"} + if err := l.Store.Create(WarrantsState, key, Warrant{Act: act.ID, At: at, By: caller}); errors.Is(err, ErrExists) { + raw, _, gerr := l.Store.Get(WarrantsState, key) + if gerr != nil { + return Recorded{}, gerr + } + return l.already(key, raw) + } else if err != nil { + return Recorded{}, fmt.Errorf("the warrant could not be recorded: %w", err) + } + if err := l.record(act); err != nil && !errors.Is(err, ErrExists) { + return Recorded{}, err + } + return Recorded{Act: act, Said: fmt.Sprintf("recorded as %s: %s, through %s", act.ID, act.Why, act.Via)}, nil +} + +// already answers a warrant recorded before; an act lost between the warrant and its act is written now. +func (l *Log) already(key string, raw []byte) (Recorded, error) { + var w Warrant + if err := json.Unmarshal(raw, &w); err != nil { + return Recorded{}, fmt.Errorf("the warrant %s is not readable: %w", key, err) + } + actRaw, found, err := l.Store.Get(ActsState, w.Act) + if err != nil { + return Recorded{}, err + } + if !found { + return Recorded{}, fmt.Errorf("the warrant %s names the act %s, which is not kept: asked again, it is read from "+ + "the router again", key, w.Act) + } + var act Act + _ = json.Unmarshal(actRaw, &act) + return Recorded{Act: act, Said: "already recorded as " + act.ID + ": " + act.Why}, nil +} + +func via(w asks.Warrant) string { + if w.By == nil { + return w.Channel + } + v := w.Channel + " (" + w.By.Kind + ")" + if w.By.Verified != "" { + v += ", " + w.By.Verified + } + return v +} + +// proven is a bus user the runtime proved: never empty, never the SDK's unproven word. +func proven(by string) bool { + by = strings.TrimSpace(by) + return by != "" && !strings.EqualFold(by, "unproven") && !strings.HasPrefix(by, "(") +} + +// Listing is what hand-acts answers: every act of both logs within the days asked, newest first, narrowed by cause +// and who, and the causes repaired by hand twice or more within a fortnight. +type Listing struct { + Acts []Act `json:"acts"` + Repeated map[string]int `json:"repeated"` + Days int `json:"days"` + // Unread says which log could not be read, so what is missing is said and never read as nothing. + Unread map[string]string `json:"unread,omitempty"` + Kept map[string]int `json:"kept"` +} + +// HandActs reads both logs. +func (l *Log) HandActs(days int, cause, who string) Listing { + now := l.Now() + since := now.Add(-time.Duration(days) * 24 * time.Hour) + out := Listing{Acts: []Act{}, Repeated: map[string]int{}, Days: days, Kept: map[string]int{}} + var all []Act + for _, log := range []string{ActsState, ControllerActs} { + acts, kept, err := l.read(log, now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin))) + if err != nil { + if out.Unread == nil { + out.Unread = map[string]string{} + } + out.Unread[log] = err.Error() + continue + } + out.Kept[log] = kept + all = append(all, acts...) + } + for _, a := range all { + if now.Sub(a.At) <= RepeatedWithin && !decision(a) { + out.Repeated[a.Cause]++ + } + if a.At.Before(since) || (cause != "" && a.Cause != cause) || (who != "" && !strings.Contains(a.By, who)) { + continue + } + out.Acts = append(out.Acts, a) + } + for c, n := range out.Repeated { + if n < 2 { + delete(out.Repeated, c) + } + } + sort.SliceStable(out.Acts, func(i, j int) bool { return out.Acts[i].At.After(out.Acts[j].At) }) + return out +} + +// read is every act of one log since a moment, and how many the log keeps. A key names its time, so only the keys +// within are read whole. +func (l *Log) read(log string, since time.Time) ([]Act, int, error) { + keys, err := l.Store.Keys(log) + if err != nil { + return nil, 0, err + } + var out []Act + for _, k := range keys { + if at, ok := keyTime(k); ok && at.Before(since) { + continue + } + raw, found, err := l.Store.Get(log, k) + if err != nil { + return nil, len(keys), err + } + if !found { + continue + } + var a Act + if json.Unmarshal(raw, &a) != nil || a.At.Before(since) { + continue + } + a.Log = log + if log == ActsState { + a.Log = Seat + } + out = append(out, a) + } + return out, len(keys), nil +} + +// keyTime is the time an act's key names (`act--`), when it names one. +func keyTime(key string) (time.Time, bool) { + parts := strings.Split(key, "-") + if len(parts) != 3 || parts[0] != "act" { + return time.Time{}, false + } + ns, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(0, ns), true +} + +// decided are the verbs whose acts are a person's decision by design, with the causes they hold for (every cause when +// none): the controller's handActVerbs as S15 reads them. A verb not listed is a repair. +var decided = map[string][]string{ + VerbWarrant: nil, "seat": {"seat-passed"}, "conditions silence": {CauseOperatorAnswer}, VerbRecord: {CauseDrill}, + "secret issued": nil, VerbDrill: nil, "retire approve": nil, "retire reject": nil, "cleanup delete": nil, + "dead-letters deliver": nil, "dead-letters drop": nil, "collect": nil, "mirrors": nil, "move-placement": nil, + "move-config": nil, "bus upgrade": nil, "upgrade release-backlog": nil, "secret accept": nil, + "secret rotate": {"leaked-in-logs"}, "rotate": {"leaked-in-logs"}, +} + +func decision(a Act) bool { + causes, ok := decided[a.Verb] + if !ok { + return false + } + if len(causes) == 0 { + return true + } + for _, c := range causes { + if c == a.Cause { + return true + } + } + return false +} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go new file mode 100644 index 00000000..9c0ea2d4 --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "encoding/json" + "errors" + "sort" + "strings" + "sync" + "testing" + "time" +) + +type memStore struct { + mu sync.Mutex + kv map[string]map[string][]byte + keysErr map[string]error +} + +func newStore() *memStore { + return &memStore{kv: map[string]map[string][]byte{}, keysErr: map[string]error{}} +} + +func (s *memStore) Get(state, key string) ([]byte, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.kv[state][key] + return v, ok, nil +} + +func (s *memStore) put(state, key string, value any, create bool) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.kv[state] == nil { + s.kv[state] = map[string][]byte{} + } + if _, ok := s.kv[state][key]; ok && create { + return ErrExists + } + s.kv[state][key] = raw + return nil +} + +func (s *memStore) Put(state, key string, value any) error { return s.put(state, key, value, false) } +func (s *memStore) Create(state, key string, value any) error { return s.put(state, key, value, true) } + +func (s *memStore) Keys(state string) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.keysErr[state]; err != nil { + return nil, err + } + var out []string + for k := range s.kv[state] { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +func newLog() (*Log, *memStore, *[]Act, *time.Time) { + store := newStore() + var said []Act + now := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) + l := &Log{Store: store, Logf: func(string, ...any) {}, Now: func() time.Time { return now }, + Emit: func(event string, body any) error { + if a, ok := body.(Act); ok && event == RecordedEvent { + said = append(said, a) + } + return nil + }} + return l, store, &said, &now +} + +// hand-act records what the proven caller says it did, once, and says it; it refuses what cannot be read back, a +// caller the runtime did not prove, and a drill recorded as a repair (ADR 0565 §10; issue 1953 L5-C1). +func TestHandActRecordsWhatTheProvenCallerDid(t *testing.T) { + cases := []struct { + name, by, verb, what, why, cause string + wantVerb, wantCause, err string + }{ + {"a repair", "ace.mesh-config", "", "set dns", "it was wrong", "config", VerbRecord, "config", ""}, + {"a recorder's own verb", "novox.mesh-conditions", "conditions silence", "silenced x", "known", "operator-answer", + "conditions silence", "operator-answer", ""}, + {"a drill takes its cause", "novox.mesh-healers", VerbDrill, "stopped records", "does it raise", "", VerbDrill, CauseDrill, ""}, + {"no proven caller", "unproven", "", "x", "y", "z", "", "", "proves"}, + {"by said empty", "", "", "x", "y", "z", "", "", "proves"}, + {"no why", "person.jochen", "", "x", "", "z", "", "", "says why"}, + {"no what", "person.jochen", "", "", "y", "z", "", "", "says what"}, + {"no cause", "person.jochen", "", "x", "y", "", "", "", "cause"}, + {"a drill as a repair", "person.jochen", "", "x", "y", "drill", "", "", "not recorded as a repair"}, + } + for _, tc := range cases { + l, store, said, _ := newLog() + r, err := l.HandAct(tc.by, tc.verb, tc.what, tc.why, tc.cause, "bus.x.missing") + keys, _ := store.Keys(ActsState) + if tc.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.err) || len(keys) != 0 || len(*said) != 0 { + t.Errorf("%s: %v, kept %v", tc.name, err, keys) + } + continue + } + if err != nil || len(keys) != 1 || keys[0] != r.Act.ID || len(*said) != 1 { + t.Fatalf("%s: %v, kept %v, said %d", tc.name, err, keys, len(*said)) + } + var kept Act + raw, _, _ := store.Get(ActsState, keys[0]) + _ = json.Unmarshal(raw, &kept) + if kept.By != tc.by || kept.Verb != tc.wantVerb || kept.Cause != tc.wantCause || kept.Condition != "bus.x.missing" || + kept.Args[0] != tc.what || kept.Log != "" { + t.Errorf("%s: kept %+v", tc.name, kept) + } + } +} + +// warranted records the router's word once per ask, never the caller's; it refuses an ask still open, one ended +// without a choice, another asker's, and one the router holds no record of (ADR 0565 §10, ADR 0259). +func TestWarrantedRecordsTheRoutersWordOnce(t *testing.T) { + chosen := `{"state":"closed","warrant":{"ask":"a1","asker":"claude-code","outcome":"chosen","label":"Approve", + "by":{"who":"operator","kind":"telegram","identity":"42","verified":"P2"},"channel":"telegram","proofs":["P1","P2"], + "at":"2026-10-29T21:00:00Z","ask-digest":"sha256:x"}}` + cases := []struct { + name, record, asker, ask, err string + }{ + {"chosen", chosen, "claude-code", "a1", ""}, + {"still open", `{"state":"open"}`, "claude-code", "a1", "still open"}, + {"no record", "", "claude-code", "a1", "no closed record"}, + {"expired", `{"state":"closed","warrant":{"ask":"a1","asker":"claude-code","outcome":"expired","at":"2026-10-29T21:00:00Z"}}`, + "claude-code", "a1", "no person chose"}, + {"another asker's", strings.Replace(chosen, `"asker":"claude-code"`, `"asker":"mesh-config"`, 1), "claude-code", "a1", + "record is for mesh-config"}, + {"no digest", strings.Replace(chosen, `,"ask-digest":"sha256:x"`, "", 1), "claude-code", "a1", "no ask digest"}, + {"not a module", chosen, "Claude Code", "a1", "not a module"}, + } + for _, tc := range cases { + l, store, said, _ := newLog() + if tc.record != "" { + _ = store.Put(RouterRecords, "claude-code.a1", json.RawMessage(tc.record)) + } + r, err := l.Warranted("ace.claude-code", tc.asker, tc.ask) + if tc.err != "" { + if keys, _ := store.Keys(ActsState); err == nil || !strings.Contains(err.Error(), tc.err) || len(keys) != 0 { + t.Errorf("%s: %v, kept %v", tc.name, err, keys) + } + continue + } + if err != nil || r.Act.Verb != VerbWarrant || r.Act.Cause != CauseOperatorAnswer || + !strings.Contains(r.Act.By, "operator, as telegram identity 42") || r.Act.Via != "telegram (telegram), P2" || + !strings.Contains(r.Act.RequestedBy, "ace.claude-code") || len(r.Act.Proofs) != 2 { + t.Fatalf("%s: %+v, %v", tc.name, r.Act, err) + } + // Asked again, by another instance: nothing more is recorded. + again, err := l.Warranted("g14.claude-code", "claude-code", "a1") + keys, _ := store.Keys(ActsState) + if err != nil || !strings.HasPrefix(again.Said, "already recorded") || len(keys) != 1 || len(*said) != 1 { + t.Errorf("%s: a second warranted: %q, %v, kept %v, said %d", tc.name, again.Said, err, keys, len(*said)) + } + } +} + +// hand-acts answers over both logs, newest first, narrowed by days, cause and who, and counts each cause repaired by +// hand twice in a fortnight across both, never a person's decision; a log it cannot read is said, never read as empty. +func TestHandActsReadsBothLogs(t *testing.T) { + l, store, _, now := newLog() + put := func(log, verb, cause, by string, ago time.Duration) { + at := now.Add(-ago) + _ = store.Put(log, actKey(at), Act{At: at, Verb: verb, Cause: cause, By: by, Why: "x"}) + } + put(ActsState, VerbRecord, "relogin", "person.jochen", time.Hour) + put(ControllerActs, VerbRecord, "relogin", "shanks.claude-code", 3*24*time.Hour) + put(ControllerActs, VerbDrill, CauseDrill, "person.jochen", time.Hour) + put(ActsState, VerbDrill, CauseDrill, "person.jochen", 2*time.Hour) + put(ActsState, VerbWarrant, CauseOperatorAnswer, "the operator", time.Hour) + put(ActsState, VerbWarrant, CauseOperatorAnswer, "the operator", 2*time.Hour) + put(ControllerActs, VerbRecord, "config", "ace.mesh-config", 30*24*time.Hour) + cases := []struct { + name string + days int + cause, who string + want int + }{ + {"a fortnight", 14, "", "", 6}, + {"a day", 1, "", "", 5}, + {"forty days", 40, "", "", 7}, + {"by cause", 14, "relogin", "", 2}, + {"by who", 14, "", "claude-code", 1}, + } + for _, tc := range cases { + got := l.HandActs(tc.days, tc.cause, tc.who) + if len(got.Acts) != tc.want || len(got.Repeated) != 1 || got.Repeated["relogin"] != 2 || got.Unread != nil { + t.Errorf("%s: %d acts, repeated %v, unread %v", tc.name, len(got.Acts), got.Repeated, got.Unread) + } + for i := 1; i < len(got.Acts); i++ { + if got.Acts[i].At.After(got.Acts[i-1].At) { + t.Errorf("%s: not newest first", tc.name) + } + } + } + if got := l.HandActs(14, "", ""); got.Acts[0].Log != Seat || got.Kept[ControllerActs] != 3 { + t.Errorf("the log of an act or the kept counts are not said: %+v", got.Kept) + } + store.keysErr[ControllerActs] = errors.New("the bus is away") + if got := l.HandActs(14, "", ""); got.Unread[ControllerActs] == "" || len(got.Acts) != 4 { + t.Errorf("an unreadable log: %+v", got.Unread) + } + w, err := l.Warrants() + if err != nil || w.(map[string]any)["count"] != 0 { + t.Errorf("warrants answered %v, %v", w, err) + } +} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go new file mode 100644 index 00000000..497875ea --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go @@ -0,0 +1,51 @@ +// mesh-hand-acts: the holder of the seat mesh-hand-acts (novox/hq ADR 0565 §10). A Go bundle served by a runtime of +// its own, on its own bus account (`runs-as`), so that its account alone writes the hand-act log (`acts`, each act kept +// 400 days) and `warrants`. It reads the router's record of each ask for a warrant and the controller's log for +// `hand-acts`. stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "errors" + "fmt" + "os" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// runtimeStore is the bus, through the runtime that launched this bundle. +type runtimeStore struct{} + +func (runtimeStore) Get(state, key string) ([]byte, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} + +func (runtimeStore) Put(state, key string, value any) error { + _, err := stdio.State(state).Put(key, value) + return err +} + +func (runtimeStore) Create(state, key string, value any) error { + _, err := stdio.State(state).Create(key, value) + if errors.Is(err, stdio.ErrChanged) { + return ErrExists + } + return err +} + +func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } + +func main() { + l := &Log{Store: runtimeStore{}, Now: time.Now, Logf: logf, + Emit: func(event string, body any) error { return stdio.Emit(event, body) }} + if err := stdio.Serve("", Tools(l)); err != nil { + logf("%v", err) + os.Exit(1) + } +} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go new file mode 100644 index 00000000..79bfe248 --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" +) + +// The manifest says what the code does: it claims the seat mesh-hand-acts once for the mesh with its verbs (ADR 0565 +// §1, §10), runs on its own account, says act-recorded, reads the controller's log and the router's records, and keeps +// acts and warrants 400 days. Every state it keeps and reads has a tool that reads it (ADR 0565 §15). +func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Module string `json:"module"` + Runs string `json:"runs-as"` + Claims []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + } `json:"claims"` + Emits []string `json:"emits"` + Reads []string `json:"reads"` + State []struct { + Name string `json:"name"` + TTL int `json:"ttl-seconds"` + } `json:"state"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.Module != Module || m.Runs != Module { + t.Errorf("module %q runs as %q", m.Module, m.Runs) + } + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || m.Claims[0].Scope != "mesh" || !slices.Equal(m.Claims[0].Serves, Verbs) { + t.Errorf("claims %+v", m.Claims) + } + if !slices.Equal(m.Emits, []string{RecordedEvent}) || !slices.Equal(m.Reads, []string{ControllerActs, RouterRecords}) { + t.Errorf("emits %v, reads %v", m.Emits, m.Reads) + } + readBy := map[string]string{ActsState: HandActsTool, WarrantsState: HandActsTool, ControllerActs: HandActsTool, + RouterRecords: WarrantedTool} + tools := map[string]bool{} + for _, tool := range Tools(&Log{}) { + tools[tool.Name] = true + } + for _, s := range m.State { + if s.TTL != 400*24*60*60 { + t.Errorf("%s is kept %d s, want 400 days", s.Name, s.TTL) + } + if !tools[readBy[s.Name]] { + t.Errorf("the state %s has no tool that reads it", s.Name) + } + } + for _, r := range m.Reads { + if !tools[readBy[r]] { + t.Errorf("the read %s has no tool that answers it", r) + } + } +} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/reads-outside b/modules/mesh-hand-acts/cmd/mesh-hand-acts/reads-outside new file mode 100644 index 00000000..60d6785c --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/reads-outside @@ -0,0 +1,2 @@ +# What these tests read outside their package, as paths from the repository's root (issue 1936). +modules/mesh-hand-acts/module.json diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go new file mode 100644 index 00000000..833db625 --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go @@ -0,0 +1,110 @@ +package main + +// The seat's verbs (novox/hq ADR 0565 §10, §15): hand-act and warranted record, hand-acts reads both logs and, with +// warrants, every warrant recorded. A recovery is a call: a warrant whose act was lost is written again by asking +// warranted again, from the router's record. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Verbs are the seat's verbs, as the manifest claims them. +var Verbs = []string{"hand-act", "hand-acts", "warranted"} + +// Tool names. +const ( + HandActTool = Seat + ".hand-act" + HandActsTool = Seat + ".hand-acts" + WarrantedTool = Seat + ".warranted" +) + +func obj(props map[string]any, required ...string) map[string]any { + m := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + m["required"] = required + } + return m +} + +func str(d string) map[string]any { return map[string]any{"type": "string", "description": d} } + +// Tools are what the runtime serves. +func Tools(l *Log) []stdio.Tool { + return []stdio.Tool{ + {Name: HandActTool, + Description: "Record one act done by hand (novox/hq ADR 0565 §10): what was done, why, its cause in a word or a " + + "condition's kind (the word a second act for the same reason uses, which is how a repair done twice is " + + "found) and the condition it addresses. Who did it is the caller the runtime proves. verb is the act's " + + "verb as the log names it: a repair when not given, `hand-act drill` for a drill, or a recorder's own " + + "(`conditions silence`, `config set`). Said as act-recorded.", + Input: obj(map[string]any{"what": str("what was done"), "why": str("why, in a sentence"), + "cause": str("the cause, in a word or a condition's kind"), "condition": str("the condition's key, if any"), + "verb": str("the act's verb in the log; a repair when not given")}, "what", "why"), + Run: func(a map[string]any) (any, error) { + return l.HandAct(stdio.CallerOf(a), arg(a, "verb"), arg(a, "what"), arg(a, "why"), arg(a, "cause"), arg(a, "condition")) + }}, + {Name: HandActsTool, + Description: "What was done by hand lately, over both logs (this seat's and the controller's until its lease " + + "ends), newest first: each act with who, why, its cause and which log holds it, and the causes repaired by " + + "hand twice or more within a fortnight (a healer is wanted). Narrowed by cause and who; warrants=true lists " + + "every warrant recorded instead.", + Input: obj(map[string]any{"days": map[string]any{"type": "number", "description": "how many days back (default 14)"}, + "cause": str("only this cause"), "who": str("only acts whose who contains this"), + "warrants": str("\"true\": every warrant recorded, `.` with its act")}), + Run: func(a map[string]any) (any, error) { + if strings.EqualFold(arg(a, "warrants"), "true") || a["warrants"] == true { + return l.Warrants() + } + return l.HandActs(days(a), arg(a, "cause"), arg(a, "who")), nil + }}, + {Name: WarrantedTool, + Description: "Record an asker's act on the operator's warrant (ADR 0274, ADR 0259): read from the router's own " + + "record of that ask — who chose, through which channel, with which proofs and which answer — never from the " + + "caller, once per ask; refused for an ask still open, ended without a choice, or another asker's.", + Input: obj(map[string]any{"asker": str("the module that asked"), "ask": str("its ask's id")}, "asker", "ask"), + Run: func(a map[string]any) (any, error) { + return l.Warranted(stdio.CallerOf(a), arg(a, "asker"), arg(a, "ask")) + }}, + } +} + +// Warrants is every warrant recorded, by key. +func (l *Log) Warrants() (any, error) { + keys, err := l.Store.Keys(WarrantsState) + if err != nil { + return nil, fmt.Errorf("the warrants cannot be read: %w", err) + } + sort.Strings(keys) + rows := map[string]json.RawMessage{} + for _, k := range keys { + if raw, found, err := l.Store.Get(WarrantsState, k); err == nil && found { + rows[k] = raw + } + } + return map[string]any{"warrants": rows, "count": len(rows)}, nil +} + +func arg(a map[string]any, k string) string { + s, _ := a[k].(string) + return strings.TrimSpace(s) +} + +func days(a map[string]any) int { + switch v := a["days"].(type) { + case float64: + if v > 0 { + return int(v) + } + case string: + var n int + if _, err := fmt.Sscan(v, &n); err == nil && n > 0 { + return n + } + } + return 14 +} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go new file mode 100644 index 00000000..62c4600f --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go @@ -0,0 +1,327 @@ +//go:build integration + +package main + +// mesh-hand-acts whole against a real bus (novox/hq ADR 0565 §10; issue 1953 L5-C2): the built binary, played over its +// real stdin and stdout as the node's tool runtime plays it (tools/call with the caller in _meta; mesh/state.* answered +// from a real nats-server's key-value buckets, mesh/publish onto its subjects). A recorder's hand act is kept in the +// log on the bus under the proven caller and heard as act-recorded; a warrant the router recorded is recorded once. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +type wire struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// player is the node's tool runtime, as far as the self-check needs it, over a real bus. +type player struct { + ctx context.Context + nc *nats.Conn + js jetstream.JetStream + mu sync.Mutex + in io.Writer + next int + pending map[string]chan wire +} + +func (p *player) send(m map[string]any) { + raw, _ := json.Marshal(m) + p.mu.Lock() + defer p.mu.Unlock() + _, _ = p.in.Write(append(raw, '\n')) +} + +func (p *player) answer(id json.RawMessage, result any, err error) { + if err != nil { + p.send(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + p.send(map[string]any{"id": id, "result": result}) +} + +func (p *player) serve(out io.Reader) { + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + for scanner.Scan() { + var m wire + if json.Unmarshal(scanner.Bytes(), &m) != nil { + continue + } + if m.Method == "" { + var key string + _ = json.Unmarshal(m.ID, &key) + p.mu.Lock() + ch := p.pending[key] + delete(p.pending, key) + p.mu.Unlock() + if ch != nil { + ch <- m + } + continue + } + go p.handle(m) + } +} + +// kv is the bucket a state names: this module's own by its local name, another's as `.`. +func (p *player) kv(state string) (jetstream.KeyValue, error) { + if owner, name, ok := strings.Cut(state, "."); ok { + return p.js.KeyValue(p.ctx, owner+"_"+name) + } + return p.js.KeyValue(p.ctx, Module+"_"+state) +} + +func (p *player) handle(m wire) { + var a struct { + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Body json.RawMessage `json:"body"` + } + _ = json.Unmarshal(m.Params, &a) + switch m.Method { + case "mesh/state.get": + kv, err := p.kv(a.State) + if err != nil { + p.answer(m.ID, nil, err) + return + } + e, err := kv.Get(p.ctx, a.Key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + p.answer(m.ID, nil, nil) + return + } + if err != nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, map[string]any{"key": e.Key(), "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil) + case "mesh/state.put": + kv, err := p.kv(a.State) + if err == nil { + var rev uint64 + if rev, err = kv.Put(p.ctx, a.Key, a.Value); err == nil { + p.answer(m.ID, map[string]any{"revision": rev}, nil) + return + } + } + p.answer(m.ID, nil, err) + case "mesh/state.create": + kv, err := p.kv(a.State) + if err == nil { + var rev uint64 + if rev, err = kv.Create(p.ctx, a.Key, a.Value); err == nil { + p.answer(m.ID, map[string]any{"revision": rev}, nil) + return + } + if errors.Is(err, jetstream.ErrKeyExists) { + p.send(map[string]any{"id": m.ID, "error": map[string]any{"code": -32001, "message": "the key was written by another writer first"}}) + return + } + } + p.answer(m.ID, nil, err) + case "mesh/state.watch": + kv, err := p.kv(a.State) + if err != nil { + p.answer(m.ID, nil, err) + return + } + key := a.Key + if key == "" { + key = ">" + } + w, err := kv.Watch(p.ctx, key) + if err != nil { + p.answer(m.ID, nil, err) + return + } + var watch string + _ = json.Unmarshal(m.ID, &watch) + current := true + for e := range w.Updates() { + if e == nil { + current = false + p.answer(m.ID, map[string]any{}, nil) + continue + } + op := "put" + if e.Operation() != jetstream.KeyValuePut { + op = "delete" + } + change := map[string]any{"state": a.State, "key": e.Key(), "op": op, "revision": e.Revision(), "current": current} + if op == "put" { + change["value"] = json.RawMessage(e.Value()) + } + p.mu.Lock() + p.next++ + id := fmt.Sprintf("w-%d", p.next) + p.mu.Unlock() + p.send(map[string]any{"id": id, "method": "mesh/state", "params": map[string]any{"watch": watch, "change": change}}) + } + case "mesh/subscribe": + p.answer(m.ID, map[string]any{}, nil) + case "mesh/state.keys": + kv, err := p.kv(a.State) + if err != nil { + p.answer(m.ID, nil, err) + return + } + keys, err := kv.Keys(p.ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + keys, err = []string{}, nil + } + p.answer(m.ID, keys, err) + case "mesh/state.delete": + kv, err := p.kv(a.State) + if err == nil { + err = kv.Delete(p.ctx, a.Key) + } + p.answer(m.ID, map[string]any{}, err) + case "mesh/publish": + var e struct { + Key string `json:"key"` + Body json.RawMessage `json:"body"` + } + _ = json.Unmarshal(m.Params, &e) + p.answer(m.ID, map[string]any{}, p.nc.Publish("mesh.mod."+Module+".event."+e.Key, e.Body)) + default: + p.answer(m.ID, nil, errors.New("nats: no responders available for request")) + } +} + +func (p *player) call(name, caller string, args map[string]any) (json.RawMessage, error) { + p.mu.Lock() + p.next++ + id := fmt.Sprintf("c-%d", p.next) + ch := make(chan wire, 1) + p.pending[id] = ch + p.mu.Unlock() + p.send(map[string]any{"id": id, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args, + "_meta": map[string]any{"mesh/caller": caller}}}) + select { + case m := <-ch: + if m.Error != nil { + return nil, errors.New(m.Error.Message) + } + var r struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(m.Result, &r); err != nil || len(r.Content) != 1 { + return nil, fmt.Errorf("%s answered %s", name, m.Result) + } + if r.IsError { + return nil, errors.New(r.Content[0].Text) + } + return json.RawMessage(r.Content[0].Text), nil + case <-time.After(60 * time.Second): + return nil, fmt.Errorf("%s did not answer in 60s", name) + } +} + +// 1953/L5-C2: an act recorded through the seat is kept on the bus and heard as act-recorded. +func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + dir := t.TempDir() + bin := filepath.Join(dir, Module) + build := exec.Command("go", "build", "-o", bin, ".") + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + t.Fatalf("mesh-hand-acts does not build: %v", err) + } + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + for _, b := range []string{Module + "_" + ActsState, Module + "_" + WarrantsState, "mesh-controller_hand-acts", "messenger_asks"} { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { + t.Fatal(err) + } + } + recorded, err := nc.SubscribeSync("mesh.mod." + Module + ".event." + RecordedEvent) + if err != nil { + t.Fatal(err) + } + cmd := exec.CommandContext(ctx, bin) + cmd.Env = append(os.Environ(), "MESH_NODE=novox", "MESH_MODULE="+Module) + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = stdin.Close(); cancel(); _ = cmd.Wait() }) + p := &player{ctx: ctx, nc: nc, js: js, in: stdin, pending: map[string]chan wire{}} + go p.serve(stdout) + + raw, err := p.call(HandActTool, "ace.mesh-config", map[string]any{"what": "set dns on g14", "why": "it was wrong", + "cause": "config", "by": "someone else"}) + if err != nil { + t.Fatal(err) + } + var r Recorded + if err := json.Unmarshal(raw, &r); err != nil || r.Act.By != "ace.mesh-config" { + t.Fatalf("hand-act answered %s", raw) + } + acts, _ := js.KeyValue(ctx, Module+"_"+ActsState) + e, err := acts.Get(ctx, r.Act.ID) + if err != nil || !strings.Contains(string(e.Value()), `"by":"ace.mesh-config"`) { + t.Fatalf("the act is not kept on the bus: %v", err) + } + msg, err := recorded.NextMsg(10 * time.Second) + if err != nil || !strings.Contains(string(msg.Data), r.Act.ID) { + t.Fatalf("act-recorded was not said: %v", err) + } + // A warrant the router recorded, asked twice: one act. + asks, _ := js.KeyValue(ctx, "messenger_asks") + if _, err := asks.Put(ctx, "claude-code.a1", []byte(`{"state":"closed","warrant":{"ask":"a1","asker":"claude-code", + "outcome":"chosen","label":"Approve","by":{"who":"operator","kind":"telegram","identity":"42"},"channel":"telegram", + "at":"2026-10-29T21:00:00Z","ask-digest":"sha256:x"}}`)); err != nil { + t.Fatal(err) + } + for _, caller := range []string{"ace.claude-code", "g14.claude-code"} { + if _, err := p.call(WarrantedTool, caller, map[string]any{"asker": "claude-code", "ask": "a1"}); err != nil { + t.Fatal(err) + } + } + if keys, _ := acts.Keys(ctx); len(keys) != 2 { + t.Errorf("the log keeps %v, want the act and one warrant", keys) + } + listed, err := p.call(HandActsTool, "person.jochen", map[string]any{}) + if err != nil || !strings.Contains(string(listed), "warrant-claude-code-a1") || !strings.Contains(string(listed), r.Act.ID) { + t.Errorf("hand-acts answered %s, %v", listed, err) + } +} diff --git a/modules/mesh-hand-acts/go.mod b/modules/mesh-hand-acts/go.mod new file mode 100644 index 00000000..f79a46d2 --- /dev/null +++ b/modules/mesh-hand-acts/go.mod @@ -0,0 +1,22 @@ +module mesh-hand-acts + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029204453-69fcb6c1c1ba + github.com/nats-io/nats.go v1.54.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nats-server/v2 v2.11.17 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-hand-acts/go.sum b/modules/mesh-hand-acts/go.sum new file mode 100644 index 00000000..ae67790f --- /dev/null +++ b/modules/mesh-hand-acts/go.sum @@ -0,0 +1,27 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029204453-69fcb6c1c1ba h1:uXwkcQWgbu/UthizGHFaKPiA4NBVJgqL9q2jJnnGR1o= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029204453-69fcb6c1c1ba/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +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/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-hand-acts/module.json b/modules/mesh-hand-acts/module.json new file mode 100644 index 00000000..3e562b9a --- /dev/null +++ b/modules/mesh-hand-acts/module.json @@ -0,0 +1,69 @@ +{ + "module": "mesh-hand-acts", + "version": "1", + "runs-as": "mesh-hand-acts", + "claims": [ + { + "name": "mesh-hand-acts", + "scope": "mesh", + "serves": [ + "hand-act", + "hand-acts", + "warranted" + ] + } + ], + "emits": [ + "act-recorded" + ], + "reads": [ + "mesh-controller.hand-acts", + "messenger.asks" + ], + "state": [ + { + "name": "acts", + "ttl-seconds": 34560000 + }, + { + "name": "warrants", + "ttl-seconds": 34560000 + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-hand-acts", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-hand-acts", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-hand-acts" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-hand-acts", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-hand-acts", + "binary": "mesh-hand-acts", + "loads": [ + "mesh-hand-acts" + ] + } + ] + } +}