diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go index 0ce55f26..fa5a0672 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go @@ -13,6 +13,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/views" @@ -105,6 +106,35 @@ var keyArgs = map[string]any{"type": "object", "properties": map[string]any{ "key": map[string]any{"type": "string", "description": "one key only"}, "prefix": map[string]any{"type": "string", "description": "only the keys that begin with it"}}} +// presenceArgs are the seat's input of presence, machine, beside the watchman's own key and prefix. +var presenceArgs = map[string]any{"type": "object", "properties": map[string]any{ + "machine": map[string]any{"type": "string", "description": "one machine: whether the watchman hears it and since when"}, + "key": map[string]any{"type": "string", "description": "one key only"}, + "prefix": map[string]any{"type": "string", "description": "only the keys that begin with it"}}} + +// presenceOf answers the seat's presence of one machine (meshwatchdogs.PresenceAnswer): online and since as the state +// presence holds it under the machine's key; a machine it holds no word of is not online, with no since (novox/hq ADR +// 0596 §2: a part asks this verb, never reads the state). +func presenceOf(st state, machine string) (any, error) { + e, err := st.Get(meshwatchdogs.PresenceByNodeKey(machine)) + if err != nil { + return nil, fmt.Errorf("presence %s cannot be read: %w", machine, err) + } + answer := meshwatchdogs.PresenceAnswer{Machine: machine} + if e == nil { + return answer, nil + } + v, err := meshwatchdogs.DecodePresenceByNodeValue(e.Value) + if err != nil { + return nil, fmt.Errorf("presence %s does not read: %w", machine, err) + } + answer.Online = v.Online + if !e.At.IsZero() { + answer.Since = e.At.UTC().Format(time.RFC3339Nano) + } + return answer, nil +} + // Tools are the seat's verbs and the module's tools, over the states states gives and the program asked through ask. func Tools(states func(string) state, ask func(socket.Ask) (json.RawMessage, error)) []stdio.Tool { read := func(name string) func(map[string]any) (any, error) { @@ -141,9 +171,15 @@ func Tools(states func(string) state, ask func(socket.Ask) (json.RawMessage, err Description: "What stopped moving: every key of the watchman's stuck bucket, . with what it waited for since " + "when, and whether it moved since (never deleted). A key not moved is also the watchman's finding " + "delivery..stuck, a node's presence machine..silent. Give key for one, prefix to narrow. Read-only."}, - {Name: verb(VerbPresence), Input: keyArgs, Run: read("presence"), + {Name: verb(VerbPresence), Input: presenceArgs, Run: func(args map[string]any) (any, error) { + if machine := str(args, "machine"); machine != "" { + return presenceOf(states("presence"), machine) + } + return read("presence")(args) + }, Description: "Who is online on the bus as the watchman hears it: each node's agent () and each delivery part's instance " + - "(part..), online or not since the entry's time. Give key for one, prefix to narrow. Read-only."}, + "(part..), online or not since the entry's time. Give machine for one machine's online and since, " + + "key for one key, prefix to narrow. Read-only."}, {Name: verb(VerbDeadLetters), Run: dead, Description: "What a consumer gave up on, kept in DEAD_LETTERS (novox/hq ADR 0564 §5, ADR 0264, ADR 0376): newest first, " + "for one consumer (its name or .), at most limit; one whole by id (a restricted one's body " + diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main_test.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main_test.go index 97a92628..d6100784 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main_test.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main_test.go @@ -4,9 +4,12 @@ import ( "encoding/json" "errors" "os" + "reflect" "slices" "testing" + "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" stdio "git.novox.be/novox/mesh-sdk/go" "mesh-delivery-watchman/internal/socket" @@ -27,7 +30,7 @@ func (f fakeState) Get(key string) (*stdio.StateEntry, error) { if !ok { return nil, nil } - return &stdio.StateEntry{Key: key, Value: json.RawMessage(v), Revision: 3}, nil + return &stdio.StateEntry{Key: key, Value: json.RawMessage(v), Revision: 3, At: time.Date(2026, 11, 10, 3, 0, 0, 0, time.UTC)}, nil } // 1952/WM1, ADR 0564 §12: every state the manifest keeps is read by a tool, every act the program starts by hand is @@ -51,7 +54,8 @@ func TestEveryStateAndActHasATool(t *testing.T) { t.Fatal(err) } asked := []socket.Ask{} - states := map[string]fakeState{"findings": {"machine.ace.silent": `{"v":1}`, "core.doctor.self-check-silent": `{"v":1}`}} + states := map[string]fakeState{"findings": {"machine.ace.silent": `{"v":1}`, "core.doctor.self-check-silent": `{"v":1}`}, + "presence": {"ace": `{"v":1,"online":true}`, "g14": `{"v":1,"online":false}`, "part.mesh-delivery-builder.ace": `{"v":1,"online":true}`}} tools := Tools(func(name string) state { return states[name] }, func(a socket.Ask) (json.RawMessage, error) { asked = append(asked, a) if a.Act != "" { @@ -126,6 +130,31 @@ func TestEveryStateAndActHasATool(t *testing.T) { t.Fatal("a key not held is answered") } }) + // Issue 1966, ADR 0596 §2 (review round 3): presence with the seat's machine answers that machine's online and since in + // the seat's declared answer; one the watchman holds no word of is not online; without it, the keys as before. + t.Run("presence answers one machine as the seat declares", func(t *testing.T) { + for machine, want := range map[string]meshwatchdogs.PresenceAnswer{ + "ace": {Machine: "ace", Online: true, Since: "2026-11-10T03:00:00Z"}, + "g14": {Machine: "g14", Since: "2026-11-10T03:00:00Z"}, + "novox": {Machine: "novox"}, + } { + out, err := served[Seat+"."+VerbPresence].Run(map[string]any{"machine": machine}) + if err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(out) + var got meshwatchdogs.PresenceAnswer + if err := json.Unmarshal(b, &got); err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("presence of %s = %s (%v), want %+v", machine, b, err, want) + } + } + out, _ := served[Seat+"."+VerbPresence].Run(map[string]any{"prefix": "part."}) + b, _ := json.Marshal(out) + var whole meshwatchdogs.PresenceAnswer + if err := json.Unmarshal(b, &whole); err != nil || whole.Holds != 3 || len(whole.Keys) != 1 || whole.State != "presence" { + t.Errorf("presence whole = %s (%v)", b, err) + } + }) t.Run("dead-letters and resync ask the program", func(t *testing.T) { if _, err := served[Seat+"."+VerbDeadLetters].Run(map[string]any{"consumer": "ace_web", "limit": float64(3), "id": float64(7)}); err != nil { t.Fatal(err)