Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86bcbc48ad |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
62
|
66
|
||||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ that repository's main (`build.artifacts[].context`); this module ships the pack
|
|||||||
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
|
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
|
||||||
same thing; retire that once this is assigned where it was.
|
same thing; retire that once this is assigned where it was.
|
||||||
|
|
||||||
A send that replaces the agent's container does not throw away the build it runs: the container declares
|
|
||||||
`busy` as `/usr/local/bin/mesh-builder busy` (novox/hq ADR 0335). The node-engine asks it before replacing it; from that
|
|
||||||
ask the agent takes no new build, the node-engine leaves it running while it says a build is in hand, and
|
|
||||||
replaces it once it says idle, or after 15 minutes at the latest.
|
|
||||||
|
|
||||||
## The package registry (novox/hq ADR 0336)
|
## The package registry (novox/hq ADR 0336)
|
||||||
|
|
||||||
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
|
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
|
||||||
|
|||||||
@@ -85,9 +85,6 @@
|
|||||||
"timeout": "5s",
|
"timeout": "5s",
|
||||||
"looks": 3,
|
"looks": 3,
|
||||||
"grace": "60s"
|
"grace": "60s"
|
||||||
},
|
|
||||||
"busy": {
|
|
||||||
"command": "/usr/local/bin/mesh-builder busy"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ Two directories, declared, so the mesh refuses a second module owning either:
|
|||||||
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
||||||
account of its own), that account's, `0700`. The module owns the directory —
|
account of its own), that account's, `0700`. The module owns the directory —
|
||||||
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||||
in it (history, projects, local settings, a person's own rules and skills) is the person's
|
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
||||||
and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module
|
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
||||||
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
|
|
||||||
removes a directory only when it is empty.
|
removes a directory only when it is empty.
|
||||||
|
|
||||||
And, for where the agent runs as an account of its own (hq ADR 0266):
|
And, for where the agent runs as an account of its own (hq ADR 0266):
|
||||||
@@ -70,37 +69,8 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
|||||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||||
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
|
|
||||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
||||||
|
|
||||||
## A session's memory follows the operator (hq ADR 0343)
|
|
||||||
|
|
||||||
A session started on another node starts with what the sessions on every other node learned (hq issue 367). The
|
|
||||||
module's long-running process carries each project's memory folder, `~/.claude/projects/<project>/memory/`, and
|
|
||||||
nothing else of the home. A project is one entry of the projects directory and a path is relative, no part empty,
|
|
||||||
`.` or `..`: a value in the state naming anything else is never taken, and nothing is touched for it.
|
|
||||||
|
|
||||||
- **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory`
|
|
||||||
state. Not carried, and listed with why: a symbolic link, a file over 128 KiB or too large for one value once
|
|
||||||
encoded, and a file holding a key block, a
|
|
||||||
signed token, a known token prefix or a password in an address.
|
|
||||||
- **Written** on every other node of the same home kind, only where its file is still the version the change
|
|
||||||
replaced, or the version it last carried (a node that was away), or there is none; staged in the folder and renamed in, never through a symbolic link. The version
|
|
||||||
replaced is kept first in `memory-copies/` in the module's state directory, 30 days.
|
|
||||||
- **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's
|
|
||||||
node and time, then the older (two versions too large for one file: the newer in it, the older beside it as
|
|
||||||
`<name>.older-from-<node>`); what was written is carried, so every node ends on it, and the operator is warned through
|
|
||||||
the operator channel's `notify` (a warning, cleared when the file next changes without a conflict).
|
|
||||||
- **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: a node still
|
|
||||||
holding the removed version moves it into the copies; a node that changed it keeps it and carries it again. A
|
|
||||||
missing folder, a key the state lost and a deleted key remove nothing, and neither does a file or folder that
|
|
||||||
cannot be read on a look: it is kept everywhere and listed as not carried.
|
|
||||||
- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among
|
|
||||||
themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator.
|
|
||||||
- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the
|
|
||||||
text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a
|
|
||||||
holder of a tool runner's credential is not yet told apart (hq issue 638).
|
|
||||||
|
|
||||||
## The mesh's tools first (hq ADR 0245)
|
## The mesh's tools first (hq ADR 0245)
|
||||||
|
|
||||||
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
||||||
@@ -179,12 +149,9 @@ said in the render's answer. What was registered before is still rendered and ma
|
|||||||
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
||||||
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
||||||
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
||||||
proves who answered (Telegram) carries either. The ask reads at a glance (hq issue 639): its explanation is one
|
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may
|
||||||
line saying what the proposal does — the proposer's `summary`, at most 300 characters, or the module's own words —
|
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with
|
||||||
and its Details, which the phone shows collapsed after the answers, carry the text (whole when it is short and every
|
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it.
|
||||||
line may leave the mesh, else its first lines), where it came from and its fingerprint; the whole text is read at the
|
|
||||||
controller's terminal with `mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. A proposal made before
|
|
||||||
that change is asked, and its warrant held, in the form it was made in (`form`). Decline, or no answer within a day, discards it.
|
|
||||||
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
||||||
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
||||||
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
|
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
|
||||||
|
|||||||
@@ -495,3 +495,22 @@ func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
|
|||||||
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
|
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tips preference reaches the managed settings as spinnerTipsEnabled, either way, and is left out when the
|
||||||
|
// mesh filled none: set in one call through the controller, never at the terminal.
|
||||||
|
func TestTheTipsPreferenceIsRenderedAsSpinnerTipsEnabled(t *testing.T) {
|
||||||
|
off, on := false, true
|
||||||
|
for _, c := range []struct {
|
||||||
|
tips *bool
|
||||||
|
want any
|
||||||
|
}{{&off, false}, {&on, true}, {nil, nil}} {
|
||||||
|
out := Render(Facts{Console: "x"}, Settings{Preferences: Preferences{Tips: c.tips}}, nil, "/h", nil, Config{})
|
||||||
|
var managed map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &managed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, has := managed["spinnerTipsEnabled"]; got != c.want || has != (c.want != nil) {
|
||||||
|
t.Errorf("tips %v rendered spinnerTipsEnabled %v (present %v)", c.tips, got, has)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,36 +152,6 @@ func (s stateOf) Create(key string, value any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||||
|
|
||||||
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
|
|
||||||
type memoryStateOf struct{ s stdio.KeptState }
|
|
||||||
|
|
||||||
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
||||||
e, err := s.s.Get(key)
|
|
||||||
if err != nil || e == nil {
|
|
||||||
return nil, 0, false, err
|
|
||||||
}
|
|
||||||
return e.Value, e.Revision, true, nil
|
|
||||||
}
|
|
||||||
func (s memoryStateOf) Create(key string, value any) error {
|
|
||||||
_, err := s.s.Create(key, value)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
|
|
||||||
_, err := s.s.Update(key, value, revision)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
|
|
||||||
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
|
|
||||||
|
|
||||||
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
|
|
||||||
var memoryCarrier *MemoryCarrier
|
|
||||||
|
|
||||||
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
|
|
||||||
func notifyOperator(args map[string]any) error {
|
|
||||||
_, err := ask("seat:operator-channel.notify", args)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
||||||
e, err := s.s.Get(key)
|
e, err := s.s.Get(key)
|
||||||
if err != nil || e == nil {
|
if err != nil || e == nil {
|
||||||
@@ -384,9 +354,6 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
|||||||
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
||||||
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
||||||
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
||||||
out = append(out, stdio.Tool{Name: "claude_code_memory",
|
|
||||||
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
|
|
||||||
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
|
|
||||||
out = append(out, proposalTools(p)...)
|
out = append(out, proposalTools(p)...)
|
||||||
judge := NewJudge(p)
|
judge := NewJudge(p)
|
||||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||||
@@ -463,12 +430,6 @@ func main() {
|
|||||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||||
Now: time.Now, Write: writeManaged}
|
Now: time.Now, Write: writeManaged}
|
||||||
go proposing.watch(p)
|
go proposing.watch(p)
|
||||||
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
|
|
||||||
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
|
|
||||||
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
|
|
||||||
return handle(c.Key, c.Op, c.Value, c.Revision)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
go run(p, view, configView)
|
go run(p, view, configView)
|
||||||
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||||
say("%v", err)
|
say("%v", err)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,968 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes
|
|
||||||
// and a state in memory with the bus's watch semantics: never the live memory folders.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every
|
|
||||||
// watcher, the writer included, as the bus does.
|
|
||||||
type memState struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
values map[string]json.RawMessage
|
|
||||||
revs map[string]uint64
|
|
||||||
rev uint64
|
|
||||||
pending []change
|
|
||||||
watchers []*MemoryCarrier
|
|
||||||
}
|
|
||||||
|
|
||||||
type change struct {
|
|
||||||
key, op string
|
|
||||||
value json.RawMessage
|
|
||||||
rev uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMemState() *memState {
|
|
||||||
return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
var errChanged = errors.New("wrong last revision")
|
|
||||||
|
|
||||||
func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
v, ok := s.values[key]
|
|
||||||
return v, s.revs[key], ok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) write(key string, value any) {
|
|
||||||
raw, _ := json.Marshal(value)
|
|
||||||
s.rev++
|
|
||||||
s.values[key], s.revs[key] = raw, s.rev
|
|
||||||
s.pending = append(s.pending, change{key, "put", raw, s.rev})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Create(key string, value any) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if _, ok := s.values[key]; ok {
|
|
||||||
return errChanged
|
|
||||||
}
|
|
||||||
s.write(key, value)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Update(key string, value any, revision uint64) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.revs[key] != revision {
|
|
||||||
return errChanged
|
|
||||||
}
|
|
||||||
s.write(key, value)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Keys() ([]string, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
var keys []string
|
|
||||||
for k := range s.values {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
return keys, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Delete(key string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
delete(s.values, key)
|
|
||||||
delete(s.revs, key)
|
|
||||||
s.rev++
|
|
||||||
s.pending = append(s.pending, change{key, "delete", nil, s.rev})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pump hands every change so far to every watcher, until none is left.
|
|
||||||
func (s *memState) pump(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
s.mu.Lock()
|
|
||||||
todo := s.pending
|
|
||||||
s.pending = nil
|
|
||||||
s.mu.Unlock()
|
|
||||||
if len(todo) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range todo {
|
|
||||||
for _, w := range s.watchers {
|
|
||||||
if err := w.Take(c.key, c.op, c.value, c.rev); err != nil {
|
|
||||||
t.Logf("%s taking %s: %v", w.Node, c.key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, w := range s.watchers {
|
|
||||||
if err := w.Drain(); err != nil && !errors.Is(err, errChanged) {
|
|
||||||
t.Logf("%s applying: %v", w.Node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("the state never settled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// clock is a clock a test moves.
|
|
||||||
type clock struct{ now time.Time }
|
|
||||||
|
|
||||||
func (c *clock) Now() time.Time { return c.now }
|
|
||||||
|
|
||||||
// memNode is one node's carrier over its own temporary home and state directory.
|
|
||||||
type memNode struct {
|
|
||||||
*MemoryCarrier
|
|
||||||
notices []map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode {
|
|
||||||
t.Helper()
|
|
||||||
n := &memNode{}
|
|
||||||
home := filepath.Join(root, name, "home")
|
|
||||||
stateDir := filepath.Join(root, name, "state")
|
|
||||||
for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} {
|
|
||||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now,
|
|
||||||
wake: make(chan struct{}, 1),
|
|
||||||
Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }}
|
|
||||||
state.watchers = append(state.watchers, n.MemoryCarrier)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) path(project, file string) string {
|
|
||||||
return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) {
|
|
||||||
t.Helper()
|
|
||||||
p := n.path(project, file)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_ = os.Chtimes(p, at, at)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) read(t *testing.T, project, file string) (string, bool) {
|
|
||||||
t.Helper()
|
|
||||||
raw, err := os.ReadFile(n.path(project, file))
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return string(raw), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) look(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
if err := n.Look(); err != nil && !errors.Is(err, errChanged) {
|
|
||||||
t.Fatalf("%s's look: %v", n.Node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copies is every text kept in the node's copies.
|
|
||||||
func (n *memNode) copies(t *testing.T) []string {
|
|
||||||
t.Helper()
|
|
||||||
var out []string
|
|
||||||
_ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error {
|
|
||||||
if err == nil && !d.IsDir() {
|
|
||||||
raw, _ := os.ReadFile(p)
|
|
||||||
out = append(out, string(raw))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func holdsText(list []string, s string) bool {
|
|
||||||
for _, x := range list {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = "-home-op-projects-novox-hq"
|
|
||||||
|
|
||||||
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
|
|
||||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
|
||||||
root := t.TempDir()
|
|
||||||
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// carried writes a file on g14 and carries it to shanks.
|
|
||||||
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
|
|
||||||
t.Helper()
|
|
||||||
a.write(t, project, file, text, c.now)
|
|
||||||
a.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
b.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := b.read(t, project, file); got != text {
|
|
||||||
t.Fatalf("not carried to %s: %q", b.Node, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
|
|
||||||
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
|
|
||||||
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
|
|
||||||
if got, ok := shanks.read(t, project, file); !ok || got != want {
|
|
||||||
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
before := state.rev
|
|
||||||
shanks.look(t)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if state.rev != before {
|
|
||||||
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
|
|
||||||
}
|
|
||||||
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
|
||||||
t.Fatalf("the change did not reach g14: %q", got)
|
|
||||||
}
|
|
||||||
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
|
|
||||||
t.Fatal("the version replaced on g14 was not kept")
|
|
||||||
}
|
|
||||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
|
||||||
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
|
|
||||||
}
|
|
||||||
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
|
|
||||||
raw, _ := json.Marshal(g14.Status())
|
|
||||||
if strings.Contains(string(raw), "accepted") {
|
|
||||||
t.Fatalf("the memory tool answered the text: %s", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
|
|
||||||
// listed with why.
|
|
||||||
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
|
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
|
||||||
for path, text := range map[string]string{
|
|
||||||
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
|
||||||
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
|
|
||||||
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
|
|
||||||
} {
|
|
||||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
|
|
||||||
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
|
|
||||||
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
|
|
||||||
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
|
||||||
outside := filepath.Join(t.TempDir(), "elsewhere.md")
|
|
||||||
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
|
|
||||||
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
|
||||||
if _, ok := shanks.read(t, project, file); ok {
|
|
||||||
t.Errorf("%s was carried", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, file := range []string{"MEMORY.md", "hash.md"} {
|
|
||||||
if _, ok := shanks.read(t, project, file); !ok {
|
|
||||||
t.Errorf("%s was not carried", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
|
||||||
t.Error("a session was carried")
|
|
||||||
}
|
|
||||||
for _, f := range []string{"settings.json", "history.jsonl"} {
|
|
||||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
|
|
||||||
t.Errorf("%s was carried", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys, _ := state.Keys()
|
|
||||||
if len(keys) != 2 {
|
|
||||||
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
|
||||||
if not[project+"/"+file] == "" {
|
|
||||||
t.Errorf("%s is not listed as not carried: %v", file, not)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §4: the operator's homes and an agent account's homes never take each other's memory.
|
|
||||||
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
|
||||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
|
||||||
root := t.TempDir()
|
|
||||||
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
|
|
||||||
control := newMemNode(t, root, "novox", HomeAgent, state, c)
|
|
||||||
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
|
|
||||||
control.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := laptop.read(t, project, "MEMORY.md"); ok {
|
|
||||||
t.Fatal("a confined agent's memory reached the operator's home")
|
|
||||||
}
|
|
||||||
laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now)
|
|
||||||
laptop.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" {
|
|
||||||
t.Fatalf("the operator's memory reached the agent account's home: %q", got)
|
|
||||||
}
|
|
||||||
// A value of the other kind put under this kind's key is refused too.
|
|
||||||
key := MemoryKey(HomeOperator, project, "x.md")
|
|
||||||
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
|
|
||||||
if err := laptop.Take(key, "put", raw, 99); err == nil {
|
|
||||||
t.Fatal("a value of another kind was taken")
|
|
||||||
}
|
|
||||||
// Which kind a node carries for.
|
|
||||||
if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator {
|
|
||||||
t.Fatalf("the operator's own home is %q", k)
|
|
||||||
}
|
|
||||||
if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent {
|
|
||||||
t.Fatalf("an agent account's home is %q", k)
|
|
||||||
}
|
|
||||||
if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" {
|
|
||||||
t.Fatalf("a confined agent in the operator's home carries %q", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
|
|
||||||
// one warning; it clears once the file is tidied.
|
|
||||||
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
|
|
||||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
|
|
||||||
c.now = c.now.Add(3 * time.Minute)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
a, _ := g14.read(t, project, "MEMORY.md")
|
|
||||||
b, _ := shanks.read(t, project, "MEMORY.md")
|
|
||||||
if a != b {
|
|
||||||
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
|
|
||||||
}
|
|
||||||
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
|
|
||||||
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
|
|
||||||
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
|
|
||||||
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
|
|
||||||
}
|
|
||||||
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
|
|
||||||
t.Fatalf("the line does not name both nodes:\n%s", a)
|
|
||||||
}
|
|
||||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
|
||||||
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
|
|
||||||
t.Fatalf("not one warning: %v", notices)
|
|
||||||
}
|
|
||||||
for _, field := range []string{"headline", "explanation", "summary", "resolved"} {
|
|
||||||
s, _ := notices[0][field].(string)
|
|
||||||
if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") {
|
|
||||||
t.Errorf("the warning's %s is empty or names a path: %q", field, s)
|
|
||||||
}
|
|
||||||
// The plain rule (ADR 0253 §3): it never sends the operator elsewhere.
|
|
||||||
if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) {
|
|
||||||
t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") {
|
|
||||||
t.Errorf("the explanation does not open with its verdict: %q", e)
|
|
||||||
}
|
|
||||||
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
|
|
||||||
t.Errorf("the headline is longer than sixty characters: %q", h)
|
|
||||||
}
|
|
||||||
all := append(g14.copies(t), shanks.copies(t)...)
|
|
||||||
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
|
|
||||||
if !holdsText(all, v) {
|
|
||||||
t.Errorf("the version %q was not kept", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
|
|
||||||
t.Fatalf("the conflict is not listed: %v", st)
|
|
||||||
}
|
|
||||||
// Tidied on either node, the warning clears.
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
cleared := false
|
|
||||||
for _, n := range append(g14.notices, shanks.notices...) {
|
|
||||||
if n["clear"] == true {
|
|
||||||
cleared = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !cleared {
|
|
||||||
t.Fatal("the warning did not clear once the file was tidied")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
|
|
||||||
t.Fatalf("the tidied file did not reach shanks: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The order of the two versions: the later change first, equal times by node name.
|
|
||||||
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
|
||||||
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
|
||||||
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
|
|
||||||
if strings.Index(m, "new") > strings.Index(m, "old") {
|
|
||||||
t.Fatalf("the newer is not first: %s", m)
|
|
||||||
}
|
|
||||||
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
|
|
||||||
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
|
|
||||||
t.Fatalf("equal times are not ordered by node name: %s", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a removal moves the other node's unchanged file into the copies.
|
|
||||||
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
|
||||||
if err := os.Remove(g14.path(project, "old.md")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
|
||||||
t.Fatal("the removal did not reach shanks")
|
|
||||||
}
|
|
||||||
if !holdsText(shanks.copies(t), "an old note\n") {
|
|
||||||
t.Fatal("the removed version was not kept on shanks")
|
|
||||||
}
|
|
||||||
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
|
|
||||||
var v MemoryVersion
|
|
||||||
_ = json.Unmarshal(raw, &v)
|
|
||||||
if !ok || !v.Removed || v.Text != "" {
|
|
||||||
t.Fatalf("the removal is not a marker in the state: %s", raw)
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatal("another file was touched")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a change outlives a removal, whichever reaches the state first.
|
|
||||||
func TestAChangeOutlivesARemoval(t *testing.T) {
|
|
||||||
for _, removalFirst := range []bool{true, false} {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
|
||||||
_ = os.Remove(g14.path(project, "note.md"))
|
|
||||||
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
|
||||||
if removalFirst {
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
shanks.look(t)
|
|
||||||
} else {
|
|
||||||
shanks.look(t)
|
|
||||||
g14.look(t)
|
|
||||||
}
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
|
|
||||||
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
|
|
||||||
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
|
||||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
|
||||||
// The folder gone on g14.
|
|
||||||
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatal("a missing folder removed the file elsewhere")
|
|
||||||
}
|
|
||||||
// A deleted key.
|
|
||||||
_ = state.Delete(key)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatal("a deleted key removed the file")
|
|
||||||
}
|
|
||||||
// The state lost: the file is carried again, not removed.
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, _, ok, _ := state.Get(key); !ok {
|
|
||||||
t.Fatal("a file whose key the state lost was not carried again")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatalf("the file was touched on shanks: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
|
|
||||||
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
|
||||||
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
|
|
||||||
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
|
|
||||||
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
|
|
||||||
shanks.write(t, project, "same.md", "same\n", c.now)
|
|
||||||
g14.write(t, project, "same.md", "same\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
index, _ := n.read(t, project, "MEMORY.md")
|
|
||||||
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
|
|
||||||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
|
|
||||||
t.Fatalf("%s's index does not hold both, the newer first:\n%s", n.Node, index)
|
|
||||||
}
|
|
||||||
for file, want := range map[string]string{"laptop.md": "only on the laptop\n", "workstation.md": "only on the workstation\n", "same.md": "same\n"} {
|
|
||||||
if got, _ := n.read(t, project, file); got != want {
|
|
||||||
t.Fatalf("%s's %s: %q", n.Node, file, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n := len(g14.notices) + len(shanks.notices); n != 1 {
|
|
||||||
t.Fatalf("%d warnings for one conflict", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
|
|
||||||
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
elsewhere := t.TempDir()
|
|
||||||
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
|
||||||
t.Fatalf("written through a symbolic link: %v", entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The manifest declares the state and the tool; the tool reads and never writes.
|
|
||||||
func TestTheManifestDeclaresTheMemoryState(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile("../../module.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
State []string `json:"state"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(raw, &m)
|
|
||||||
if !holdsText(m.State, MemoryState) {
|
|
||||||
t.Fatalf("the manifest does not declare the %q state: %v", MemoryState, m.State)
|
|
||||||
}
|
|
||||||
var carrier *MemoryCarrier
|
|
||||||
if st := carrier.Status(); st["carrying"] != false {
|
|
||||||
t.Fatalf("a carrier not started says it carries: %v", st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replay hands one node the whole current set, as a watch begins when the node comes back.
|
|
||||||
func (s *memState) replay(t *testing.T, n *memNode) {
|
|
||||||
t.Helper()
|
|
||||||
keys, _ := s.Keys()
|
|
||||||
for _, k := range keys {
|
|
||||||
raw, rev, _, _ := s.Get(k)
|
|
||||||
if err := n.Take(k, "put", raw, rev); err != nil {
|
|
||||||
t.Logf("%s taking %s: %v", n.Node, k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := n.Drain(); err != nil && !errors.Is(err, errChanged) {
|
|
||||||
t.Logf("%s applying: %v", n.Node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
|
|
||||||
// its own file holds no change it did not carry; the version replaced is kept.
|
|
||||||
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
|
|
||||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
|
||||||
for _, v := range []string{"v2\n", "v3\n"} {
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
g14.write(t, project, "MEMORY.md", v, c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
}
|
|
||||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
|
||||||
state.replay(t, shanks)
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
|
|
||||||
t.Fatalf("the node that was away holds %q", got)
|
|
||||||
}
|
|
||||||
if !holdsText(shanks.copies(t), "v1\n") {
|
|
||||||
t.Fatal("the version replaced was not kept")
|
|
||||||
}
|
|
||||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
|
||||||
t.Fatal("taking a newer version warned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
|
|
||||||
// both carried; and a text that would not fit in one value of the state is not carried.
|
|
||||||
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
|
|
||||||
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
|
|
||||||
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
|
|
||||||
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
|
|
||||||
c.now = c.now.Add(3 * time.Minute)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
beside := OlderBeside("big.md", "g14")
|
|
||||||
if beside != "big.older-from-g14.md" {
|
|
||||||
t.Fatalf("the older is named %q", beside)
|
|
||||||
}
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
if got, _ := n.read(t, project, "big.md"); got != newer {
|
|
||||||
t.Fatalf("%s's file does not hold the newer version", n.Node)
|
|
||||||
}
|
|
||||||
if got, _ := n.read(t, project, beside); got != older {
|
|
||||||
t.Fatalf("%s does not hold the older version beside it", n.Node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
|
||||||
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
|
|
||||||
t.Fatalf("not one warning saying the older is beside it: %v", notices)
|
|
||||||
}
|
|
||||||
// Under the size limit, but too large once encoded: not carried, and said.
|
|
||||||
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := shanks.read(t, project, "angles.md"); ok {
|
|
||||||
t.Fatal("a text too large for one value was carried")
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
if not[project+"/angles.md"] == "" {
|
|
||||||
t.Fatalf("it is not listed as not carried: %v", not)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateHolds says whether any value of the state holds s.
|
|
||||||
func (s *memState) holds(sub string) bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for _, v := range s.values {
|
|
||||||
var mv MemoryVersion
|
|
||||||
_ = json.Unmarshal(v, &mv)
|
|
||||||
if strings.Contains(mv.Text, sub) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// putRaw puts a value straight into the state, as a holder of a tool runner's credential could.
|
|
||||||
func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
|
|
||||||
t.Helper()
|
|
||||||
key := MemoryKey(v.Kind, v.Project, v.Path)
|
|
||||||
s.mu.Lock()
|
|
||||||
s.write(key, v)
|
|
||||||
s.mu.Unlock()
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
|
|
||||||
// written for it.
|
|
||||||
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
root := filepath.Dir(filepath.Dir(shanks.Home))
|
|
||||||
for _, bad := range []struct{ project, path string }{
|
|
||||||
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
|
|
||||||
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
|
|
||||||
} {
|
|
||||||
text := "planted\n"
|
|
||||||
state.putRaw(t, MemoryVersion{Kind: HomeOperator, Project: bad.project, Path: bad.path, Text: text,
|
|
||||||
Digest: digestOf([]byte(text)), Node: "elsewhere", Changed: c.now})
|
|
||||||
if NameProblem(bad.project, bad.path) == "" {
|
|
||||||
t.Errorf("%q %q is taken as a name", bad.project, bad.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
|
||||||
if err == nil && !d.IsDir() {
|
|
||||||
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
|
|
||||||
t.Errorf("a planted value was written at %s", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if NameProblem(project, "notes/a.md") != "" || NameProblem(project, "MEMORY.md") != "" {
|
|
||||||
t.Fatal("a memory file's own name is refused")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 2: a file or a folder that cannot be read is kept everywhere, never carried as a removal, and is
|
|
||||||
// said as not carried.
|
|
||||||
func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root reads a file whatever its mode")
|
|
||||||
}
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
|
|
||||||
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
|
|
||||||
_ = os.Chmod(file, 0)
|
|
||||||
_ = os.Chmod(folder, 0)
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
|
|
||||||
if _, ok := shanks.read(t, project, f); !ok {
|
|
||||||
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
if len(not) == 0 {
|
|
||||||
t.Fatalf("nothing is said as not carried: %v", not)
|
|
||||||
}
|
|
||||||
keys, _ := state.Keys()
|
|
||||||
for _, k := range keys {
|
|
||||||
raw, _, _, _ := state.Get(k)
|
|
||||||
var v MemoryVersion
|
|
||||||
_ = json.Unmarshal(raw, &v)
|
|
||||||
if v.Removed {
|
|
||||||
t.Errorf("%s/%s was carried as a removal", v.Project, v.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 3: no text reaches the state without being screened, on the removal and the conflict paths too.
|
|
||||||
func TestNoUnscreenedTextReachesTheState(t *testing.T) {
|
|
||||||
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
|
|
||||||
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
|
|
||||||
for _, text := range []string{token, big} {
|
|
||||||
// The removal path: removed on g14, changed on shanks to what is never carried.
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
|
||||||
_ = os.Remove(g14.path(project, "note.md"))
|
|
||||||
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if state.holds(text[:20]) {
|
|
||||||
t.Errorf("the removal path put text that is never carried into the state")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
|
||||||
t.Errorf("the change on shanks was not kept")
|
|
||||||
}
|
|
||||||
// The conflict path: changed on g14, and on shanks to what is never carried.
|
|
||||||
state, c, g14, shanks = twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
|
||||||
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if state.holds(text[:20]) {
|
|
||||||
t.Errorf("the conflict path put text that is never carried into the state")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
|
||||||
t.Errorf("shanks's own version was not kept as it was")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 4: a conflict whose file cannot be written leaves the state as it was, says so once, and merges
|
|
||||||
// once when the folder is writable again.
|
|
||||||
func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root writes a folder whatever its mode")
|
|
||||||
}
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
|
|
||||||
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
|
|
||||||
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
|
|
||||||
_ = os.Chmod(folder, 0o500)
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
_ = shanks.Look()
|
|
||||||
state.pump(t)
|
|
||||||
}
|
|
||||||
if state.holds("<!-- novox mesh") {
|
|
||||||
t.Fatal("a merge that could not be written here reached the state")
|
|
||||||
}
|
|
||||||
unwritten := 0
|
|
||||||
for _, n := range shanks.notices {
|
|
||||||
if strings.HasSuffix(n["key"].(string), ".unwritten") {
|
|
||||||
unwritten++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if unwritten != 1 {
|
|
||||||
t.Fatalf("%d warnings that the file could not be written", unwritten)
|
|
||||||
}
|
|
||||||
_ = os.Chmod(folder, 0o700)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
got, _ := n.read(t, project, "MEMORY.md")
|
|
||||||
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
|
|
||||||
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
|
|
||||||
// not UTF-8 is not carried.
|
|
||||||
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
_ = state
|
|
||||||
g14.write(t, project, "a.md", "read\n", c.now)
|
|
||||||
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
|
|
||||||
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
|
|
||||||
if !errors.Is(err, errChangedHere) {
|
|
||||||
t.Fatalf("written over a change: %v", err)
|
|
||||||
}
|
|
||||||
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
|
|
||||||
t.Fatalf("the change was replaced: %q", got)
|
|
||||||
}
|
|
||||||
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := shanks.read(t, project, "latin1.md"); ok {
|
|
||||||
t.Fatal("text that is not UTF-8 was carried")
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
|
|
||||||
t.Fatalf("it is not said why: %v", not)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
|
|
||||||
// its own: the removed file does not come back.
|
|
||||||
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
|
|
||||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
g14.write(t, project, "old.md", "v2\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
_ = os.Remove(g14.path(project, "old.md"))
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
|
||||||
state.replay(t, shanks)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
|
||||||
t.Fatal("the removal did not reach the node that was away")
|
|
||||||
}
|
|
||||||
if _, ok := g14.read(t, project, "old.md"); ok {
|
|
||||||
t.Fatal("the removed file came back")
|
|
||||||
}
|
|
||||||
if !holdsText(shanks.copies(t), "v1\n") {
|
|
||||||
t.Fatal("the version removed on the node that was away was not kept")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// slowState answers every read only once it is let go, as a state a whole bus away does under load.
|
|
||||||
type slowState struct {
|
|
||||||
*memState
|
|
||||||
gate chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
||||||
<-s.gate
|
|
||||||
return s.memState.Get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
|
|
||||||
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
|
|
||||||
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
|
|
||||||
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
|
|
||||||
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.mu.Lock()
|
|
||||||
state.pending = nil
|
|
||||||
state.mu.Unlock()
|
|
||||||
slow := slowState{memState: state, gate: make(chan struct{})}
|
|
||||||
shanks.Store = slow
|
|
||||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
|
||||||
raw, rev, _, _ := state.Get(key)
|
|
||||||
shanks.mu.Lock() // a look running
|
|
||||||
done := make(chan error, 1)
|
|
||||||
start := time.Now()
|
|
||||||
for i := 0; i < 400; i++ {
|
|
||||||
go func() { done <- shanks.Take(key, "put", raw, rev) }()
|
|
||||||
}
|
|
||||||
for i := 0; i < 400; i++ {
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("the watch handler waited for the look or the state (%d of 400 returned)", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if took := time.Since(start); took > time.Second {
|
|
||||||
t.Fatalf("400 values took %s to hand over", took)
|
|
||||||
}
|
|
||||||
shanks.mu.Unlock()
|
|
||||||
close(slow.gate)
|
|
||||||
if err := shanks.Drain(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatalf("what the watch noted was not applied: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -124,6 +124,7 @@ func HomeScopeRefused(p Paths, target string) string {
|
|||||||
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
||||||
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
||||||
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
||||||
|
func (p Paths) preferences() string { return filepath.Join(p.State, "preferences.json") }
|
||||||
func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") }
|
func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") }
|
||||||
func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") }
|
func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") }
|
||||||
|
|
||||||
@@ -218,6 +219,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
|||||||
}
|
}
|
||||||
var settings Settings
|
var settings Settings
|
||||||
readJSON(p.Settings, &settings)
|
readJSON(p.Settings, &settings)
|
||||||
|
readJSON(p.preferences(), &settings.Preferences)
|
||||||
var binding *Binding
|
var binding *Binding
|
||||||
var b Binding
|
var b Binding
|
||||||
if readJSON(p.binding(), &b) {
|
if readJSON(p.binding(), &b) {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
|||||||
text, _ = a["content"].(string)
|
text, _ = a["content"].(string)
|
||||||
}
|
}
|
||||||
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
||||||
Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now())
|
Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now())
|
||||||
}},
|
}},
|
||||||
{Name: "claude_code_proposals",
|
{Name: "claude_code_proposals",
|
||||||
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
|
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
|
||||||
@@ -164,14 +164,14 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
|||||||
"operator's word to you; it authorises no call that needs the operator's approval.",
|
"operator's word to you; it authorises no call that needs the operator's approval.",
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
||||||
"context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"),
|
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
|
||||||
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
||||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||||
"label": str("the button's words, at most 24 characters"),
|
"label": str("the button's words, at most 24 characters"),
|
||||||
"means": str("what you do when it is chosen, in plain words"),
|
"means": str("what you do when it is chosen, in plain words"),
|
||||||
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
||||||
}, "required": []string{"label", "means"}}},
|
}, "required": []string{"label", "means"}}},
|
||||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
"why": str("why the recommended option is the one you recommend"),
|
||||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||||
"expires_minutes": map[string]any{"type": "integer",
|
"expires_minutes": map[string]any{"type": "integer",
|
||||||
|
|||||||
@@ -104,12 +104,6 @@ type Proposal struct {
|
|||||||
Digest string `json:"digest"`
|
Digest string `json:"digest"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
// Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module
|
|
||||||
// says what it does (novox/hq issue 639).
|
|
||||||
Summary string `json:"summary,omitempty"`
|
|
||||||
// Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for
|
|
||||||
// a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance.
|
|
||||||
Form int `json:"form,omitempty"`
|
|
||||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||||
Asked string `json:"asked,omitempty"`
|
Asked string `json:"asked,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -170,109 +164,9 @@ func (p Proposal) Act(option string) asks.Act {
|
|||||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
|
|
||||||
const AskGlance = 1
|
|
||||||
|
|
||||||
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
||||||
// router's warrant to it — in the form it was asked in.
|
// router's warrant to it.
|
||||||
func (p Proposal) Ask() asks.Ask {
|
func (p Proposal) Ask() asks.Ask {
|
||||||
if p.Form == AskGlance {
|
|
||||||
return p.glanceAsk()
|
|
||||||
}
|
|
||||||
return p.listedAsk()
|
|
||||||
}
|
|
||||||
|
|
||||||
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
|
|
||||||
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
|
|
||||||
// Details, which the router notes after the answers, collapsed where the channel can.
|
|
||||||
func (p Proposal) glanceAsk() asks.Ask {
|
|
||||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
|
||||||
shown, whole := excerpt(p.Text)
|
|
||||||
part := ""
|
|
||||||
if p.Action != ActionRemove && !whole {
|
|
||||||
part = " Part shown"
|
|
||||||
}
|
|
||||||
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
|
|
||||||
if len([]rune(headline)) > asks.HeadlineLength {
|
|
||||||
headline = verb + " Claude instructions?" + part
|
|
||||||
}
|
|
||||||
var d strings.Builder
|
|
||||||
if p.Action == ActionRemove {
|
|
||||||
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
|
|
||||||
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
|
|
||||||
} else {
|
|
||||||
if whole {
|
|
||||||
d.WriteString("The text:\n" + shown + "\n")
|
|
||||||
} else {
|
|
||||||
d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
|
|
||||||
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
|
|
||||||
does := "the text becomes part of those sessions' instructions"
|
|
||||||
if p.Action == ActionRemove {
|
|
||||||
does = "the instructions are removed from those sessions"
|
|
||||||
}
|
|
||||||
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
|
|
||||||
}
|
|
||||||
|
|
||||||
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
|
|
||||||
func (p Proposal) summary() string {
|
|
||||||
if s := strings.TrimSpace(p.Summary); s != "" {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
lines := fmt.Sprintf("these %d lines", p.Lines())
|
|
||||||
if p.Lines() == 1 {
|
|
||||||
lines = "this line"
|
|
||||||
}
|
|
||||||
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
|
|
||||||
switch p.Action {
|
|
||||||
case ActionRemove:
|
|
||||||
return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name)
|
|
||||||
case ActionReplace:
|
|
||||||
return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+
|
|
||||||
"they are removed the same way.", where, lines, p.Name)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "".
|
|
||||||
func SummaryProblem(summary string) string {
|
|
||||||
switch s := strings.TrimSpace(summary); {
|
|
||||||
case s == "":
|
|
||||||
return ""
|
|
||||||
case strings.Contains(s, "\n"):
|
|
||||||
return "the summary is more than one line"
|
|
||||||
case len([]rune(s)) > asks.SummaryLength:
|
|
||||||
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
|
|
||||||
case !shownLine(s):
|
|
||||||
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
|
|
||||||
// options, each binding its act.
|
|
||||||
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
|
|
||||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
|
|
||||||
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
|
|
||||||
if len(p.Nodes) == 0 {
|
|
||||||
a.About = "instructions." + p.Scope + "." + p.Name
|
|
||||||
}
|
|
||||||
for _, o := range []struct{ id, label, does string }{
|
|
||||||
{OptionApprove, "Approve", does},
|
|
||||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
|
||||||
} {
|
|
||||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
|
||||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
|
|
||||||
// warrant of a proposal made then.
|
|
||||||
func (p Proposal) listedAsk() asks.Ask {
|
|
||||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||||
shown, whole := excerpt(p.Text)
|
shown, whole := excerpt(p.Text)
|
||||||
part := ""
|
part := ""
|
||||||
@@ -723,8 +617,6 @@ type ProposeInput struct {
|
|||||||
Name string
|
Name string
|
||||||
Text string
|
Text string
|
||||||
Remove bool
|
Remove bool
|
||||||
// Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it.
|
|
||||||
Summary string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
||||||
@@ -804,11 +696,7 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
||||||
if problem := SummaryProblem(in.Summary); problem != "" {
|
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
|
||||||
return refuse(problem)
|
|
||||||
}
|
|
||||||
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
|
|
||||||
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
|
|
||||||
switch {
|
switch {
|
||||||
case in.Remove:
|
case in.Remove:
|
||||||
if !isActive {
|
if !isActive {
|
||||||
|
|||||||
@@ -147,17 +147,14 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
|||||||
if err := a.Check(t0); err != nil {
|
if err := a.Check(t0); err != nil {
|
||||||
t.Fatalf("the router would refuse the ask: %v", err)
|
t.Fatalf("the router would refuse the ask: %v", err)
|
||||||
}
|
}
|
||||||
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
|
if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") {
|
||||||
// router notes collapsed after the answers.
|
t.Errorf("the phone does not say what is asked first: %q", a.Explanation)
|
||||||
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
|
|
||||||
asks.Glance(a.Explanation) != nil {
|
|
||||||
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
|
||||||
}
|
}
|
||||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
||||||
t.Errorf("headline: %q", a.Headline)
|
t.Errorf("headline: %q", a.Headline)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
|
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) {
|
||||||
t.Errorf("a short text is not shown whole: %q", a.Details)
|
t.Errorf("a short text is shown whole: %q", a.Explanation)
|
||||||
}
|
}
|
||||||
if len(a.Options) != 2 {
|
if len(a.Options) != 2 {
|
||||||
t.Fatalf("options: %+v", a.Options)
|
t.Fatalf("options: %+v", a.Options)
|
||||||
@@ -297,7 +294,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
rid := out["proposed"].(string)
|
rid := out["proposed"].(string)
|
||||||
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
|
if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") {
|
||||||
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
||||||
}
|
}
|
||||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
||||||
@@ -329,7 +326,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
id := out["proposed"].(string)
|
id := out["proposed"].(string)
|
||||||
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
|
if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") {
|
||||||
t.Errorf("%q", r.published[id].Explanation)
|
t.Errorf("%q", r.published[id].Explanation)
|
||||||
}
|
}
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||||
@@ -420,16 +417,15 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
id := out["proposed"].(string)
|
id := out["proposed"].(string)
|
||||||
a := r.published[id]
|
a := r.published[id]
|
||||||
if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[1 line(s) not shown here") ||
|
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") {
|
||||||
!strings.HasPrefix(a.Details, "NOT SHOWN IN FULL here.") || !strings.HasSuffix(a.Headline, "Part shown") {
|
t.Errorf("a line with a path was shown: %q", a.Explanation)
|
||||||
t.Errorf("a line with a path was shown, or the cut not said: %q", a.Details)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
|
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||||
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
|
!strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) {
|
||||||
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
|
t.Errorf("the phone does not say where to read all of it: %q", a.Explanation)
|
||||||
}
|
}
|
||||||
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
|
if len(a.Explanation) > 3000 {
|
||||||
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
|
t.Errorf("the explanation is %d bytes", len(a.Explanation))
|
||||||
}
|
}
|
||||||
list, err := ListProposals(p, store, r, id, t0)
|
list, err := ListProposals(p, store, r, id, t0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -743,33 +739,3 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
|||||||
t.Errorf("decided %q", got)
|
t.Errorf("decided %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
|
|
||||||
// asked, so the warrant for it still holds after the change.
|
|
||||||
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
|
||||||
p, _ := node(t, "g14")
|
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
|
||||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
|
||||||
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
id := out["proposed"].(string)
|
|
||||||
a := r.published[id]
|
|
||||||
if a.Explanation != "Every session learns how hand-overs are written, so the next one reads them the same way." {
|
|
||||||
t.Errorf("the proposer's summary is not the explanation: %q", a.Explanation)
|
|
||||||
}
|
|
||||||
for _, bad := range []string{strings.Repeat("word ", 70), "one\ntwo", "Read it in /etc/claude-code first."} {
|
|
||||||
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "other", Text: handover, Summary: bad}, store, r, t0); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "the summary") {
|
|
||||||
t.Errorf("the summary %q was taken: %v", bad, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prop, _ := readProposal(store, id)
|
|
||||||
older := prop
|
|
||||||
older.Form = 0
|
|
||||||
listed := older.Ask()
|
|
||||||
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
|
|
||||||
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ const (
|
|||||||
// The bounds of what a question says.
|
// The bounds of what a question says.
|
||||||
const (
|
const (
|
||||||
FewestQuestionOptions = 2
|
FewestQuestionOptions = 2
|
||||||
MostContextBytes = asks.SummaryLength
|
MostContextBytes = 1500
|
||||||
MostMeansBytes = 200
|
MostMeansBytes = 200
|
||||||
MostWhyBytes = 120
|
MostWhyBytes = 300
|
||||||
MostOnExpiryBytes = 200
|
MostOnExpiryBytes = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,8 +131,6 @@ type Question struct {
|
|||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
Expires time.Time `json:"expires"`
|
Expires time.Time `json:"expires"`
|
||||||
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
|
|
||||||
Form int `json:"form,omitempty"`
|
|
||||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||||
Asked string `json:"asked,omitempty"`
|
Asked string `json:"asked,omitempty"`
|
||||||
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
||||||
@@ -162,41 +160,8 @@ func (q Question) Act(option string) asks.Act {
|
|||||||
return act
|
return act
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
|
// Ask is the ask for a question, composed from it alone.
|
||||||
func (q Question) Ask() asks.Ask {
|
func (q Question) Ask() asks.Ask {
|
||||||
if q.Form == AskGlance {
|
|
||||||
return q.glanceAsk()
|
|
||||||
}
|
|
||||||
return q.listedAsk()
|
|
||||||
}
|
|
||||||
|
|
||||||
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
|
|
||||||
// answer and why as its one point; who asked and what the answer does are its Details.
|
|
||||||
func (q Question) glanceAsk() asks.Ask {
|
|
||||||
explanation := strings.TrimSpace(q.Context)
|
|
||||||
if rec, ok := q.recommended(); ok {
|
|
||||||
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
|
|
||||||
}
|
|
||||||
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
|
|
||||||
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
|
|
||||||
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q Question) withOptions(a asks.Ask) asks.Ask {
|
|
||||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
|
|
||||||
for _, o := range q.Options {
|
|
||||||
does := o.Means
|
|
||||||
if o.Recommended {
|
|
||||||
does = "Recommended. " + does
|
|
||||||
}
|
|
||||||
binds, _ := asks.ActDigest(q.Act(o.ID))
|
|
||||||
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
|
|
||||||
func (q Question) listedAsk() asks.Ask {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(strings.TrimSpace(q.Context))
|
b.WriteString(strings.TrimSpace(q.Context))
|
||||||
if rec, ok := q.recommended(); ok {
|
if rec, ok := q.recommended(); ok {
|
||||||
@@ -234,10 +199,8 @@ func QuestionProblem(q Question) string {
|
|||||||
switch {
|
switch {
|
||||||
case strings.TrimSpace(q.Context) == "":
|
case strings.TrimSpace(q.Context) == "":
|
||||||
add("it gives no context: say in plain words what the operator decides and why it matters")
|
add("it gives no context: say in plain words what the operator decides and why it matters")
|
||||||
case len([]rune(q.Context)) > MostContextBytes:
|
case len(q.Context) > MostContextBytes:
|
||||||
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
|
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
|
||||||
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
|
|
||||||
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
|
|
||||||
}
|
}
|
||||||
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
||||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
||||||
@@ -359,7 +322,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
|||||||
}
|
}
|
||||||
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
||||||
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
||||||
Expires: now.UTC().Add(life), Form: AskGlance}
|
Expires: now.UTC().Add(life)}
|
||||||
for i, o := range in.Options {
|
for i, o := range in.Options {
|
||||||
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
||||||
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
||||||
|
|||||||
@@ -68,13 +68,10 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
|||||||
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
|
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
|
||||||
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
||||||
}
|
}
|
||||||
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
|
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.",
|
||||||
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
|
"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||||
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
|
if !strings.Contains(a.Explanation, want) {
|
||||||
}
|
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation)
|
||||||
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
|
|
||||||
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
|
|
||||||
t.Errorf("the details do not say %q:\n%s", want, a.Details)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.Check(t0); err != nil {
|
if err := a.Check(t0); err != nil {
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ type Settings struct {
|
|||||||
MCPServers map[string]map[string]any `json:"mcp_servers"`
|
MCPServers map[string]map[string]any `json:"mcp_servers"`
|
||||||
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
|
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
|
||||||
ManagedSettings map[string]any `json:"managed_settings"`
|
ManagedSettings map[string]any `json:"managed_settings"`
|
||||||
|
// Preferences are this module's own preferences, from its untrusted preferences file: set in one call
|
||||||
|
// with the controller's `settings` verb, for the mesh or a node, like any module's preference (ADR 0262).
|
||||||
|
Preferences Preferences `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preferences are the module's preferences as the mesh filled them; nil is a value the file did not carry.
|
||||||
|
type Preferences struct {
|
||||||
|
// Tips is whether Claude Code shows a tip while it works (`spinnerTipsEnabled`).
|
||||||
|
Tips *bool `json:"tips"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binding is the licence this node holds, as it was last applied.
|
// Binding is the licence this node holds, as it was last applied.
|
||||||
@@ -121,6 +130,11 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
|||||||
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
||||||
// through this module's tools is laid over it (novox/hq issue 340).
|
// through this module's tools is laid over it (novox/hq issue 340).
|
||||||
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
|
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
|
||||||
|
// The module's preferences: keys of how a session looks, never of what it may do, so they are set in one
|
||||||
|
// call through the controller rather than at the terminal, and laid over the operator's keys of the same name.
|
||||||
|
if settings.Preferences.Tips != nil {
|
||||||
|
managed["spinnerTipsEnabled"] = *settings.Preferences.Tips
|
||||||
|
}
|
||||||
// The mesh's own keys are laid last: a setting never replaces them.
|
// The mesh's own keys are laid last: a setting never replaces them.
|
||||||
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
||||||
managed["allowAllClaudeAiMcps"] = true
|
managed["allowAllClaudeAiMcps"] = true
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ module claude-code
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"module": "claude-code",
|
"module": "claude-code",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"settings": {
|
||||||
|
"tips": {
|
||||||
|
"kind": "preference",
|
||||||
|
"default": true,
|
||||||
|
"why": "Claude Code shows a tip while it works; false turns tips off in every session on the machine, written as spinnerTipsEnabled in its managed settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
"slug": "agent",
|
"slug": "agent",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager"
|
"package-manager"
|
||||||
@@ -19,8 +26,7 @@
|
|||||||
"holdings",
|
"holdings",
|
||||||
"config",
|
"config",
|
||||||
"proposals",
|
"proposals",
|
||||||
"questions",
|
"questions"
|
||||||
"memory"
|
|
||||||
],
|
],
|
||||||
"reads": [
|
"reads": [
|
||||||
"claude-licence-manager.bindings"
|
"claude-licence-manager.bindings"
|
||||||
@@ -33,7 +39,6 @@
|
|||||||
"claude_code_grant",
|
"claude_code_grant",
|
||||||
"claude_code_add_api_key",
|
"claude_code_add_api_key",
|
||||||
"claude_code_registrations",
|
"claude_code_registrations",
|
||||||
"claude_code_memory",
|
|
||||||
"claude_code_mcp_list",
|
"claude_code_mcp_list",
|
||||||
"claude_code_mcp_register",
|
"claude_code_mcp_register",
|
||||||
"claude_code_mcp_unregister",
|
"claude_code_mcp_unregister",
|
||||||
@@ -148,6 +153,15 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
|
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preferences",
|
||||||
|
"trusted": false,
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/preferences.json",
|
||||||
|
"mode": "0600",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"content": "{\n \"tips\": ${setting:tips}\n}\n"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shell": [
|
"shell": [
|
||||||
|
|||||||
@@ -233,13 +233,7 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
|||||||
case m.Silent:
|
case m.Silent:
|
||||||
urgency = "low"
|
urgency = "low"
|
||||||
}
|
}
|
||||||
// The note — who asked, what each answer does, the expiry — follows the body after a blank line: a
|
args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "urgency": urgency, "app_name": "mesh"}
|
||||||
// notification has no smaller text, and the note is never left out (novox/hq issue 639).
|
|
||||||
text := strings.TrimSpace(m.Body)
|
|
||||||
if note := strings.TrimSpace(m.Note); note != "" {
|
|
||||||
text = strings.TrimSpace(text + "\n\n" + note)
|
|
||||||
}
|
|
||||||
args := map[string]any{"summary": m.Title, "body": html.EscapeString(text), "urgency": urgency, "app_name": "mesh"}
|
|
||||||
if replace != 0 {
|
if replace != 0 {
|
||||||
args["replace_id"] = replace
|
args["replace_id"] = replace
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,16 +103,6 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
|
|
||||||
func TestTheNoteFollowsTheBody(t *testing.T) {
|
|
||||||
d, shown, _, _ := newDesk(t, "laptop")
|
|
||||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
|
|
||||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
|
||||||
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
|
|
||||||
t.Errorf("the body: %q", last.args["body"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnEditReplacesTheNotification(t *testing.T) {
|
func TestAnEditReplacesTheNotification(t *testing.T) {
|
||||||
d, shown, _, _ := newDesk(t, "laptop")
|
d, shown, _, _ := newDesk(t, "laptop")
|
||||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module deskchannel
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||||
|
|||||||
@@ -1,8 +1,2 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac
|
|||||||
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
|
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
|
||||||
`tools/`, `provisioner/`). This README covers the two Go bundles beside it.
|
`tools/`, `provisioner/`). This README covers the two Go bundles beside it.
|
||||||
|
|
||||||
**What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the
|
|
||||||
private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every
|
|
||||||
tarball at: `https://` and the forge's route's composed public name, under the npm path
|
|
||||||
(`${bound:route:name-web}`, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a
|
|
||||||
locked tarball only from the registry it is configured with, so a consumer that installs from lock files
|
|
||||||
trusts that root. On a machine with no public domain the key is left out, and the plan says so.
|
|
||||||
|
|
||||||
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
|
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
|
||||||
|
|
||||||
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
|
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
|
||||||
|
|||||||
@@ -68,8 +68,7 @@
|
|||||||
"npm-package-registry": {
|
"npm-package-registry": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"npm-path": "/api/packages/novox/npm/",
|
"npm-path": "/api/packages/novox/npm/"
|
||||||
"public-root": "https://${bound:route:name-web}/api/packages/novox/npm/"
|
|
||||||
},
|
},
|
||||||
"git": {
|
"git": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
@@ -149,13 +148,6 @@
|
|||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"health": {
|
|
||||||
"kind": "http",
|
|
||||||
"endpoint": "web",
|
|
||||||
"path": "/api/healthz",
|
|
||||||
"status": 200,
|
|
||||||
"needs": "postgres-database"
|
|
||||||
},
|
|
||||||
"name": "gitea",
|
"name": "gitea",
|
||||||
"image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef",
|
"image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef",
|
||||||
"env": {
|
"env": {
|
||||||
|
|||||||
@@ -104,9 +104,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_LAB_WORK": "${dir:work}",
|
"MESH_LAB_WORK": "${dir:work}",
|
||||||
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env",
|
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
|
||||||
"MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json",
|
|
||||||
"MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BASE_IMAGE_ALIAS, BASE_MAX_AGE_DAYS, baseImageFrom, baseProblem, baseScript, buildingBase, cleanCredentials, credentialPath,
|
|
||||||
describeBase, launchRebuild, launchRun, listBaseBuilds, listRuns, readStatus, runRefusal, running, staleBase,
|
|
||||||
type BaseImage,
|
|
||||||
} from "../tools/runs.ts";
|
|
||||||
|
|
||||||
// Defends novox/hq issue 698: the lab's base image was 44 days old, a bed machine raised from it carried a
|
|
||||||
// package database the mirrors no longer served, and genesis-keeps-its-bus stopped on a 404 for
|
|
||||||
// postgresql-libs. Nothing said how old the image was, nothing refused it, and no mesh tool rebuilt it.
|
|
||||||
|
|
||||||
const now = new Date("2026-10-14T01:00:00Z");
|
|
||||||
const listed = (uploaded: string, alias = BASE_IMAGE_ALIAS) => JSON.stringify([
|
|
||||||
{ aliases: [{ name: "images:other" }], uploaded_at: "2026-10-13T00:00:00Z" },
|
|
||||||
{ aliases: [{ name: alias }], uploaded_at: uploaded, created_at: "2026-08-01T00:00:00Z" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
test("the base image's age is read from when this machine made it", () => {
|
|
||||||
const b = baseImageFrom(listed("2026-08-31T09:00:00Z"), now);
|
|
||||||
assert.equal(b.exists, true);
|
|
||||||
assert.equal(b.built, "2026-08-31T09:00:00.000Z");
|
|
||||||
assert.equal(b.ageDays, 43);
|
|
||||||
assert.match(describeBase(b), /built 2026-08-31T09:00:00.000Z \(43 day\(s\) ago; the bound is 14\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an image older than the bound refuses a run, naming the tool that rebuilds it", () => {
|
|
||||||
const stale = baseImageFrom(listed("2026-08-31T09:00:00Z"), now);
|
|
||||||
const reason = runRefusal(stale);
|
|
||||||
assert.ok(reason, "a 43-day-old base image was not refused");
|
|
||||||
assert.match(reason, /older than the lab's bound of 14 days/);
|
|
||||||
assert.match(reason, /lab_base \{"rebuild": true\}/);
|
|
||||||
assert.equal(staleBase(stale), reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an image within the bound runs", () => {
|
|
||||||
const fresh = baseImageFrom(listed(new Date(now.getTime() - BASE_MAX_AGE_DAYS * 86_400_000).toISOString()), now);
|
|
||||||
assert.equal(fresh.ageDays, BASE_MAX_AGE_DAYS);
|
|
||||||
assert.equal(runRefusal(fresh), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a missing image, or one whose age cannot be read, is said and not refused", () => {
|
|
||||||
const none = baseImageFrom(listed("2026-10-01T00:00:00Z", "something-else"), now);
|
|
||||||
assert.equal(none.exists, false);
|
|
||||||
assert.equal(runRefusal(none), undefined);
|
|
||||||
assert.match(describeBase(none), /is not on this machine; rebuild it with lab_base/);
|
|
||||||
const unread = baseImageFrom(undefined, now);
|
|
||||||
assert.equal(runRefusal(unread), undefined);
|
|
||||||
assert.match(describeBase(unread), /its age is not known/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no run starts while the base image is rebuilt", () => {
|
|
||||||
const fresh = baseImageFrom(listed("2026-10-13T00:00:00Z"), now);
|
|
||||||
const reason = runRefusal(fresh, { id: "base-x", started: "2026-10-14T00:59:00Z" });
|
|
||||||
assert.match(reason ?? "", /is being rebuilt \(base-x/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => {
|
|
||||||
const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials");
|
|
||||||
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/);
|
|
||||||
assert.match(s, /src\/cli.ts base build/);
|
|
||||||
assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a rebuild is known by its status, and is not a run", () => {
|
|
||||||
const work = mkdtempSync(join(tmpdir(), "lab-base-"));
|
|
||||||
try {
|
|
||||||
mkdirSync(join(work, "base-1"));
|
|
||||||
writeFileSync(join(work, "base-1", "status.json"), JSON.stringify({ id: "base-1", state: "built", started: "2026-10-13T00:00:00Z" }));
|
|
||||||
mkdirSync(join(work, "base-2"));
|
|
||||||
// A build whose process is gone while it says building was lost, and holds nothing up.
|
|
||||||
writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: 2 ** 22 + 7 }));
|
|
||||||
assert.deepEqual(listBaseBuilds(work).map((b) => `${b.id} ${b.state}`), ["base-2 lost", "base-1 built"]);
|
|
||||||
assert.equal(buildingBase(work), undefined);
|
|
||||||
writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: process.pid }));
|
|
||||||
assert.equal(buildingBase(work)?.id, "base-2");
|
|
||||||
assert.deepEqual(listRuns(work), []);
|
|
||||||
assert.ok(readFileSync(join(work, "base-1", "status.json"), "utf8"));
|
|
||||||
} finally {
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// **A run and a rebuild asked together: one starts** (review 256). Each asks the package registry, which
|
|
||||||
// waits, and each refused while the other was going; asked at once, both passed their checks before either
|
|
||||||
// wrote its status. The registry is asked first now, and the check and the start are one step.
|
|
||||||
function aLab(): string {
|
|
||||||
return mkdtempSync(join(tmpdir(), "lab-launch-"));
|
|
||||||
}
|
|
||||||
const fresh: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-10-13T00:00:00.000Z", ageDays: 1 };
|
|
||||||
const slowRegistry = () => new Promise<{ registry: string }>((ok) => setTimeout(() => ok({ registry: "https://forge.invalid/r/" }), 20));
|
|
||||||
const launch = { registry: slowRegistry, base: () => fresh };
|
|
||||||
function beginRun(work: string, id: string) {
|
|
||||||
return () => {
|
|
||||||
mkdirSync(join(work, id));
|
|
||||||
const s = { id, state: "running" as const, started: new Date().toISOString(), tests: [], refs: {}, pid: process.pid };
|
|
||||||
writeFileSync(join(work, id, "status.json"), JSON.stringify(s));
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function beginBuild(work: string, id: string) {
|
|
||||||
return () => {
|
|
||||||
mkdirSync(join(work, id));
|
|
||||||
const b = { id, state: "building" as const, started: new Date().toISOString(), pid: process.pid };
|
|
||||||
writeFileSync(join(work, id, "status.json"), JSON.stringify(b));
|
|
||||||
return b;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a run and a rebuild asked at once: exactly one starts", async () => {
|
|
||||||
for (const runFirst of [true, false]) {
|
|
||||||
const work = aLab();
|
|
||||||
try {
|
|
||||||
// Both asked before either answers, in one order and then the other.
|
|
||||||
const ask = { run: () => launchRun(work, launch, beginRun(work, "run-a")), build: () => launchRebuild(work, launch, beginBuild(work, "base-a")) };
|
|
||||||
const [r, b] = runFirst ? await Promise.all([ask.run(), ask.build()])
|
|
||||||
: (await Promise.all([ask.build(), ask.run()])).reverse() as [Awaited<ReturnType<typeof ask.run>>, Awaited<ReturnType<typeof ask.build>>];
|
|
||||||
assert.equal([r.started, b.started].filter(Boolean).length, 1, `both or neither started: run ${r.started}, rebuild ${b.started}`);
|
|
||||||
assert.equal(listRuns(work).length + listBaseBuilds(work).length, 1);
|
|
||||||
} finally {
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("two runs, or two rebuilds, asked at once: one of each starts", async () => {
|
|
||||||
const work = aLab();
|
|
||||||
try {
|
|
||||||
const runs = await Promise.all([launchRun(work, launch, beginRun(work, "run-a")), launchRun(work, launch, beginRun(work, "run-b"))]);
|
|
||||||
assert.deepEqual(runs.map((r) => r.started).sort(), [false, true]);
|
|
||||||
const other = aLab();
|
|
||||||
const builds = await Promise.all([launchRebuild(other, launch, beginBuild(other, "base-a")), launchRebuild(other, launch, beginBuild(other, "base-b"))]);
|
|
||||||
assert.deepEqual(builds.map((b) => b.started).sort(), [false, true]);
|
|
||||||
rmSync(other, { recursive: true, force: true });
|
|
||||||
} finally {
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a run is refused on a stale base, and starts nothing", async () => {
|
|
||||||
const work = aLab();
|
|
||||||
try {
|
|
||||||
const stale: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-08-31T09:00:00.000Z", ageDays: 43 };
|
|
||||||
const r = await launchRun(work, { registry: slowRegistry, base: () => stale }, beginRun(work, "run-a"));
|
|
||||||
assert.equal(r.started, false);
|
|
||||||
assert.match(r.started ? "" : r.reason, /lab_base \{"rebuild": true\}/);
|
|
||||||
assert.deepEqual(listRuns(work), []);
|
|
||||||
} finally {
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a rebuild or a run that never recorded its process is lost, and holds nothing up", () => {
|
|
||||||
const work = aLab();
|
|
||||||
try {
|
|
||||||
mkdirSync(join(work, "base-x"));
|
|
||||||
writeFileSync(join(work, "base-x", "status.json"), JSON.stringify({ id: "base-x", state: "building", started: "2026-10-14T00:00:00Z" }));
|
|
||||||
mkdirSync(join(work, "run-x"));
|
|
||||||
writeFileSync(join(work, "run-x", "status.json"), JSON.stringify({ id: "run-x", state: "running", started: "2026-10-14T00:00:00Z", tests: [], refs: {} }));
|
|
||||||
assert.equal(listBaseBuilds(work)[0]?.state, "lost");
|
|
||||||
assert.equal(buildingBase(work), undefined);
|
|
||||||
assert.equal(readStatus(work, "run-x")?.state, "lost");
|
|
||||||
assert.equal(running(work), undefined);
|
|
||||||
} finally {
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a rebuild's credential left behind is cleaned, and a running one's is kept", () => {
|
|
||||||
const work = aLab();
|
|
||||||
try {
|
|
||||||
for (const id of ["base-old", "base-now", "run-old", "run-now"]) writeFileSync(credentialPath(work, id), "x");
|
|
||||||
const removed = cleanCredentials(work, [credentialPath(work, "base-now"), credentialPath(work, "run-now")]).sort();
|
|
||||||
assert.deepEqual(removed, [credentialPath(work, "base-old"), credentialPath(work, "run-old")].sort());
|
|
||||||
} finally {
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lab_check's problem: a stale, missing or unread base image; a fresh one is none", () => {
|
|
||||||
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false }) ?? "", /is not on this machine/);
|
|
||||||
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false, problem: "incus could not list the lab's images" }) ?? "", /age is not known/);
|
|
||||||
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: true, built: "x", ageDays: 30 }) ?? "", /older than the lab's bound/);
|
|
||||||
assert.equal(baseProblem(fresh), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The tools use the gate and the check above (index.ts registers with the SDK when imported, so it is read).
|
|
||||||
test("lab_run and lab_base start through the gate, and lab_check names what is wrong with the base", () => {
|
|
||||||
const index = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8");
|
|
||||||
assert.match(index, /await launchRun\(work,/);
|
|
||||||
assert.match(index, /await launchRebuild\(work,/);
|
|
||||||
assert.match(index, /const unfit = baseProblem\(base\);\s*if \(unfit\) problems\.push\(unfit\)/);
|
|
||||||
assert.match(index, /if \(args\.rebuild !== true\) return reading;/);
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, REPOSITORIES, script } from "../tools/runs.ts";
|
import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts";
|
||||||
|
|
||||||
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
|
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
|
||||||
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
|
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
|
||||||
@@ -194,295 +194,3 @@ test("the lab asks the package registry for read only", () => {
|
|||||||
assert.ok(m.requires.includes("npm-package-registry"));
|
assert.ok(m.requires.includes("npm-package-registry"));
|
||||||
assert.equal(m.contributes?.["npm-package-registry"]?.access, "read");
|
assert.equal(m.contributes?.["npm-package-registry"]?.access, "read");
|
||||||
});
|
});
|
||||||
|
|
||||||
// lab_check failed for ever on "destination path '<work>/check' already exists": it cloned into one fixed
|
|
||||||
// path and ignored whether removing the last one worked (novox/hq issue 624). Each check now works in a
|
|
||||||
// fresh directory, and what earlier checks left is cleared or named.
|
|
||||||
import { checkDir, clearChecks } from "../tools/runs.ts";
|
|
||||||
|
|
||||||
test("each check gets a fresh directory of its own", () => {
|
|
||||||
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
|
||||||
const a = checkDir(work);
|
|
||||||
const b = checkDir(work);
|
|
||||||
assert.notEqual(a, b);
|
|
||||||
assert.ok(a.startsWith(join(work, "check-")) && existsSync(a) && existsSync(b));
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// As root nothing is locked against removal, so the unremovable tree cannot be made: skipped there, before
|
|
||||||
// it makes anything (the merge check runs as root).
|
|
||||||
test("what earlier checks left is cleared, and what cannot be is named rather than tripped over", {
|
|
||||||
skip: process.getuid?.() === 0 ? "running as root, which no directory mode stops from removing anything" : false,
|
|
||||||
}, () => {
|
|
||||||
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
|
||||||
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x", "keep"]) mkdirSync(join(work, d), { recursive: true });
|
|
||||||
// A tree this account cannot empty: a directory it may not write, with something in it.
|
|
||||||
mkdirSync(join(work, "check-stuck", "locked"), { recursive: true });
|
|
||||||
writeFileSync(join(work, "check-stuck", "locked", "file"), "x");
|
|
||||||
chmodSync(join(work, "check-stuck", "locked"), 0o555);
|
|
||||||
const said = clearChecks(work);
|
|
||||||
chmodSync(join(work, "check-stuck", "locked"), 0o755);
|
|
||||||
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
|
|
||||||
assert.ok(existsSync(join(work, "run-x")) && existsSync(join(work, "keep")));
|
|
||||||
assert.equal(said.length, 1);
|
|
||||||
assert.match(said[0] as string, /check-stuck, left by an earlier check, could not be removed/);
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// And as root too: what earlier checks left is cleared.
|
|
||||||
test("leftover checks are cleared, whoever runs the check", () => {
|
|
||||||
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
|
||||||
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x"]) mkdirSync(join(work, d), { recursive: true });
|
|
||||||
assert.deepEqual(clearChecks(work), []);
|
|
||||||
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
|
|
||||||
assert.ok(existsSync(join(work, "run-x")));
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// The lab's tools read the bound credential, so the runtime is given that file by the tools bundle's env
|
|
||||||
// (ADR 0192), as gitea and umami are given theirs: the runtime's account may then read exactly it, and no
|
|
||||||
// other credential changes hands (issue 624).
|
|
||||||
test("the lab's tools bundle names its bound credential and binding in its env", () => {
|
|
||||||
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
|
|
||||||
secrets: Record<string, string>; binds: Record<string, string>;
|
|
||||||
build: { artifacts: { name: string; env?: Record<string, string> }[] };
|
|
||||||
};
|
|
||||||
const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {};
|
|
||||||
assert.equal(env.MESH_LAB_FORGE_SECRET, m.secrets["npm-package-registry"]);
|
|
||||||
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
|
|
||||||
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
|
|
||||||
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
|
|
||||||
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
|
|
||||||
// A run names the registry by the address the registry names itself by.
|
|
||||||
import { npmRegistry } from "../tools/runs.ts";
|
|
||||||
|
|
||||||
function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fetch; asked: string[] } {
|
|
||||||
const asked: string[] = [];
|
|
||||||
const get = (async (url: string | URL | Request) => {
|
|
||||||
asked.push(String(url));
|
|
||||||
return answer();
|
|
||||||
}) as typeof fetch;
|
|
||||||
return { get, asked };
|
|
||||||
}
|
|
||||||
|
|
||||||
const packument = (tarball: string) => new Response(JSON.stringify({
|
|
||||||
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
|
|
||||||
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
|
||||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
||||||
|
|
||||||
test("a run names the package registry by the address the registry gives its tarballs", async () => {
|
|
||||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
|
||||||
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
|
|
||||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
|
|
||||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
|
|
||||||
// The forge's own scheme is taken too, with its port.
|
|
||||||
const plain = await npmRegistry("http://forge.example.org:3000",
|
|
||||||
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
|
||||||
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
|
|
||||||
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
|
|
||||||
// it (review of mesh-catalog #275, ADR 0346).
|
|
||||||
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
|
|
||||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
|
||||||
for (const [answer, why] of [
|
|
||||||
[() => new Response("not found", { status: 404 }), /it answered 404/],
|
|
||||||
[() => new Response("{}", { status: 200 }), /no tarball/],
|
|
||||||
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
|
|
||||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
|
|
||||||
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
|
|
||||||
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
|
|
||||||
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
|
|
||||||
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
|
|
||||||
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
|
|
||||||
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
|
|
||||||
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
|
|
||||||
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
|
||||||
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
|
|
||||||
] as [() => Response, RegExp][]) {
|
|
||||||
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
|
|
||||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
|
|
||||||
assert.match(got.problem ?? "", /did not give an address the lab takes/);
|
|
||||||
assert.match(got.problem ?? "", why);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
|
|
||||||
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
|
|
||||||
"https://forge.example.org/api/packages/novox/npm/");
|
|
||||||
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
|
||||||
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
|
|
||||||
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
|
|
||||||
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
|
|
||||||
// tarball may come from, by scheme, host and port: the review of #275 found the port ignored.
|
|
||||||
import { publicRootOf } from "../tools/runs.ts";
|
|
||||||
|
|
||||||
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
|
|
||||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
|
||||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
|
||||||
const at = (tarball: string, publicRoot?: string) =>
|
|
||||||
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
|
|
||||||
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
|
|
||||||
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
|
|
||||||
const got = await at(tarball, root);
|
|
||||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
|
|
||||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
|
|
||||||
}
|
|
||||||
// Without a public root, the forge's public name is not trusted on its own word.
|
|
||||||
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
|
|
||||||
// And the forge's own address only at its own port.
|
|
||||||
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "lab-root-"));
|
|
||||||
const binding = (root: unknown) => {
|
|
||||||
const f = join(dir, `b-${Math.random()}.json`);
|
|
||||||
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
|
|
||||||
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
|
||||||
return f;
|
|
||||||
};
|
|
||||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
|
|
||||||
{ root: "https://git.example.org/api/packages/novox/npm/" });
|
|
||||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
|
|
||||||
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
|
|
||||||
for (const [root, why] of [
|
|
||||||
[undefined, /serves no public-root/],
|
|
||||||
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
|
||||||
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
|
|
||||||
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
|
|
||||||
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
|
||||||
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
|
|
||||||
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
|
|
||||||
] as [unknown, RegExp][]) {
|
|
||||||
const got = publicRootOf(binding(root));
|
|
||||||
assert.equal(got.root, undefined, String(root));
|
|
||||||
assert.match(got.problem ?? "", why, String(root));
|
|
||||||
}
|
|
||||||
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
|
|
||||||
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
|
|
||||||
// is "null", as is a `file:` forge's, and the two must not match.
|
|
||||||
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
|
|
||||||
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
|
|
||||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
|
|
||||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
|
||||||
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
|
|
||||||
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
|
|
||||||
assert.match(viaRoot.problem ?? "", /neither/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0,
|
|
||||||
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it.
|
|
||||||
const oldLog = [
|
|
||||||
"STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.",
|
|
||||||
"\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:",
|
|
||||||
" \t\t\t\tMake sure you have all the referenced content and try again.",
|
|
||||||
"NotFound: content digest sha256:e2de96513ba9: not found",
|
|
||||||
"ℹ tests 1", "ℹ suites 0", "ℹ pass 0", "ℹ fail 0", "ℹ cancelled 0", "ℹ skipped 1", "ℹ todo 0",
|
|
||||||
"ℹ duration_ms 590254.163993",
|
|
||||||
"",
|
|
||||||
"recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, against mesh-lab 7ed7da1",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
// The same run, as the lab's suite says it once it names its skips (mesh-lab, issue 693).
|
|
||||||
const newLog = [
|
|
||||||
"ℹ tests 1", "ℹ pass 0", "ℹ fail 0", "ℹ skipped 1",
|
|
||||||
"",
|
|
||||||
"recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, 1 skipped, against mesh-lab 7ed7da1",
|
|
||||||
"NOT PASSED: no test passed: 1 skipped, 1 of them as NOT REACHED",
|
|
||||||
"skipped: genesis adopts its bus — one bus (test/integration/genesis-keeps-its-bus.test.ts) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
function anEndedRun(log: string, state: "passed" | "failed", exit: number): { work: string; id: string } {
|
|
||||||
const work = mkdtempSync(join(tmpdir(), "lab-runs-"));
|
|
||||||
const id = "run-2026-10-14T00-21-44-014Z";
|
|
||||||
mkdirSync(join(work, id));
|
|
||||||
writeFileSync(join(work, id, "run.log"), log);
|
|
||||||
writeFileSync(join(work, id, "status.json"), JSON.stringify({
|
|
||||||
id, state, exit, started: "2026-10-14T00:21:44.014Z", ended: "2026-10-14T00:33:11.179Z",
|
|
||||||
tests: ["test/integration/genesis-keeps-its-bus.test.ts"], refs: {},
|
|
||||||
}));
|
|
||||||
return { work, id };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a run whose only test skipped as NOT REACHED, from a lab of before, reads as failed with the reason", () => {
|
|
||||||
const { work, id } = anEndedRun(oldLog, "passed", 0);
|
|
||||||
const s = readStatus(work, id);
|
|
||||||
assert.equal(s?.state, "failed", JSON.stringify(s));
|
|
||||||
assert.equal(s?.exit, 0);
|
|
||||||
assert.equal(s?.outcome?.passed, 0);
|
|
||||||
assert.equal(s?.outcome?.skipped, 1);
|
|
||||||
assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
|
|
||||||
assert.match(s?.outcome?.skips[0]?.reason ?? "", /^ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:$/);
|
|
||||||
// Kept in the run's record, not only answered.
|
|
||||||
const kept = JSON.parse(readFileSync(join(work, id, "status.json"), "utf8"));
|
|
||||||
assert.equal(kept.state, "failed");
|
|
||||||
assert.equal(kept.outcome.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the lab's own NOT PASSED and skipped lines are read, a test name's punctuation and all", () => {
|
|
||||||
const { work, id } = anEndedRun(newLog, "failed", 1);
|
|
||||||
const s = readStatus(work, id);
|
|
||||||
assert.equal(s?.state, "failed");
|
|
||||||
assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
|
|
||||||
assert.deepEqual(s?.outcome?.skips, [{
|
|
||||||
file: "test/integration/genesis-keeps-its-bus.test.ts",
|
|
||||||
test: "genesis adopts its bus — one bus",
|
|
||||||
reason: "ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest",
|
|
||||||
}]);
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a run that passed tests and skipped one for a declared reason stays passed, its skip listed", () => {
|
|
||||||
const log = [
|
|
||||||
"✔ a module lands (12ms)",
|
|
||||||
"﹣ a builder builds (0.1ms) # set MESH_LAB_BUILDER to a built mesh-builder",
|
|
||||||
"ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1",
|
|
||||||
].join("\n");
|
|
||||||
const { work, id } = anEndedRun(log, "passed", 0);
|
|
||||||
const s = readStatus(work, id);
|
|
||||||
assert.equal(s?.state, "passed");
|
|
||||||
assert.equal(s?.outcome?.notPassed, undefined);
|
|
||||||
assert.equal(s?.outcome?.skips[0]?.reason, "set MESH_LAB_BUILDER to a built mesh-builder");
|
|
||||||
rmSync(work, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a run that ran no test at all is not passed", () => {
|
|
||||||
assert.equal(outcomeOf("ℹ tests 0\nℹ pass 0\nℹ fail 0\nℹ skipped 0\n").notPassed, "no test passed: none ran");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Review 254 on issue 693: a test's own output, printed before the suite reports, is not the suite's verdict.
|
|
||||||
test("a line a test printed that looks like the suite's is not read as the suite's", () => {
|
|
||||||
const log = [
|
|
||||||
"NOT PASSED: a test printed this",
|
|
||||||
"skipped: a forged skip (test/integration/x.test.ts) # FORGED NOT REACHED",
|
|
||||||
"✔ a module lands (12ms)",
|
|
||||||
"ℹ tests 1", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 0",
|
|
||||||
"",
|
|
||||||
"recorded: test/integration/x.test.ts — 1 passed, 0 failed, 0 skipped, against mesh-lab 7ed7da1",
|
|
||||||
].join("\n");
|
|
||||||
const o = outcomeOf(log);
|
|
||||||
assert.equal(o.notPassed, undefined, JSON.stringify(o));
|
|
||||||
assert.deepEqual(o.skips, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("not reached is matched in any case", () => {
|
|
||||||
const log = [
|
|
||||||
"✔ a module lands (12ms)",
|
|
||||||
"﹣ the broker step (0.1ms) # the broker step was not reached",
|
|
||||||
"ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1",
|
|
||||||
].join("\n");
|
|
||||||
assert.equal(outcomeOf(log).notPassed, "1 test(s) skipped as NOT REACHED: their subject was not reached");
|
|
||||||
});
|
|
||||||
|
|||||||
+16
-76
@@ -8,9 +8,8 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
cleanCredentials, credentialArgs, forgeCredential, installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start,
|
||||||
stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun,
|
stop, tail, writeCredential,
|
||||||
launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem,
|
|
||||||
} from "./runs.js";
|
} from "./runs.js";
|
||||||
|
|
||||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||||
@@ -20,14 +19,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||||
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
|
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
|
||||||
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
|
|
||||||
const bindingOf = (): string | undefined =>
|
|
||||||
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
|
|
||||||
const registryOf = async (forge: string) => {
|
|
||||||
const root = publicRootOf(bindingOf());
|
|
||||||
const registry = await npmRegistry(forge, fetch, root.root);
|
|
||||||
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
|
|
||||||
};
|
|
||||||
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
|
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
|
||||||
env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"),
|
env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"),
|
||||||
env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET"));
|
env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET"));
|
||||||
@@ -39,39 +30,22 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
const forge = forgeOf();
|
const forge = forgeOf();
|
||||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||||
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
|
const dir = `${work}/check`;
|
||||||
// directory of its own either way, and removes it when it is done.
|
spawnSync("rm", ["-rf", dir]);
|
||||||
const leftovers = clearChecks(work);
|
|
||||||
const dir = checkDir(work);
|
|
||||||
let check: ReturnType<typeof spawnSync>;
|
|
||||||
let problems: string[];
|
|
||||||
try {
|
|
||||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
||||||
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
|
if (clone.status !== 0) return { ok: false, output: clone.stderr };
|
||||||
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
||||||
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
||||||
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
|
|
||||||
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
|
|
||||||
const registry = await registryOf(forge);
|
|
||||||
if (registry.problem) problems.push(registry.problem);
|
|
||||||
} finally {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
|
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
|
||||||
// repository it clones answers on the forge, and every one it installs has a lock file where the run
|
// repository it clones answers on the forge, and every one it installs has a lock file where the run
|
||||||
// installs from.
|
// installs from.
|
||||||
// A credential a stopped, crashed or rebooted run or rebuild left behind is removed, and said; one still in
|
// A credential a stopped, crashed or rebooted run left behind is removed, and said; one still in use
|
||||||
// use by a running run or rebuild is not.
|
// by a running run is not.
|
||||||
const busy = [running(work)?.id, buildingBase(work)?.id].filter((id): id is string => !!id);
|
const busy = running(work);
|
||||||
const left = cleanCredentials(work, busy.map((id) => credentialPath(work, id)));
|
const left = cleanCredentials(work, busy ? `${work}/.${busy.id}.git-credentials` : undefined);
|
||||||
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run or rebuild left behind: ${left.join(", ")}` : "";
|
const problems = setupProblems(forge, `${work}/check-setup`, credentialOf(forge));
|
||||||
// **And how old the base image is** (novox/hq issue 698): past the bound, missing, or of an age that cannot
|
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run left behind: ${left.join(", ")}` : "";
|
||||||
// be read is a problem here, though lab_run refuses only the first.
|
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...problems].filter(Boolean).join("\n");
|
||||||
const base = readBaseImage();
|
|
||||||
const unfit = baseProblem(base);
|
|
||||||
if (unfit) problems.push(unfit);
|
|
||||||
const output = [`${check.stdout}${check.stderr}`.trim(), describeBase(base), removed, ...leftovers, ...problems]
|
|
||||||
.filter(Boolean).join("\n");
|
|
||||||
return { ok: check.status === 0 && problems.length === 0, output };
|
return { ok: check.status === 0 && problems.length === 0, output };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -103,43 +77,9 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
}
|
}
|
||||||
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
|
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
|
||||||
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
||||||
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
|
const busy = running(work);
|
||||||
// review 256): the registry asked first, then the check and the start with nothing awaited between.
|
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
|
||||||
const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() },
|
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge)) };
|
||||||
(registry) => start(work, forge, tests, refs, credentialOf(forge), registry));
|
|
||||||
if (!launched.started) return launched;
|
|
||||||
return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry,
|
|
||||||
...(launched.problem ? { problem: launched.problem } : {}) };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lab_base",
|
|
||||||
description:
|
|
||||||
"The lab's base image, which every bed machine with a container runtime is raised from: when it was built, its " +
|
|
||||||
`age, the bound lab_run refuses past (${BASE_MAX_AGE_DAYS} days), and the last rebuilds with the newest one's log. ` +
|
|
||||||
"Reads only, unless rebuild is true: then it rebuilds the image with mesh-lab's `base build` from the forge's " +
|
|
||||||
"main, in the background, and answers at once; ask again for the build's state and the image's new age.",
|
|
||||||
input: {
|
|
||||||
rebuild: { type: "boolean", description: "rebuild the image (default false: only read)" },
|
|
||||||
},
|
|
||||||
run: async (args) => {
|
|
||||||
const before = readBaseImage();
|
|
||||||
const builds = listBaseBuilds(work).slice(0, 3);
|
|
||||||
const newest = builds[0];
|
|
||||||
const reading = {
|
|
||||||
base: before, said: describeBase(before), bound: BASE_MAX_AGE_DAYS,
|
|
||||||
...(baseProblem(before) ? { problem: baseProblem(before) } : {}),
|
|
||||||
builds, ...(newest ? { log: baseBuildLog(work, newest.id) } : {}),
|
|
||||||
};
|
|
||||||
if (args.rebuild !== true) return reading;
|
|
||||||
const forge = forgeOf();
|
|
||||||
if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
|
||||||
// Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with
|
|
||||||
// nothing awaited between (review 256).
|
|
||||||
const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before },
|
|
||||||
(registry) => startBaseBuild(work, forge, credentialOf(forge), registry));
|
|
||||||
if (launched.started === false) return { ...reading, started: false, reason: launched.reason };
|
|
||||||
return { ...reading, started: true, build: launched.job };
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+16
-504
@@ -6,8 +6,8 @@
|
|||||||
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
|
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
|
||||||
// for it, and says it was lost rather than pretending it is still going.
|
// for it, and says it was lost rather than pretending it is still going.
|
||||||
|
|
||||||
import { spawn, spawnSync } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,108 +60,6 @@ export function installStep(root: string, build: boolean): string {
|
|||||||
fi`;
|
fi`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
|
|
||||||
export const NPM_PATH = "/api/packages/novox/npm/";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
|
|
||||||
* (`serves.public-root`, novox/hq ADR 0346): the forge's route's composed public name under the npm path.
|
|
||||||
* The mesh writes the binding, and it is checked all the same: an https URL whose path is the npm path and
|
|
||||||
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
|
|
||||||
* machine with no public domain serves none (ADR 0346 §3).
|
|
||||||
*/
|
|
||||||
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
|
|
||||||
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
|
|
||||||
let said: unknown;
|
|
||||||
try {
|
|
||||||
said = (JSON.parse(readFileSync(bindingFile, "utf8")) as { serves?: Record<string, unknown> }).serves?.["public-root"];
|
|
||||||
} catch (e) {
|
|
||||||
return { problem: `its npm-package-registry binding could not be read: ${e instanceof Error ? e.message : String(e)}` };
|
|
||||||
}
|
|
||||||
if (typeof said !== "string" || !said) return { problem: "its npm-package-registry binding serves no public-root" };
|
|
||||||
if (/[\s\u0000-\u001f\u007f]/.test(said)) return { problem: "the binding's public-root holds whitespace or a control character" };
|
|
||||||
let u: URL;
|
|
||||||
try {
|
|
||||||
u = new URL(said);
|
|
||||||
} catch {
|
|
||||||
return { problem: `the binding's public-root ${JSON.stringify(said)} is not a URL` };
|
|
||||||
}
|
|
||||||
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.pathname !== NPM_PATH) {
|
|
||||||
return { problem: `the binding's public-root ${JSON.stringify(said)} is not https://<host>${NPM_PATH}` };
|
|
||||||
}
|
|
||||||
return { root: `${u.origin}${NPM_PATH}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The address a run names the @novox registry by: the one the registry names itself by, when that is the
|
|
||||||
* forge's public root or the forge's own address (novox/hq issue 624, ADR 0346).
|
|
||||||
*
|
|
||||||
* **npm 12 installs a locked tarball only from the registry it is configured with.** Its `allow-remote`
|
|
||||||
* defaults to `none`, and a lock file's `resolved` URL counts as the registry's only when its origin and
|
|
||||||
* path are the configured scope registry's; any other is a "remote" tarball and refused (EALLOWREMOTE).
|
|
||||||
* The forge writes every tarball URL — in what it answers, and so in every lock file made against it — at
|
|
||||||
* its own public address, whichever address it was asked at. So the lab asks the registry, at the forge,
|
|
||||||
* for the SDK's newest tarball and names the registry by the origin of that URL.
|
|
||||||
*
|
|
||||||
* **What the registry answers is not trusted for where packages come from** (review of mesh-catalog #275):
|
|
||||||
* it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it carries no
|
|
||||||
* whitespace, control character, user, password, query or fragment, its path is under the npm path, and
|
|
||||||
* its origin — scheme, host and port — is one the lab already trusts: the public root the forge serves in
|
|
||||||
* the lab's binding (`publicRoot`, ADR 0346), or the forge setting's own. The registry is then built from
|
|
||||||
* the parsed origin and the npm path alone, never from the string answered, so nothing the registry says
|
|
||||||
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
|
|
||||||
* forge's own address and says why, so a run still starts and lab_check names it.
|
|
||||||
*/
|
|
||||||
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
|
|
||||||
Promise<{ registry: string; problem?: string }> {
|
|
||||||
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
|
|
||||||
const url = `${asked}@novox%2fmesh-sdk`;
|
|
||||||
const fallback = (why: string) => ({
|
|
||||||
registry: asked,
|
|
||||||
problem: `the package registry at ${url} did not give an address the lab takes (${why}), so a run names it ${asked}, and npm 12 refuses a locked @novox tarball at any other`,
|
|
||||||
});
|
|
||||||
let own: URL;
|
|
||||||
try {
|
|
||||||
own = new URL(forge);
|
|
||||||
} catch {
|
|
||||||
return fallback(`the forge ${JSON.stringify(forge)} is not a URL`);
|
|
||||||
}
|
|
||||||
// The forge setting's own origin is trusted only as an http(s) one: any other scheme's origin is
|
|
||||||
// "null", or names no registry npm would fetch from.
|
|
||||||
const trusted = own.protocol === "https:" || own.protocol === "http:" ? [own.origin] : [];
|
|
||||||
if (publicRoot) {
|
|
||||||
try {
|
|
||||||
trusted.unshift(new URL(publicRoot).origin);
|
|
||||||
} catch {
|
|
||||||
// publicRootOf hands only a parsed root; a caller's other string is not trusted.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await get(url, { signal: AbortSignal.timeout(15_000) });
|
|
||||||
if (!res.ok) return fallback(`it answered ${res.status}`);
|
|
||||||
const doc = await res.json() as { "dist-tags"?: { latest?: string }; versions?: Record<string, { dist?: { tarball?: unknown } }> };
|
|
||||||
const latest = doc["dist-tags"]?.latest ?? "";
|
|
||||||
const tarball = doc.versions?.[latest]?.dist?.tarball;
|
|
||||||
if (typeof tarball !== "string" || !tarball) return fallback("no tarball for its latest version");
|
|
||||||
if (/[\s\u0000-\u001f\u007f]/.test(tarball)) return fallback("its tarball URL holds whitespace or a control character");
|
|
||||||
let t: URL;
|
|
||||||
try {
|
|
||||||
t = new URL(tarball);
|
|
||||||
} catch {
|
|
||||||
return fallback("its tarball is not a URL");
|
|
||||||
}
|
|
||||||
if (t.username || t.password) return fallback("its tarball URL carries a user or password");
|
|
||||||
if (t.search || t.hash) return fallback("its tarball URL carries a query or a fragment");
|
|
||||||
if (!t.pathname.startsWith(NPM_PATH)) return fallback(`its tarball is not under ${NPM_PATH}`);
|
|
||||||
if (!trusted.includes(t.origin)) {
|
|
||||||
return fallback(`its tarball is at ${t.origin}, which is neither the forge's public root nor its own address (${trusted.join(", ")})`);
|
|
||||||
}
|
|
||||||
return { registry: `${t.origin}${NPM_PATH}` };
|
|
||||||
} catch (e) {
|
|
||||||
return fallback(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The forge credential a run's clones may present, as a git credential-store line —
|
* The forge credential a run's clones may present, as a git credential-store line —
|
||||||
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
|
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
|
||||||
@@ -223,55 +121,22 @@ trap ${shellQuote(`${rm}; exit 129`)} HUP`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* cleanCredentials removes every run's and every base rebuild's credential file left in the work directory
|
* cleanCredentials removes every run's credential file left in the work directory but the one named — what
|
||||||
* but the ones named — what a crash or a reboot left behind, which no trap could remove. Answers what it
|
* a crash or a reboot left behind, which no trap could remove. Answers what it removed.
|
||||||
* removed.
|
|
||||||
*/
|
*/
|
||||||
export function cleanCredentials(work: string, keep?: string | string[]): string[] {
|
export function cleanCredentials(work: string, keep?: string): string[] {
|
||||||
if (!existsSync(work)) return [];
|
if (!existsSync(work)) return [];
|
||||||
const kept = new Set([keep ?? []].flat());
|
|
||||||
const removed: string[] = [];
|
const removed: string[] = [];
|
||||||
for (const name of readdirSync(work)) {
|
for (const name of readdirSync(work)) {
|
||||||
if (!/^\.(run|base)-.*\.git-credentials$/.test(name)) continue;
|
if (!/^\.run-.*\.git-credentials$/.test(name)) continue;
|
||||||
const path = join(work, name);
|
const path = join(work, name);
|
||||||
if (kept.has(path)) continue;
|
if (path === keep) continue;
|
||||||
rmSync(path, { force: true });
|
rmSync(path, { force: true });
|
||||||
removed.push(path);
|
removed.push(path);
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* checkDir is a directory of lab_check's own for one check, fresh each time (novox/hq issue 624). The check
|
|
||||||
* cloned into the one fixed path `<work>/check`, removing it first and ignoring whether that worked; a tree
|
|
||||||
* an earlier check left that this account could not remove — one cloned as another account before the
|
|
||||||
* work directory was the operator's — made every check fail on "destination path already exists".
|
|
||||||
*/
|
|
||||||
export function checkDir(work: string): string {
|
|
||||||
mkdirSync(work, { recursive: true });
|
|
||||||
return mkdtempSync(join(work, "check-"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* clearChecks removes what earlier checks left in the work directory — the fixed `check` and `check-setup`
|
|
||||||
* of before, and any `check-*` — and says, for each it could not remove, which and why, so a check reports
|
|
||||||
* it rather than tripping over it.
|
|
||||||
*/
|
|
||||||
export function clearChecks(work: string): string[] {
|
|
||||||
if (!existsSync(work)) return [];
|
|
||||||
const said: string[] = [];
|
|
||||||
for (const name of readdirSync(work)) {
|
|
||||||
if (name !== "check" && name !== "check-setup" && !name.startsWith("check-")) continue;
|
|
||||||
const path = join(work, name);
|
|
||||||
try {
|
|
||||||
rmSync(path, { recursive: true, force: true });
|
|
||||||
} catch (err) {
|
|
||||||
said.push(`${path}, left by an earlier check, could not be removed: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return said;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The arguments that have git offer the credential in that file, and only that one. */
|
/** The arguments that have git offer the credential in that file, and only that one. */
|
||||||
export function credentialArgs(path: string | undefined): string {
|
export function credentialArgs(path: string | undefined): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
@@ -288,117 +153,6 @@ export interface RunStatus {
|
|||||||
commits?: Record<string, string>;
|
commits?: Record<string, string>;
|
||||||
exit?: number;
|
exit?: number;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
/** What the suite said of its tests, read from the run's log once it ended (novox/hq issue 693). */
|
|
||||||
outcome?: Outcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One skipped test, its file and the reason it gave. */
|
|
||||||
export interface Skip {
|
|
||||||
file: string;
|
|
||||||
test: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a run's suite said of its tests: its totals, each skip with its reason, and — when the run did not
|
|
||||||
* pass although its exit said 0 — why (`notPassed`). Totals the runner did not print are absent.
|
|
||||||
*/
|
|
||||||
export interface Outcome {
|
|
||||||
passed?: number;
|
|
||||||
failed?: number;
|
|
||||||
skipped?: number;
|
|
||||||
skips: Skip[];
|
|
||||||
notPassed?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* outcomeOf reads what a run's log says of its tests (novox/hq issue 693).
|
|
||||||
*
|
|
||||||
* **A run in which no test passed has proven nothing**, and the runner exits 0 when every test skipped:
|
|
||||||
* the genesis bed skipped its only test as "NOT REACHED", the suite printed "0 passed, 0 failed", and the
|
|
||||||
* lab recorded the run as passed. The lab's suite now says it itself — a "NOT PASSED: <why>" line and a
|
|
||||||
* "skipped: <test> (<file>) # <reason>" line per skip after its "recorded:" line, and an exit that is not
|
|
||||||
* 0 — and this reads those lines. For a run of a lab from before (a branch, or an old log), the same rule is
|
|
||||||
* read from the runner's own output: its summary totals, and its skip lines' first line of reason.
|
|
||||||
*
|
|
||||||
* The rule, the lab suite's (mesh-lab src/outcomes.ts): **not passed when no test passed**, or when a test
|
|
||||||
* skipped as NOT REACHED (its subject was not reached); a skip for another reason beside tests that passed
|
|
||||||
* leaves the run passed, its reason listed.
|
|
||||||
*/
|
|
||||||
export function outcomeOf(log: string): Outcome {
|
|
||||||
const plain = log.replace(/\u001b\[[0-9;]*m/g, "");
|
|
||||||
const total = (word: string) => {
|
|
||||||
// The last summary in the log: the runner's own, once per run.
|
|
||||||
const all = [...plain.matchAll(new RegExp(`^\\s*(?:\u2139|#)\\s*${word}\\s+(\\d+)\\s*$`, "gm"))];
|
|
||||||
const last = all[all.length - 1];
|
|
||||||
return last ? Number(last[1]) : undefined;
|
|
||||||
};
|
|
||||||
const outcome: Outcome = { skips: [] };
|
|
||||||
const passed = total("pass");
|
|
||||||
const failed = total("fail");
|
|
||||||
const skipped = total("skipped");
|
|
||||||
if (passed !== undefined) outcome.passed = passed;
|
|
||||||
if (failed !== undefined) outcome.failed = failed;
|
|
||||||
if (skipped !== undefined) outcome.skipped = skipped;
|
|
||||||
|
|
||||||
// **The suite's own lines are read only after its last "recorded:" line** (novox/hq issue 693, review
|
|
||||||
// 254): a test prints what it likes before the suite reports, and a line of its that looked like the
|
|
||||||
// suite's — "NOT PASSED: …", "skipped: … # NOT REACHED" — would otherwise stand for the suite's verdict.
|
|
||||||
const recorded = [...plain.matchAll(/^recorded: .*$/gm)];
|
|
||||||
const lastRecorded = recorded[recorded.length - 1];
|
|
||||||
const suiteSaid = lastRecorded ? plain.slice((lastRecorded.index ?? 0) + lastRecorded[0].length) : "";
|
|
||||||
|
|
||||||
// The suite's own line, "skipped: <test> (<file>) # <reason>": the file in brackets, a .ts, parts it.
|
|
||||||
for (const m of suiteSaid.matchAll(/^skipped: (.*?) \(([^()]+\.ts)\) # (.*)$/gm)) {
|
|
||||||
outcome.skips.push({ file: m[2] ?? "", test: m[1] ?? "", reason: m[3] === "no reason given" ? "" : m[3] ?? "" });
|
|
||||||
}
|
|
||||||
if (outcome.skips.length === 0) {
|
|
||||||
// A lab from before names no skip itself; its runner's spec line does: "﹣ <test> (<ms>ms) # <reason>".
|
|
||||||
for (const m of plain.matchAll(/^\s*\ufe63 (.*?) \([\d.]+m?s\) # (.*)$/gm)) {
|
|
||||||
const reason = (m[2] ?? "").trim();
|
|
||||||
outcome.skips.push({ file: "", test: m[1] ?? "", reason: reason === "SKIP" ? "" : reason });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const said = suiteSaid.match(/^NOT PASSED: (.*)$/m);
|
|
||||||
if (said) {
|
|
||||||
outcome.notPassed = (said[1] ?? "").trim();
|
|
||||||
} else if ((failed ?? 0) === 0) {
|
|
||||||
// In any case: "not reached" says the same as "NOT REACHED".
|
|
||||||
const unreached = outcome.skips.filter((s) => s.reason.toUpperCase().includes("NOT REACHED")).length;
|
|
||||||
if (passed === 0) {
|
|
||||||
outcome.notPassed = outcome.skips.length > 0 || (skipped ?? 0) > 0
|
|
||||||
? `no test passed: ${skipped ?? outcome.skips.length} skipped${unreached > 0 ? `, ${unreached} of them as NOT REACHED` : ""}`
|
|
||||||
: "no test passed: none ran";
|
|
||||||
} else if (unreached > 0) {
|
|
||||||
outcome.notPassed = `${unreached} test(s) skipped as NOT REACHED: their subject was not reached`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return outcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* settled is a run that ended, with its outcome read from its log: a run whose exit said 0 and whose suite
|
|
||||||
* proved nothing reads as failed, with why (novox/hq issue 693). Read once and kept in its status, so the
|
|
||||||
* record says it too; a run still going, stopped or lost is left as it is.
|
|
||||||
*/
|
|
||||||
export function settled(work: string, s: RunStatus): RunStatus {
|
|
||||||
if (s.outcome || !s.ended || (s.state !== "passed" && s.state !== "failed")) return s;
|
|
||||||
let log: string;
|
|
||||||
try {
|
|
||||||
log = readFileSync(join(runDir(work, s.id), "run.log"), "utf8");
|
|
||||||
} catch {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
const outcome = outcomeOf(log);
|
|
||||||
const next: RunStatus = { ...s, outcome };
|
|
||||||
if (next.state === "passed" && outcome.notPassed) next.state = "failed";
|
|
||||||
try {
|
|
||||||
writeFileSync(statusPath(work, s.id), JSON.stringify(next, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Read again next time: what lab_status answers is the same either way.
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runDir(work: string, id: string): string {
|
export function runDir(work: string, id: string): string {
|
||||||
@@ -413,23 +167,16 @@ export function readStatus(work: string, id: string): RunStatus | undefined {
|
|||||||
try {
|
try {
|
||||||
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
|
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
|
||||||
// A run whose process is gone while its status still says it is going was lost — the runtime or
|
// A run whose process is gone while its status still says it is going was lost — the runtime or
|
||||||
// the machine restarted under it. Said, rather than left reading as running for ever. One that never
|
// the machine restarted under it. Said, rather than left reading as running for ever.
|
||||||
// recorded a process (the runtime died between writing its status and starting it) is lost too: it
|
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
|
||||||
// would otherwise hold up every run after it (review 256).
|
|
||||||
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && gone(s.pid)) {
|
|
||||||
s.state = "lost";
|
s.state = "lost";
|
||||||
}
|
}
|
||||||
return settled(work, s);
|
return s;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** gone is whether a job's process is not running: none recorded, or none by that id. */
|
|
||||||
function gone(pid: number | undefined): boolean {
|
|
||||||
return !pid || !alive(pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function alive(pid: number): boolean {
|
function alive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
@@ -462,7 +209,7 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|||||||
* at this run's own tree, so what is built and claimed is exactly what was checked out.
|
* at this run's own tree, so what is built and claimed is exactly what was checked out.
|
||||||
*/
|
*/
|
||||||
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||||
credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
credential?: string): string {
|
||||||
const dir = runDir(work, id);
|
const dir = runDir(work, id);
|
||||||
const setState = (state: string) =>
|
const setState = (state: string) =>
|
||||||
`node -e ${shellQuote(
|
`node -e ${shellQuote(
|
||||||
@@ -488,9 +235,9 @@ node -e ${shellQuote(
|
|||||||
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
|
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||||
)}
|
)}
|
||||||
${setState("building")}
|
${setState("building")}
|
||||||
# The @novox scope resolves from the mesh's own package registry on the forge, named by the address it
|
# The @novox scope resolves from the mesh's own package registry on the forge, as the builder
|
||||||
# names itself by (npmRegistry), which is the address its lock files hold; nothing else is asked of it.
|
# resolves it; nothing else is asked of it.
|
||||||
printf '%s\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
|
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||||
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
||||||
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
|
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
|
||||||
mkdir -p ${shellQuote(bin)}
|
mkdir -p ${shellQuote(bin)}
|
||||||
@@ -516,7 +263,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
|
|||||||
|
|
||||||
/** start begins a run and returns at once with its status. */
|
/** start begins a run and returns at once with its status. */
|
||||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||||
credential?: string, registry?: string): RunStatus {
|
credential?: string): RunStatus {
|
||||||
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||||
const dir = runDir(work, id);
|
const dir = runDir(work, id);
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
@@ -525,7 +272,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
|
|||||||
// What a crashed or rebooted run left behind goes before this run writes its own.
|
// What a crashed or rebooted run left behind goes before this run writes its own.
|
||||||
cleanCredentials(work);
|
cleanCredentials(work);
|
||||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
|
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials), { mode: 0o700 });
|
||||||
|
|
||||||
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
|
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
|
||||||
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
|
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
|
||||||
@@ -566,238 +313,3 @@ export function tail(work: string, id: string, lines: number): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the base image (novox/hq issue 698)
|
|
||||||
//
|
|
||||||
// Every bed machine that needs a container runtime is a clone of one base image, `mesh-lab/base`, which
|
|
||||||
// mesh-lab's `base build` makes from the distribution's current image. A machine raised from it carries the
|
|
||||||
// package database the image was built with. On a rolling distribution the mirrors stop serving what an old
|
|
||||||
// database names within weeks, and a bed that installs a package then fails on a 404 that reads as a mirror
|
|
||||||
// outage (novox/hq issue 205; seen 2026-10-14 when genesis-keeps-its-bus installed postgresql-libs from a
|
|
||||||
// database six weeks old). So the lab names the image's age wherever it is asked, refuses a run on an image
|
|
||||||
// older than the bound, and rebuilds the image when asked to.
|
|
||||||
//
|
|
||||||
// **The bound is 14 days** (BASE_MAX_AGE_DAYS). Arch's mirrors keep a package version for as long as it is
|
|
||||||
// current and drop it soon after it is replaced; an image a fortnight old has reliably installed what beds
|
|
||||||
// install, and the one that failed was 44 days old. A rebuild takes minutes, so a short bound costs little;
|
|
||||||
// a run refused is cheaper than a bed that fails an hour in for a reason that is not the change under test.
|
|
||||||
|
|
||||||
/** The image every bed machine with a runtime is raised from, as mesh-lab names it (src/lifecycle/place.ts). */
|
|
||||||
export const BASE_IMAGE_ALIAS = "mesh-lab/base";
|
|
||||||
|
|
||||||
/** How old the base image may be, in days, before a run is refused (see the head of this file). */
|
|
||||||
export const BASE_MAX_AGE_DAYS = 14;
|
|
||||||
|
|
||||||
const DAY = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export interface BaseImage {
|
|
||||||
alias: string;
|
|
||||||
/** Whether the image is there at all. */
|
|
||||||
exists: boolean;
|
|
||||||
/** When this machine made it, ISO. */
|
|
||||||
built?: string;
|
|
||||||
/** Its age in whole days. */
|
|
||||||
ageDays?: number;
|
|
||||||
/** Why its age could not be read, when it could not. */
|
|
||||||
problem?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* baseImageFrom reads `incus image list <alias> --format json`: the image whose aliases hold the base's
|
|
||||||
* name, and when it was made here (its upload, which a publish sets).
|
|
||||||
*/
|
|
||||||
export function baseImageFrom(listed: string | undefined, now: Date): BaseImage {
|
|
||||||
const alias = BASE_IMAGE_ALIAS;
|
|
||||||
if (listed === undefined) return { alias, exists: false, problem: "incus could not list the lab's images" };
|
|
||||||
let images: Array<{ aliases?: Array<{ name?: string }>; uploaded_at?: string; created_at?: string }>;
|
|
||||||
try {
|
|
||||||
images = JSON.parse(listed);
|
|
||||||
} catch {
|
|
||||||
return { alias, exists: false, problem: "incus listed the lab's images as something that is not JSON" };
|
|
||||||
}
|
|
||||||
const image = (Array.isArray(images) ? images : []).find((i) => (i.aliases ?? []).some((a) => a.name === alias));
|
|
||||||
if (!image) return { alias, exists: false };
|
|
||||||
const made = [image.uploaded_at, image.created_at].map((t) => (t ? Date.parse(t) : NaN))
|
|
||||||
.find((t) => Number.isFinite(t) && t > 0);
|
|
||||||
if (made === undefined) return { alias, exists: true, problem: "incus gives no time the image was made" };
|
|
||||||
return { alias, exists: true, built: new Date(made).toISOString(), ageDays: Math.floor((now.getTime() - made) / DAY) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** readBaseImage asks incus, on this machine, for the base image. */
|
|
||||||
export function readBaseImage(now = new Date()): BaseImage {
|
|
||||||
const listed = spawnSync("incus", ["image", "list", BASE_IMAGE_ALIAS, "--format", "json"], { encoding: "utf8" });
|
|
||||||
return baseImageFrom(listed.status === 0 ? listed.stdout : undefined, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The base image's age in one sentence, as lab_check and lab_run say it. */
|
|
||||||
export function describeBase(b: BaseImage): string {
|
|
||||||
if (b.problem) return `the base image ${b.alias}: its age is not known: ${b.problem}`;
|
|
||||||
if (!b.exists) return `the base image ${b.alias} is not on this machine; rebuild it with lab_base {"rebuild": true}`;
|
|
||||||
return `the base image ${b.alias} was built ${b.built} (${b.ageDays} day(s) ago; the bound is ${BASE_MAX_AGE_DAYS})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* staleBase is why a run is refused for its base image, or undefined when it is not: an image older than the
|
|
||||||
* bound. An image that is missing or whose age cannot be read is not refused here — a bed that needs it says
|
|
||||||
* so itself, and a bed that does not need it runs.
|
|
||||||
*/
|
|
||||||
export function staleBase(b: BaseImage): string | undefined {
|
|
||||||
if (!b.exists || b.ageDays === undefined || b.ageDays <= BASE_MAX_AGE_DAYS) return undefined;
|
|
||||||
return `the base image ${b.alias} was built ${b.built}, ${b.ageDays} days ago, older than the lab's bound of ` +
|
|
||||||
`${BASE_MAX_AGE_DAYS} days: a bed machine raised from it carries a package database the mirrors no longer serve ` +
|
|
||||||
`(novox/hq issues 205, 698). Rebuild it with lab_base {"rebuild": true}, then run again`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* baseProblem is what lab_check and lab_base say is wrong with the base image, or undefined: past the bound, not
|
|
||||||
* on this machine, or of an age that cannot be read (review 256). Only the first refuses a run.
|
|
||||||
*/
|
|
||||||
export function baseProblem(b: BaseImage): string | undefined {
|
|
||||||
if (b.problem || !b.exists) return describeBase(b);
|
|
||||||
return staleBase(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* runRefusal is why lab_run starts no run for the base image, or undefined: a rebuild still going, which
|
|
||||||
* replaces the image a bed raises its machines from, or an image older than the bound.
|
|
||||||
*/
|
|
||||||
export function runRefusal(base: BaseImage, rebuilding?: { id: string; started: string }): string | undefined {
|
|
||||||
if (rebuilding) return `the base image is being rebuilt (${rebuilding.id}, since ${rebuilding.started}); run once lab_base says it is built`;
|
|
||||||
return staleBase(base);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- its rebuild: mesh-lab's `base build`, in the background, as a run is.
|
|
||||||
|
|
||||||
export interface BaseBuild {
|
|
||||||
id: string;
|
|
||||||
state: "building" | "built" | "failed" | "lost";
|
|
||||||
started: string;
|
|
||||||
ended?: string;
|
|
||||||
exit?: number;
|
|
||||||
pid?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStatusPath(work: string, id: string): string {
|
|
||||||
return join(work, id, "status.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The base builds, newest first. A build whose process is gone while it says building was lost. */
|
|
||||||
export function listBaseBuilds(work: string): BaseBuild[] {
|
|
||||||
if (!existsSync(work)) return [];
|
|
||||||
const builds: BaseBuild[] = [];
|
|
||||||
for (const id of readdirSync(work).filter((d) => d.startsWith("base-"))) {
|
|
||||||
try {
|
|
||||||
const b = JSON.parse(readFileSync(buildStatusPath(work, id), "utf8")) as BaseBuild;
|
|
||||||
if (b.state === "building" && gone(b.pid)) b.state = "lost";
|
|
||||||
builds.push(b);
|
|
||||||
} catch {
|
|
||||||
// A directory without a status is not a build.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return builds.sort((a, b) => b.started.localeCompare(a.started));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The base build still going, if any. */
|
|
||||||
export function buildingBase(work: string): BaseBuild | undefined {
|
|
||||||
return listBaseBuilds(work).find((b) => b.state === "building");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The last lines of a base build's log. */
|
|
||||||
export function baseBuildLog(work: string, id: string, lines = 40): string {
|
|
||||||
try {
|
|
||||||
return readFileSync(join(work, id, "build.log"), "utf8").split("\n").slice(-Math.max(1, lines)).join("\n");
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it,
|
|
||||||
* then its `base build`, which replaces the image and reads back that it works before publishing it.
|
|
||||||
*/
|
|
||||||
export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
|
||||||
const lab = join(dir, "mesh-lab");
|
|
||||||
return `set -euo pipefail
|
|
||||||
export GIT_TERMINAL_PROMPT=0
|
|
||||||
${credentialGuard(credential)}
|
|
||||||
cd ${shellQuote(dir)}
|
|
||||||
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)}
|
|
||||||
${credential ? `rm -f ${shellQuote(credential)}` : ""}
|
|
||||||
echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)"
|
|
||||||
printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
|
|
||||||
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
|
||||||
${installStep(lab, false)}
|
|
||||||
cd ${shellQuote(lab)}
|
|
||||||
node --experimental-strip-types src/cli.ts base build
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** startBaseBuild rebuilds the base image in the background and answers at once with the build. */
|
|
||||||
export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild {
|
|
||||||
const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
||||||
const dir = join(work, id);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
const build: BaseBuild = { id, state: "building", started: new Date().toISOString() };
|
|
||||||
writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2));
|
|
||||||
// What a crashed or rebooted run or rebuild left behind goes before this one writes its own.
|
|
||||||
cleanCredentials(work);
|
|
||||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
|
||||||
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 });
|
|
||||||
const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$?
|
|
||||||
node -e ${shellQuote(
|
|
||||||
`const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
|
||||||
)} "$code"
|
|
||||||
rm -f ${shellQuote(credentialPath(work, id))}
|
|
||||||
rm -rf ${shellQuote(join(dir, "mesh-lab"))}`;
|
|
||||||
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
|
|
||||||
child.unref();
|
|
||||||
build.pid = child.pid;
|
|
||||||
writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2));
|
|
||||||
return build;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- starting a run or a rebuild: one job on the lab's machine at a time (review 256).
|
|
||||||
//
|
|
||||||
// **Everything that waits is done first; the check and the start are one step.** A run and a rebuild each
|
|
||||||
// refused while the other was going, but each asked the package registry — an await — between its check and
|
|
||||||
// writing its status, so a run and a rebuild asked together could both pass their checks and both start: a
|
|
||||||
// bed raising machines from an image being replaced under it. Here the registry is asked first, and nothing
|
|
||||||
// is awaited between reading what is going and writing the new job's status, so in the one runtime that
|
|
||||||
// serves the lab's tools no other call runs in between.
|
|
||||||
|
|
||||||
export interface Launch {
|
|
||||||
/** The address a run or a rebuild names the package registry by, asked of the forge (npmRegistry). */
|
|
||||||
registry: () => Promise<{ registry: string; problem?: string }>;
|
|
||||||
/** The base image as it is now. */
|
|
||||||
base: () => BaseImage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Launched<T> = { started: true; registry: string; problem?: string; base: BaseImage; job: T }
|
|
||||||
| { started: false; reason: string; base: BaseImage };
|
|
||||||
|
|
||||||
/** launchRun starts a run unless a run or a rebuild is going, or the base image is past its bound. */
|
|
||||||
export async function launchRun(work: string, launch: Launch, begin: (registry: string) => RunStatus):
|
|
||||||
Promise<Launched<RunStatus>> {
|
|
||||||
const registry = await launch.registry();
|
|
||||||
// From here to begin(): nothing awaited.
|
|
||||||
const base = launch.base();
|
|
||||||
const busy = running(work);
|
|
||||||
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, base };
|
|
||||||
const refused = runRefusal(base, buildingBase(work));
|
|
||||||
if (refused) return { started: false, reason: refused, base };
|
|
||||||
return { started: true, ...registry, base, job: begin(registry.registry) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** launchRebuild starts a rebuild of the base image unless a run or another rebuild is going. */
|
|
||||||
export async function launchRebuild(work: string, launch: Launch, begin: (registry: string) => BaseBuild):
|
|
||||||
Promise<Launched<BaseBuild>> {
|
|
||||||
const registry = await launch.registry();
|
|
||||||
// From here to begin(): nothing awaited.
|
|
||||||
const base = launch.base();
|
|
||||||
const rebuilding = buildingBase(work);
|
|
||||||
if (rebuilding) return { started: false, reason: `${rebuilding.id} is still rebuilding it`, base };
|
|
||||||
// A run raises its machines from the image; replacing it under one would change what the run tests.
|
|
||||||
const busy = running(work);
|
|
||||||
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}, and raises its machines from this image`, base };
|
|
||||||
return { started: true, ...registry, base, job: begin(registry.registry) };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
// Whether mail flows through mailu, as the node-engine asks it every interval (novox/hq ADR 0240, kind
|
|
||||||
// `tool`; issue 690). Every container read healthy on 2026-10-14 while postfix could not resolve a name:
|
|
||||||
// inbound mail was refused with 450 and outbound mail sat in the deferred queue for days. The parts'
|
|
||||||
// checks judge parts; this judges the flow, from inside the postfix container, the way postfix sees it:
|
|
||||||
//
|
|
||||||
// 1. postfix runs (`postfix status`, standing in for the image's own check, `! /usr/libexec/postfix/master
|
|
||||||
// -t`, which this one replaces);
|
|
||||||
// 2. the deferred queue (`postqueue -j`) shows no fault of this side: a recipient deferred longer than the
|
|
||||||
// bound because a lookup failed here, or overdue mail to three or more domains. One or two far domains
|
|
||||||
// that do not answer are theirs, and can stay deferred for days: said, never unhealthy, so one dead
|
|
||||||
// recipient does not page the operator for days;
|
|
||||||
// 3. a public mail host's name resolves through the resolver postfix is given: a resolver that
|
|
||||||
// SERVFAILs refuses inbound mail before anything reaches the queue.
|
|
||||||
//
|
|
||||||
// The answer is the health answer every health tool gives (mesh-sdk go/health): `state`, `healthy` beside
|
|
||||||
// it for an older reader, and `why` in words — counts, the oldest message's age and its first reason —
|
|
||||||
// never a bare false.
|
|
||||||
|
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const run = promisify(execFile);
|
|
||||||
|
|
||||||
/** How long a message may wait in the deferred queue before mail is said not to flow. */
|
|
||||||
export const DEFERRED_BOUND_SECONDS = 30 * 60;
|
|
||||||
|
|
||||||
/** The public name resolved through postfix's resolver: gmail's mail exchanger, which mail needs. */
|
|
||||||
export const PUBLIC_NAME = "gmail-smtp-in.l.google.com";
|
|
||||||
|
|
||||||
// The lookup postfix's own would make, through the resolver the container is given; a failure is said on one
|
|
||||||
// line ("gaierror: [Errno -3] Try again" is what a SERVFAIL reads as), never as a traceback.
|
|
||||||
const RESOLVE = [
|
|
||||||
"import socket, sys",
|
|
||||||
"try:",
|
|
||||||
` socket.getaddrinfo(${JSON.stringify(PUBLIC_NAME)}, 25)`,
|
|
||||||
"except Exception as e:",
|
|
||||||
" sys.exit(type(e).__name__ + ': ' + str(e))",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
/** Overdue mail to this many recipient domains or more is our side's fault, not one far server's. */
|
|
||||||
export const SPREAD_DOMAINS = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What postfix writes when its own lookups fail, as on 2026-10-14 (issue 687): "Host or domain name not
|
|
||||||
* found. Name service error for name=gmail.com type=MX: Host not found, try again", or "Temporary lookup
|
|
||||||
* failure". A far server that is down reads otherwise ("Connection timed out", "Connection refused").
|
|
||||||
*/
|
|
||||||
export const LOCAL_LOOKUP_FAILURE =
|
|
||||||
/Name service error|Temporary lookup failure|Host or domain name not found.*try again/is;
|
|
||||||
|
|
||||||
/** The domain of an address, lower-cased; "(no recipient)" when there is none. */
|
|
||||||
function domainOf(address?: string): string {
|
|
||||||
const at = address?.lastIndexOf("@") ?? -1;
|
|
||||||
return address && at >= 0 ? address.slice(at + 1).toLowerCase() : "(no recipient)";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs a command in a container: its stdout, or a rejection carrying stderr. */
|
|
||||||
export type Exec = (container: string, command: string[]) => Promise<string>;
|
|
||||||
|
|
||||||
/** The health answer (mesh-sdk go/health): healthy, or unhealthy with why. */
|
|
||||||
export interface HealthAnswer {
|
|
||||||
state: "healthy" | "unhealthy";
|
|
||||||
healthy: boolean;
|
|
||||||
why: string;
|
|
||||||
queue?: {
|
|
||||||
messages: number;
|
|
||||||
deferred: number;
|
|
||||||
overdue: number;
|
|
||||||
oldestDeferredSeconds?: number;
|
|
||||||
overdueDomains?: string[];
|
|
||||||
nameServiceFailures?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One message as `postqueue -j` prints it, one JSON object a line (postfix 3.1 and later). */
|
|
||||||
interface Queued {
|
|
||||||
queue_name?: string;
|
|
||||||
queue_id?: string;
|
|
||||||
arrival_time?: number;
|
|
||||||
recipients?: Array<{ address?: string; delay_reason?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** docker exec, bounded: a hung container is an answer too. */
|
|
||||||
export function dockerExec(timeoutMs = 10_000): Exec {
|
|
||||||
return async (container, command) =>
|
|
||||||
(await run("docker", ["exec", container, ...command], { timeout: timeoutMs })).stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a failed exec said, on one line: its stderr when it has one, else its message. */
|
|
||||||
function said(e: unknown): string {
|
|
||||||
const err = e as { stderr?: string; message?: string; killed?: boolean; signal?: string };
|
|
||||||
if (err?.killed || err?.signal === "SIGTERM") return "it did not answer in time";
|
|
||||||
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
|
|
||||||
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An age in words: "2 days 3 hours", "45 minutes". */
|
|
||||||
export function age(seconds: number): string {
|
|
||||||
const s = Math.max(0, Math.floor(seconds));
|
|
||||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
|
||||||
const unit = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
||||||
if (d > 0) return h > 0 ? `${unit(d, "day")} ${unit(h, "hour")}` : unit(d, "day");
|
|
||||||
if (h > 0) return m > 0 ? `${unit(h, "hour")} ${unit(m, "minute")}` : unit(h, "hour");
|
|
||||||
return unit(m, "minute");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseQueue(output: string): Queued[] {
|
|
||||||
const queued: Queued[] = [];
|
|
||||||
for (const line of output.split("\n")) {
|
|
||||||
const text = line.trim();
|
|
||||||
if (!text.startsWith("{")) continue; // "Mail queue is empty" and the like
|
|
||||||
queued.push(JSON.parse(text) as Queued);
|
|
||||||
}
|
|
||||||
return queued;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Judge whether mail flows. `now` is in seconds since the epoch. Every part is asked even when one
|
|
||||||
* fails, so the answer says everything that is wrong at once.
|
|
||||||
*/
|
|
||||||
export async function mailFlows(
|
|
||||||
exec: Exec,
|
|
||||||
container: string,
|
|
||||||
now: number = Date.now() / 1000,
|
|
||||||
boundSeconds: number = DEFERRED_BOUND_SECONDS,
|
|
||||||
): Promise<HealthAnswer> {
|
|
||||||
const faults: string[] = [];
|
|
||||||
const [status, queue, resolve] = await Promise.allSettled([
|
|
||||||
exec(container, ["postfix", "status"]),
|
|
||||||
exec(container, ["postqueue", "-j"]),
|
|
||||||
exec(container, ["python3", "-c", RESOLVE]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (status.status === "rejected") {
|
|
||||||
faults.push(`postfix in ${container} does not run: ${said(status.reason)}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary: HealthAnswer["queue"];
|
|
||||||
const notes: string[] = [];
|
|
||||||
if (queue.status === "rejected") {
|
|
||||||
faults.push(`postfix's queue in ${container} could not be read: ${said(queue.reason)}.`);
|
|
||||||
} else {
|
|
||||||
let queued: Queued[] = [];
|
|
||||||
try {
|
|
||||||
queued = parseQueue(queue.value);
|
|
||||||
} catch (e) {
|
|
||||||
faults.push(`postfix's queue in ${container} printed a line that is not JSON: ${said(e)}.`);
|
|
||||||
}
|
|
||||||
const deferred = queued.filter((q) => q.queue_name === "deferred");
|
|
||||||
const overdue = deferred
|
|
||||||
.filter((q) => typeof q.arrival_time === "number" && now - q.arrival_time > boundSeconds)
|
|
||||||
.sort((a, b) => (a.arrival_time ?? 0) - (b.arrival_time ?? 0));
|
|
||||||
const oldest = deferred.reduce<number | undefined>(
|
|
||||||
(o, q) => (typeof q.arrival_time === "number" && (o === undefined || q.arrival_time < o) ? q.arrival_time : o),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
// Each overdue recipient, oldest message first: what our side is judged by, not one far host.
|
|
||||||
const late = overdue.flatMap((q) =>
|
|
||||||
(q.recipients?.length ? q.recipients : [{}]).map((r) => ({
|
|
||||||
to: r.address,
|
|
||||||
domain: domainOf(r.address),
|
|
||||||
reason: r.delay_reason?.trim() || "postfix gave no reason",
|
|
||||||
waited: now - (q.arrival_time ?? now),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const lookups = late.filter((r) => LOCAL_LOOKUP_FAILURE.test(r.reason));
|
|
||||||
const domains = [...new Set(late.map((r) => r.domain))];
|
|
||||||
summary = {
|
|
||||||
messages: queued.length,
|
|
||||||
deferred: deferred.length,
|
|
||||||
overdue: overdue.length,
|
|
||||||
...(oldest !== undefined ? { oldestDeferredSeconds: Math.max(0, Math.floor(now - oldest)) } : {}),
|
|
||||||
...(domains.length > 0 ? { overdueDomains: domains } : {}),
|
|
||||||
...(lookups.length > 0 ? { nameServiceFailures: lookups.length } : {}),
|
|
||||||
};
|
|
||||||
const one = (r: (typeof late)[number]) =>
|
|
||||||
`the oldest, ${age(r.waited)} old${r.to ? ` to ${r.to}` : ""}, waits because: ${r.reason}`;
|
|
||||||
const bound = age(boundSeconds);
|
|
||||||
if (lookups.length > 0) {
|
|
||||||
faults.push(
|
|
||||||
`${lookups.length} deferred ${lookups.length === 1 ? "recipient has" : "recipients have"} waited longer ` +
|
|
||||||
`than ${bound} on a name-service failure, which is this server's resolver; ${one(lookups[0])}.`,
|
|
||||||
);
|
|
||||||
} else if (domains.length >= SPREAD_DOMAINS) {
|
|
||||||
faults.push(
|
|
||||||
`${overdue.length} of ${deferred.length} deferred messages have waited longer than ${bound}, to ` +
|
|
||||||
`${domains.length} domains (${domains.join(", ")}): more than one far server's fault; ${one(late[0])}.`,
|
|
||||||
);
|
|
||||||
} else if (late.length > 0) {
|
|
||||||
notes.push(
|
|
||||||
`${overdue.length} deferred ${overdue.length === 1 ? "message has" : "messages have"} waited longer than ` +
|
|
||||||
`${bound}, only to ${domains.join(" and ")}, which reads as the far side's: ${one(late[0])}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolve.status === "rejected") {
|
|
||||||
faults.push(`${container} cannot resolve ${PUBLIC_NAME} through its resolver: ${said(resolve.reason)}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (faults.length > 0) {
|
|
||||||
const why = [`Mail does not flow.`, ...faults, ...notes].join(" ");
|
|
||||||
return { state: "unhealthy", healthy: false, why, queue: summary };
|
|
||||||
}
|
|
||||||
const waiting = !summary || summary.deferred === 0
|
|
||||||
? " The deferred queue is empty."
|
|
||||||
: notes.length > 0
|
|
||||||
? ` ${notes.join(" ")}`
|
|
||||||
: ` ${summary.deferred} deferred, the oldest ${age(summary.oldestDeferredSeconds ?? 0)} old, within ${age(boundSeconds)}.`;
|
|
||||||
return {
|
|
||||||
state: "healthy",
|
|
||||||
healthy: true,
|
|
||||||
why: `Mail flows: postfix runs, ${PUBLIC_NAME} resolves.${waiting}`,
|
|
||||||
queue: summary,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -56,19 +56,6 @@
|
|||||||
"api-token": "${dir:state}/api-token.secret"
|
"api-token": "${dir:state}/api-token.secret"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": [
|
|
||||||
"mailu_list_users",
|
|
||||||
"mailu_create_user",
|
|
||||||
"mailu_change_password",
|
|
||||||
"mailu_delete_user",
|
|
||||||
"mailu_list_aliases",
|
|
||||||
"mailu_create_alias",
|
|
||||||
"mailu_delete_alias",
|
|
||||||
"mailu_list_domains",
|
|
||||||
"mailu_read_mail",
|
|
||||||
"mailu_search_mail",
|
|
||||||
"mailu_health"
|
|
||||||
],
|
|
||||||
"emits": [
|
"emits": [
|
||||||
"user.created",
|
"user.created",
|
||||||
"user.deleted",
|
"user.deleted",
|
||||||
@@ -384,7 +371,7 @@
|
|||||||
"id": "resolver",
|
"id": "resolver",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-resolver",
|
"name": "mailu-resolver",
|
||||||
"image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e",
|
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
|
||||||
"health": {
|
"health": {
|
||||||
"kind": "runtime"
|
"kind": "runtime"
|
||||||
},
|
},
|
||||||
@@ -456,12 +443,7 @@
|
|||||||
"name": "mailu-smtp",
|
"name": "mailu-smtp",
|
||||||
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
||||||
"health": {
|
"health": {
|
||||||
"kind": "tool",
|
"kind": "runtime"
|
||||||
"tool": "mailu_health",
|
|
||||||
"interval": "60s",
|
|
||||||
"timeout": "30s",
|
|
||||||
"looks": 3,
|
|
||||||
"grace": "120s"
|
|
||||||
},
|
},
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
@@ -578,12 +560,7 @@
|
|||||||
"name": "mailu-front",
|
"name": "mailu-front",
|
||||||
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
||||||
"health": {
|
"health": {
|
||||||
"kind": "exec",
|
"kind": "runtime"
|
||||||
"command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem",
|
|
||||||
"interval": "60s",
|
|
||||||
"timeout": "15s",
|
|
||||||
"looks": 3,
|
|
||||||
"grace": "120s"
|
|
||||||
},
|
},
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
@@ -660,7 +637,6 @@
|
|||||||
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
||||||
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
||||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||||
"MESH_MAILU_SMTP_CONTAINER": "mailu-smtp",
|
|
||||||
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||||
}
|
}
|
||||||
@@ -697,7 +673,7 @@
|
|||||||
"jails": [
|
"jails": [
|
||||||
{
|
{
|
||||||
"name": "mailu-front",
|
"name": "mailu-front",
|
||||||
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: <HOST>, server: \\S+:25,",
|
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
|
||||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
// Whether mail flows (novox/hq issue 690): the health tool is unhealthy, in words, when a message waits in
|
|
||||||
// postfix's deferred queue longer than the bound, when postfix does not run, when a public name does not
|
|
||||||
// resolve, and when the container cannot be reached — and healthy otherwise. Run against the compiled
|
|
||||||
// module, the way the runtime loads it.
|
|
||||||
|
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import { mailFlows, DEFERRED_BOUND_SECONDS } from "../dist/health.js";
|
|
||||||
|
|
||||||
const NOW = 1_800_000_000;
|
|
||||||
|
|
||||||
type Answers = { status?: string | Error; queue?: string | Error; resolve?: string | Error };
|
|
||||||
|
|
||||||
/** A container that answers each command as told; an Error is a failed exec, with its stderr. */
|
|
||||||
function container(a: Answers) {
|
|
||||||
const asked: string[][] = [];
|
|
||||||
const exec = async (_name: string, command: string[]) => {
|
|
||||||
asked.push(command);
|
|
||||||
const answer = command[0] === "postfix" ? a.status ?? "" : command[0] === "postqueue" ? a.queue ?? "" : a.resolve ?? "";
|
|
||||||
if (answer instanceof Error) throw answer;
|
|
||||||
return answer;
|
|
||||||
};
|
|
||||||
return { exec, asked };
|
|
||||||
}
|
|
||||||
|
|
||||||
function failed(stderr: string): Error {
|
|
||||||
return Object.assign(new Error("Command failed: docker exec"), { stderr });
|
|
||||||
}
|
|
||||||
|
|
||||||
function deferred(id: string, ageSeconds: number, reason: string, to = "friend@example.com"): string {
|
|
||||||
return JSON.stringify({
|
|
||||||
queue_name: "deferred",
|
|
||||||
queue_id: id,
|
|
||||||
arrival_time: NOW - ageSeconds,
|
|
||||||
message_size: 1234,
|
|
||||||
sender: "someone@example.org",
|
|
||||||
recipients: [{ address: to, delay_reason: reason }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("an empty queue is healthy, and says so", async () => {
|
|
||||||
const c = container({ queue: "" });
|
|
||||||
const a = await mailFlows(c.exec, "mailu-smtp", NOW);
|
|
||||||
assert.equal(a.state, "healthy");
|
|
||||||
assert.equal(a.healthy, true);
|
|
||||||
assert.match(a.why, /deferred queue is empty/);
|
|
||||||
assert.deepEqual(a.queue, { messages: 0, deferred: 0, overdue: 0 });
|
|
||||||
assert.equal(c.asked.length, 3, "postfix, its queue and a lookup were each asked");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a recipient overdue on a name-service failure is unhealthy, with counts, its age and its reason", async () => {
|
|
||||||
const reason = "Host or domain name not found. Name service error for name=gmail.com type=MX: Host not found, try again";
|
|
||||||
const queue = [
|
|
||||||
deferred("AAA111", 3 * 86400 + 2 * 3600, reason),
|
|
||||||
deferred("BBB222", 5 * 60, "connect to mx.example.net[192.0.2.1]:25: Connection timed out"),
|
|
||||||
JSON.stringify({ queue_name: "active", queue_id: "CCC333", arrival_time: NOW - 10, recipients: [] }),
|
|
||||||
].join("\n");
|
|
||||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
|
||||||
assert.equal(a.state, "unhealthy");
|
|
||||||
assert.equal(a.healthy, false);
|
|
||||||
assert.match(a.why, /^Mail does not flow\./);
|
|
||||||
assert.match(a.why, /1 deferred recipient has waited longer than 30 minutes on a name-service failure/);
|
|
||||||
assert.match(a.why, /3 days 2 hours old to friend@example\.com/);
|
|
||||||
assert.ok(a.why.includes(reason), "the reason is said");
|
|
||||||
assert.deepEqual(a.queue, {
|
|
||||||
messages: 3, deferred: 2, overdue: 1, oldestDeferredSeconds: 3 * 86400 + 2 * 3600,
|
|
||||||
overdueDomains: ["example.com"], nameServiceFailures: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("one dead far domain overdue is healthy, and said", async () => {
|
|
||||||
const queue = [
|
|
||||||
deferred("AAA111", 2 * 86400, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "a@dead.example"),
|
|
||||||
deferred("BBB222", 3600, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "b@dead.example"),
|
|
||||||
].join("\n");
|
|
||||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
|
||||||
assert.equal(a.state, "healthy");
|
|
||||||
assert.equal(a.healthy, true);
|
|
||||||
assert.match(a.why, /2 deferred messages have waited longer than 30 minutes, only to dead\.example, which reads as the far side's/);
|
|
||||||
assert.match(a.why, /2 days old to a@dead\.example, waits because: connect to mx\.dead\.example/);
|
|
||||||
assert.deepEqual(a.queue?.overdueDomains, ["dead.example"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("overdue mail to three domains is unhealthy, whatever the reason", async () => {
|
|
||||||
const timedOut = "connect to mx[192.0.2.1]:25: Connection timed out";
|
|
||||||
const queue = [
|
|
||||||
deferred("AAA111", 7200, timedOut, "a@one.example"),
|
|
||||||
deferred("BBB222", 3600, timedOut, "b@two.example"),
|
|
||||||
deferred("CCC333", 3600, timedOut, "c@three.example"),
|
|
||||||
].join("\n");
|
|
||||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
|
||||||
assert.equal(a.state, "unhealthy");
|
|
||||||
assert.match(a.why, /3 of 3 deferred messages have waited longer than 30 minutes, to 3 domains \(one\.example, two\.example, three\.example\)/);
|
|
||||||
assert.match(a.why, /the oldest, 2 hours old to a@one\.example/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a deferred message younger than the bound is healthy, and is said", async () => {
|
|
||||||
const queue = deferred("BBB222", DEFERRED_BOUND_SECONDS - 60, "connect to mx.example.net: Connection timed out");
|
|
||||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
|
||||||
assert.equal(a.state, "healthy");
|
|
||||||
assert.equal(a.healthy, true);
|
|
||||||
assert.match(a.why, /1 deferred, the oldest 29 minutes old, within 30 minutes/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a container that cannot be reached is unhealthy, and says what docker said", async () => {
|
|
||||||
const gone = failed("Error response from daemon: No such container: mailu-smtp\n");
|
|
||||||
const a = await mailFlows(container({ status: gone, queue: gone, resolve: gone }).exec, "mailu-smtp", NOW);
|
|
||||||
assert.equal(a.state, "unhealthy");
|
|
||||||
assert.equal(a.healthy, false);
|
|
||||||
assert.match(a.why, /postfix in mailu-smtp does not run: Error response from daemon: No such container: mailu-smtp\./);
|
|
||||||
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: Error response from daemon: No such container/);
|
|
||||||
assert.equal(a.queue, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a name that does not resolve is unhealthy, though the queue is empty", async () => {
|
|
||||||
const a = await mailFlows(
|
|
||||||
container({ queue: "", resolve: failed("gaierror: [Errno -3] Try again\n") }).exec,
|
|
||||||
"mailu-smtp",
|
|
||||||
NOW,
|
|
||||||
);
|
|
||||||
assert.equal(a.state, "unhealthy");
|
|
||||||
assert.match(a.why, /mailu-smtp cannot resolve gmail-smtp-in\.l\.google\.com through its resolver: gaierror: \[Errno -3\] Try again\./);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a container that does not answer in time is said so", async () => {
|
|
||||||
const hung = Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM" });
|
|
||||||
const a = await mailFlows(container({ queue: hung }).exec, "mailu-smtp", NOW);
|
|
||||||
assert.equal(a.state, "unhealthy");
|
|
||||||
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: it did not answer in time\./);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the health tool is served without the admin API, on the smtp container it is told", async () => {
|
|
||||||
const { collectTools } = await import("@novox/mesh-sdk/tools");
|
|
||||||
await import("../dist/tools/index.js");
|
|
||||||
const mailu = collectTools({ MESH_MAILU_SMTP_CONTAINER: "mailu-smtp" }).find((m) => m.module === "mailu");
|
|
||||||
const names = (mailu?.tools ?? []).map((t) => t.name);
|
|
||||||
assert.deepEqual(names, ["mailu_health"]);
|
|
||||||
assert.match(mailu!.tools[0].description, /inside mailu-smtp/);
|
|
||||||
});
|
|
||||||
@@ -4,27 +4,6 @@
|
|||||||
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { MailuClient } from "../client.js";
|
import { MailuClient } from "../client.js";
|
||||||
import { dockerExec, mailFlows, DEFERRED_BOUND_SECONDS, PUBLIC_NAME, SPREAD_DOMAINS } from "../health.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether mail flows (novox/hq ADR 0240, issue 690): the tool the node-engine asks as the smtp container's
|
|
||||||
* check. It needs no admin API, only the postfix container, so it is served even when the API is not
|
|
||||||
* configured — a check that vanished with the API would say unhealthy for the wrong reason.
|
|
||||||
*/
|
|
||||||
export function getHealthTool(env: NodeJS.ProcessEnv): ToolDefinition {
|
|
||||||
const container = env.MESH_MAILU_SMTP_CONTAINER || "mailu-smtp";
|
|
||||||
return {
|
|
||||||
name: "mailu_health",
|
|
||||||
description:
|
|
||||||
`Whether mail flows through mailu, asked inside ${container}: postfix runs; no recipient has waited ` +
|
|
||||||
`deferred longer than ${DEFERRED_BOUND_SECONDS / 60} minutes on a name-service failure here, and overdue ` +
|
|
||||||
`deferred mail does not reach ${SPREAD_DOMAINS} or more domains (one or two far domains are said, not ` +
|
|
||||||
`unhealthy); and ${PUBLIC_NAME} resolves through the resolver postfix uses. Says counts, domains, the ` +
|
|
||||||
"oldest message's age and its reason (novox/hq ADR 0240, issue 690).",
|
|
||||||
input: {},
|
|
||||||
run: async () => mailFlows(dockerExec(), container),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
@@ -164,14 +143,12 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin tools exist only when the admin API is configured; without it, mailu contributes only its
|
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
|
||||||
// health tool rather than failing the whole runtime.
|
// than failing the whole runtime.
|
||||||
registerModuleTools("mailu", (env) => {
|
registerModuleTools("mailu", (env) => {
|
||||||
let tools: ToolDefinition[] = [];
|
|
||||||
try {
|
try {
|
||||||
tools = getMailuTools(MailuClient.fromEnv(env));
|
return getMailuTools(MailuClient.fromEnv(env));
|
||||||
} catch {
|
} catch {
|
||||||
tools = [];
|
return [];
|
||||||
}
|
}
|
||||||
return [...tools, getHealthTool(env)];
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
|
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// One build per change (novox/hq ADR 0350): a pull request closed unmerged has its check builds let go of, and a
|
|
||||||
// walk's record says per module whether its build reused a check's build or built, and why.
|
|
||||||
|
|
||||||
// A pull request closed unmerged asks the controller to let go of what its checks built for its merge (§5); one
|
|
||||||
// the controller cannot answer is said, never fatal.
|
|
||||||
func TestAPullRequestClosedUnmergedLetsGoOfItsCheckBuilds(t *testing.T) {
|
|
||||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC)
|
|
||||||
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
|
|
||||||
w.h.PullUpdated(cc)
|
|
||||||
w.h.PullClosed(cc)
|
|
||||||
if want := []string{"novox/mesh-catalog#101"}; !reflect.DeepEqual(w.ctl.forgot, want) {
|
|
||||||
t.Fatalf("closed unmerged, the controller was asked to let go of %v", w.ctl.forgot)
|
|
||||||
}
|
|
||||||
if s := w.state(IDOf(cat, headCC)); s != Stopped {
|
|
||||||
t.Fatalf("closed unmerged it is %s", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.ctl.down = true
|
|
||||||
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
|
|
||||||
w.h.PullUpdated(du)
|
|
||||||
w.h.PullClosed(du)
|
|
||||||
if s := w.state(IDOf(cat, headDu)); s != Stopped {
|
|
||||||
t.Fatalf("with the controller down, a closed delivery is %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The walk's record says per module what its build did, and the delivery keeps it beside its walk (§4).
|
|
||||||
func TestAWalksRecordSaysWhetherEachModuleWasReusedOrBuilt(t *testing.T) {
|
|
||||||
walk := catWalk("plan-1", mergeCC, atCC.Add(time.Minute), "claude-code", "dunst")
|
|
||||||
walk.Modules["claude-code"].Made = "reused from the check of novox/mesh-catalog#101 at head cccccccccccc " +
|
|
||||||
"(merged into trunk 553b71910000): tools sha256:aa"
|
|
||||||
walk.Modules["dunst"].Made = "built: no check of novox/mesh-catalog#101 built it"
|
|
||||||
seen := walk.Seen()
|
|
||||||
if !strings.HasPrefix(seen.Built["claude-code"], "reused from the check of novox/mesh-catalog#101") ||
|
|
||||||
seen.Built["dunst"] != "built: no check of novox/mesh-catalog#101 built it" {
|
|
||||||
t.Fatalf("the delivery keeps %v", seen.Built)
|
|
||||||
}
|
|
||||||
if catWalk("plan-2", mergeCC, atCC, "dunst").Seen().Built != nil {
|
|
||||||
t.Fatal("a walk from a controller that says nothing of its builds reads as if it said something")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -110,18 +110,6 @@ type fakeController struct {
|
|||||||
onCheck func()
|
onCheck func()
|
||||||
// planned is what Plan answers instead of "builds app", when set.
|
// planned is what Plan answers instead of "builds app", when set.
|
||||||
planned *DeliveryPlan
|
planned *DeliveryPlan
|
||||||
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
|
|
||||||
forgot []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeController) ForgetChecks(owner, repo string, number int) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.down {
|
|
||||||
return errors.New("down")
|
|
||||||
}
|
|
||||||
c.forgot = append(c.forgot, fmt.Sprintf("%s/%s#%d", owner, repo, number))
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeController() *fakeController {
|
func newFakeController() *fakeController {
|
||||||
|
|||||||
@@ -593,12 +593,9 @@ func verdictOfState(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PullClosed takes a pull request closed unmerged: its open deliveries are stopped.
|
// PullClosed takes a pull request closed unmerged: its open deliveries are stopped.
|
||||||
//
|
|
||||||
// **And what its checks built for its merge goes** (novox/hq ADR 0350 §5): nothing will take it now. Asked of the
|
|
||||||
// controller once the deliveries are settled, outside the lock; a refusal is said, and the build seat's holders let
|
|
||||||
// go of it within the week anyway.
|
|
||||||
func (h *Holder) PullClosed(p PullEvent) {
|
func (h *Holder) PullClosed(p PullEvent) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
for _, d := range h.deliveries {
|
for _, d := range h.deliveries {
|
||||||
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
|
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
|
||||||
d.ClosedUnmerged = true
|
d.ClosedUnmerged = true
|
||||||
@@ -608,16 +605,6 @@ func (h *Holder) PullClosed(p PullEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
|
||||||
if h.Controller == nil || p.Owner == "" || p.Repo == "" || p.Number <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.Controller.ForgetChecks(p.Owner, p.Repo, p.Number); err != nil {
|
|
||||||
h.mu.Lock()
|
|
||||||
h.refuse(fmt.Errorf("%s#%d closed, and its check builds stay until the week's sweep: %w", p.repository(),
|
|
||||||
p.Number, err))
|
|
||||||
h.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckedEvent is the controller's `checked`.
|
// CheckedEvent is the controller's `checked`.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
|
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
|
||||||
want := []string{}
|
want := []string{}
|
||||||
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks", "forget-checks"} {
|
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks"} {
|
||||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||||
}
|
}
|
||||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
||||||
|
|||||||
@@ -402,9 +402,6 @@ type WalkModule struct {
|
|||||||
Build string `json:"build,omitempty"`
|
Build string `json:"build,omitempty"`
|
||||||
Gate *WalkGate `json:"gate,omitempty"`
|
Gate *WalkGate `json:"gate,omitempty"`
|
||||||
GatedBy string `json:"gated_by,omitempty"`
|
GatedBy string `json:"gated_by,omitempty"`
|
||||||
// Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>", naming the pull
|
|
||||||
// request's head, the trunk commit and the digests, or "built: <why>". Empty from a controller before it.
|
|
||||||
Made string `json:"made,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WalkGate is a module's judging on its first machine.
|
// WalkGate is a module's judging on its first machine.
|
||||||
@@ -595,9 +592,6 @@ type WalkSeen struct {
|
|||||||
// Unchanged are the modules the walk said matched what runs, with why (novox/hq issue 611): not built, and
|
// Unchanged are the modules the walk said matched what runs, with why (novox/hq issue 611): not built, and
|
||||||
// nothing for it to deliver.
|
// nothing for it to deliver.
|
||||||
Unchanged map[string]string `json:"unchanged,omitempty"`
|
Unchanged map[string]string `json:"unchanged,omitempty"`
|
||||||
// Built is, per module, what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>" or
|
|
||||||
// "built: <why>" — what `show` says beside the build phase.
|
|
||||||
Built map[string]string `json:"built,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seen is a walk as a delivery keeps it.
|
// Seen is a walk as a delivery keeps it.
|
||||||
@@ -622,12 +616,6 @@ func (w Walk) Seen() *WalkSeen {
|
|||||||
if m != nil && (m.SentAt != nil || m.Gate != nil && m.Gate.Verdict == "passed") {
|
if m != nil && (m.SentAt != nil || m.Gate != nil && m.Gate.Verdict == "passed") {
|
||||||
s.Moved = append(s.Moved, name)
|
s.Moved = append(s.Moved, name)
|
||||||
}
|
}
|
||||||
if m != nil && m.Made != "" {
|
|
||||||
if s.Built == nil {
|
|
||||||
s.Built = map[string]string{}
|
|
||||||
}
|
|
||||||
s.Built[name] = m.Made
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sort.Strings(s.Moved)
|
sort.Strings(s.Moved)
|
||||||
s.MovedSaid = true
|
s.MovedSaid = true
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
@@ -71,8 +70,6 @@ type Controller interface {
|
|||||||
StopWithout(walk, why, by string, without []string) (StopAnswer, error)
|
StopWithout(walk, why, by string, without []string) (StopAnswer, error)
|
||||||
// Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record.
|
// Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record.
|
||||||
Walks(walk string) (bool, []Walk, error)
|
Walks(walk string) (bool, []Walk, error)
|
||||||
// ForgetChecks lets go of the builds a pull request's merge checks made for its merge (novox/hq ADR 0350 §5).
|
|
||||||
ForgetChecks(owner, repo string, number int) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forge is what the forge's holder does for a delivery.
|
// Forge is what the forge's holder does for a delivery.
|
||||||
@@ -274,11 +271,6 @@ func (c seatController) Deliver(walk, why string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c seatController) ForgetChecks(owner, repo string, number int) error {
|
|
||||||
_, err := c.verb("forget-checks", map[string]any{"owner": owner, "repo": repo, "number": strconv.Itoa(number)})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c seatController) Stop(walk, why, by string) error {
|
func (c seatController) Stop(walk, why, by string) error {
|
||||||
_, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by})
|
_, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by})
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"seat:mesh-controller.deliver",
|
"seat:mesh-controller.deliver",
|
||||||
"seat:mesh-controller.delivery-stop",
|
"seat:mesh-controller.delivery-stop",
|
||||||
"seat:mesh-controller.delivery-walks",
|
"seat:mesh-controller.delivery-walks",
|
||||||
"seat:mesh-controller.forget-checks",
|
|
||||||
"gitea.gitea_note_append",
|
"gitea.gitea_note_append",
|
||||||
"gitea.gitea_delivery_view",
|
"gitea.gitea_delivery_view",
|
||||||
"gitea.gitea_commit_status",
|
"gitea.gitea_commit_status",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
|
|||||||
## Writing a record
|
## Writing a record
|
||||||
|
|
||||||
1. `reserve` a number for its kind.
|
1. `reserve` a number for its kind.
|
||||||
2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **30 rules
|
2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **29 rules
|
||||||
that judge one record or its prose**. They are hq's checks moved to the write; `PerRecordRules` in `rules.go`
|
that judge one record or its prose**. They are hq's checks moved to the write; `PerRecordRules` in `rules.go`
|
||||||
lists each one with the check it comes from:
|
lists each one with the check it comes from:
|
||||||
- 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one
|
- 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one
|
||||||
@@ -81,10 +81,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
|
|||||||
supersedes or changes is accepted, a changed clause is in force or changed (a drafted one too), a superseded record cited beside its
|
supersedes or changes is accepted, a changed clause is in force or changed (a drafted one too), a superseded record cited beside its
|
||||||
superseder, a graduated effort says what it became, and the links in its text resolve;
|
superseder, a graduated effort says what it became, and the links in its text resolve;
|
||||||
- 4 on the prose: no word the glossary retired, no daemon's line, a deferral names its tracking issue, and a
|
- 4 on the prose: no word the glossary retired, no daemon's line, a deferral names its tracking issue, and a
|
||||||
progressive insight in the dated, marked form;
|
progressive insight in the dated, marked form.
|
||||||
- 1 on the operator's ask (hq issue 639): `brief`, a record that needs the operator carries a brief its ask reads
|
|
||||||
at a glance — a headline of at most 60 characters, a one-line summary of at most 300, at most three points of
|
|
||||||
at most 160, and no path, address, dotted name or long word. It holds records dated from 2026-10-13.
|
|
||||||
|
|
||||||
A rule holds a record from the day hq's checks began to hold it (`RuleFrom` in `rules.go`):
|
A rule holds a record from the day hq's checks began to hold it (`RuleFrom` in `rules.go`):
|
||||||
- retired words from 2026-10-07 and daemons' lines from 2026-10-09 for a research effort; for a decision record
|
- retired words from 2026-10-07 and daemons' lines from 2026-10-09 for a research effort; for a decision record
|
||||||
@@ -125,10 +122,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
|
|||||||
- On approve the record and its clauses are accepted and in force. Every clause it changes moves to
|
- On approve the record and its clauses are accepted and in force. Every clause it changes moves to
|
||||||
`changed`, and every record it supersedes moves to `superseded`, each as a new version of that record. A record marked `needs-operator` is first asked of the
|
`changed`, and every record it supersedes moves to `superseded`, each as a new version of that record. A record marked `needs-operator` is first asked of the
|
||||||
operator (an ask that cannot be published is taken back, and the record is reviewed again later): an ask with
|
operator (an ask that cannot be published is taken back, and the record is reviewed again later): an ask with
|
||||||
Approve and Decline, both at the level approve, each bound to this version's digest. The ask reads at a glance
|
Approve and Decline, both at the level approve, each bound to this version's digest. It is
|
||||||
(hq issue 639): the record's `brief` — a headline saying what it changes for the operator, a summary, at most three
|
|
||||||
points — and as its Details who proposed it, the review, the fingerprint and how to read its clauses; the clauses
|
|
||||||
are never listed. An ask made before that change is composed again as it was asked (`form`). It is
|
|
||||||
accepted only on the router's warrant for Approve, read back from the router's own record and held to the ask
|
accepted only on the router's warrant for Approve, read back from the router's own record and held to the ask
|
||||||
composed again. A record changed after it was asked finds the digests differ, and nothing is accepted. The
|
composed again. A record changed after it was asked finds the digests differ, and nothing is accepted. The
|
||||||
warrant is kept with the record.
|
warrant is kept with the record.
|
||||||
|
|||||||
@@ -309,8 +309,6 @@ type Got struct {
|
|||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
Reserved string `json:"reserved,omitempty"`
|
Reserved string `json:"reserved,omitempty"`
|
||||||
NeedsOperat bool `json:"needs-operator,omitempty"`
|
NeedsOperat bool `json:"needs-operator,omitempty"`
|
||||||
// Brief is what the operator's ask shows of it (issue 639).
|
|
||||||
Brief *Brief `json:"brief,omitempty"`
|
|
||||||
Cut string `json:"cut,omitempty"`
|
Cut string `json:"cut,omitempty"`
|
||||||
Bound int `json:"bound"`
|
Bound int `json:"bound"`
|
||||||
Read string `json:"read,omitempty"`
|
Read string `json:"read,omitempty"`
|
||||||
@@ -351,7 +349,7 @@ func (k *KB) Get(a GetAsk) (*Got, error) {
|
|||||||
bound := boundOf(a.Bound)
|
bound := boundOf(a.Bound)
|
||||||
g := &Got{Record: key.String(), Title: r.Title, Status: r.Status, Topic: r.Topic, Date: r.Date, Deciders: r.Deciders,
|
g := &Got{Record: key.String(), Title: r.Title, Status: r.Status, Topic: r.Topic, Date: r.Date, Deciders: r.Deciders,
|
||||||
Version: version, Versions: e.Versions, Insights: r.Insights, Reviews: r.Reviews, Drafting: r.Drafting, Source: r.Source,
|
Version: version, Versions: e.Versions, Insights: r.Insights, Reviews: r.Reviews, Drafting: r.Drafting, Source: r.Source,
|
||||||
NeedsOperat: r.NeedsOperator, Brief: r.Brief, Bound: bound, Since: k.since(ctx, key),
|
NeedsOperat: r.NeedsOperator, Bound: bound, Since: k.since(ctx, key),
|
||||||
Reconstructed: r.Reconstructed, Amended: r.Amended, Touches: r.Touches, Fields: r.Extra}
|
Reconstructed: r.Reconstructed, Amended: r.Amended, Touches: r.Touches, Fields: r.Extra}
|
||||||
if r.Status == StatusReserved || r.Status == StatusWithdrawn {
|
if r.Status == StatusReserved || r.Status == StatusWithdrawn {
|
||||||
g.Reserved = fmt.Sprintf("reserved by %s at %s for %q", r.ReservedBy, r.ReservedAt.Format("2006-01-02 15:04 UTC"), r.ReservedFor)
|
g.Reserved = fmt.Sprintf("reserved by %s at %s for %q", r.ReservedBy, r.ReservedAt.Format("2006-01-02 15:04 UTC"), r.ReservedFor)
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq issue 639: the operator would not read the ask for ADR 0343 — thirty plain lines, every clause one per
|
|
||||||
// line. A record that needs the operator is asked with its brief: a headline that says what it changes for them, a
|
|
||||||
// summary, at most three points; who proposed it, the fingerprint and how to read the clauses are the Details.
|
|
||||||
|
|
||||||
// adr0343 is ADR 0343 as it was asked on 2026-10-13: thirteen clauses, and the brief its author would now give.
|
|
||||||
func adr0343() *Record {
|
|
||||||
r := &Record{Kind: KindDecision, Number: 343, Status: StatusProposed, Date: "2026-10-13", NeedsOperator: true,
|
|
||||||
Title: "A session's memory follows the operator: the agent module carries each project's memory folder to every node, and a file changed on two keeps both",
|
|
||||||
Author: "fix-367@g14",
|
|
||||||
Brief: &Brief{Headline: "Your agents' memory follows you to every machine",
|
|
||||||
Summary: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
|
|
||||||
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory " +
|
|
||||||
"files are screened, though not perfectly.",
|
|
||||||
Points: []string{"Only memory folders travel: links and files over 128 KiB stay where they are",
|
|
||||||
"A conflict reaches you as a warning that needs you",
|
|
||||||
"A deleted file is marked, and a newer change elsewhere outlives the deletion"}}}
|
|
||||||
leads := []string{"Only the memory folders are carried", "Some memory files are not carried, and each is listed with why",
|
|
||||||
"The memory folders become paths the module's own process writes", "Memory moves only between homes of the same kind",
|
|
||||||
"The module's process on each node notices a change and puts it in the module's state",
|
|
||||||
"Every other node writes the new version where its own file holds no change it did not carry, and keeps the version replaced",
|
|
||||||
"When a file changed on two nodes, both versions are kept, the newer first",
|
|
||||||
"A conflict is sent to the operator as a warning that needs them",
|
|
||||||
"A deletion is carried as a removal marker, and a change outlives it",
|
|
||||||
"On a node's first carrying, every version learned before is kept", "One read-only tool shows what is carried",
|
|
||||||
"A value put straight into the state is not yet told apart from one the module put there", "How it is checked"}
|
|
||||||
for i, l := range leads {
|
|
||||||
r.Clauses = append(r.Clauses, Clause{N: i + 1, Text: "**" + l + ".** The statement.", Status: ClauseProposed, Source: SourceWritten})
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestADecisionAskReadsAtAGlance(t *testing.T) {
|
|
||||||
r := adr0343()
|
|
||||||
at := time.Date(2026, 10, 13, 14, 5, 0, 0, time.UTC)
|
|
||||||
a := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
|
|
||||||
if err := a.Check(at); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if a.Headline != "Your agents' memory follows you to every machine" {
|
|
||||||
t.Errorf("the headline is not what it changes for the operator: %q", a.Headline)
|
|
||||||
}
|
|
||||||
if err := asks.Glance(a.Explanation); err != nil {
|
|
||||||
t.Errorf("%v:\n%s", err, a.Explanation)
|
|
||||||
}
|
|
||||||
if n := strings.Count(a.Explanation, "\n• "); n != 3 || !strings.HasPrefix(a.Explanation, "What an agent learns") {
|
|
||||||
t.Errorf("the summary and its %d points:\n%s", n, a.Explanation)
|
|
||||||
}
|
|
||||||
for _, c := range r.Clauses {
|
|
||||||
if strings.Contains(a.Explanation+a.Details, c.Lead()) {
|
|
||||||
t.Errorf("the ask lists the clause %d: %q", c.N, c.Lead())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
details := strings.Split(a.Details, "\n")
|
|
||||||
if len(details) != 2 || !strings.Contains(details[0], "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it.") ||
|
|
||||||
!strings.Contains(details[0], "Fingerprint "+fingerprint(r.Digest())+".") ||
|
|
||||||
details[1] != "Read its 13 clauses with the knowledge base's get, number 343." {
|
|
||||||
t.Errorf("the details:\n%s", a.Details)
|
|
||||||
}
|
|
||||||
// The ask made before the brief is composed again as it was, so its warrant still holds after the change.
|
|
||||||
old := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12})
|
|
||||||
if !strings.Contains(old.Explanation, "Its 13 clause(s):") || old.Headline != "Accept ADR 0343?" || old.Details != "" || old.Digest() == a.Digest() {
|
|
||||||
t.Errorf("an ask made before the brief is not composed as it was asked:\n%s", old.Explanation)
|
|
||||||
}
|
|
||||||
// Without a brief (a record proposed before the rule), the glance is the title, and no clause.
|
|
||||||
r.Brief = nil
|
|
||||||
a = composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
|
|
||||||
if a.Headline != "Approve ADR 0343?" || asks.Glance(a.Explanation) != nil || strings.Contains(a.Explanation, "Only the memory folders") {
|
|
||||||
t.Errorf("without a brief: %q\n%s", a.Headline, a.Explanation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A brief is held to what the phone reads at a glance, each way it fails named.
|
|
||||||
func TestABriefIsHeldToAGlance(t *testing.T) {
|
|
||||||
good := adr0343().Brief
|
|
||||||
if got := briefProblems(good); len(got) != 0 {
|
|
||||||
t.Fatalf("the brief of ADR 0343 is refused: %v", got)
|
|
||||||
}
|
|
||||||
for name, c := range map[string]struct {
|
|
||||||
change func(b *Brief)
|
|
||||||
says string
|
|
||||||
}{
|
|
||||||
"no headline": {func(b *Brief) { b.Headline = "" }, "headline is empty"},
|
|
||||||
"long headline": {func(b *Brief) { b.Headline = strings.Repeat("memory ", 10) }, "longer than 60"},
|
|
||||||
"no summary": {func(b *Brief) { b.Summary = " " }, "summary is empty"},
|
|
||||||
"long summary": {func(b *Brief) { b.Summary = strings.Repeat("A short sentence. ", 20) }, "summary is"},
|
|
||||||
"four points": {func(b *Brief) { b.Points = append(b.Points, "one more") }, "4 points"},
|
|
||||||
"a path": {func(b *Brief) { b.Points[0] = "Carried from the agent's home under .claude/projects" }, "names a path"},
|
|
||||||
"two lines": {func(b *Brief) { b.Summary = "One.\nTwo." }, "more than one line"},
|
|
||||||
} {
|
|
||||||
b := *good
|
|
||||||
b.Points = append([]string{}, good.Points...)
|
|
||||||
c.change(&b)
|
|
||||||
if got := fmt.Sprint(briefProblems(&b)); !strings.Contains(got, c.says) {
|
|
||||||
t.Errorf("%s: %s", name, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := briefProblems(nil); len(got) != 1 || !strings.Contains(got[0], "carries a brief") {
|
|
||||||
t.Errorf("no brief: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -238,8 +238,6 @@ type ProposeAsk struct {
|
|||||||
Links []LinkAsk
|
Links []LinkAsk
|
||||||
Homes []string
|
Homes []string
|
||||||
NeedsOperator bool
|
NeedsOperator bool
|
||||||
// Brief is what the operator's ask shows (novox/hq issue 639).
|
|
||||||
Brief *Brief
|
|
||||||
// Status is a research effort's: active, graduated or abandoned.
|
// Status is a research effort's: active, graduated or abandoned.
|
||||||
Status string
|
Status string
|
||||||
// Insight, for an accepted record, is what stood before: the proposal is a progressive insight.
|
// Insight, for an accepted record, is what stood before: the proposal is a progressive insight.
|
||||||
@@ -282,7 +280,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
|
|||||||
}
|
}
|
||||||
r := &Record{Kind: a.Kind, Number: a.Number, Title: strings.TrimSpace(a.Title), Topic: strings.TrimSpace(a.Topic),
|
r := &Record{Kind: a.Kind, Number: a.Number, Title: strings.TrimSpace(a.Title), Topic: strings.TrimSpace(a.Topic),
|
||||||
Status: StatusProposed, Date: strings.TrimSpace(a.Date), Deciders: a.Deciders, Effort: a.Status,
|
Status: StatusProposed, Date: strings.TrimSpace(a.Date), Deciders: a.Deciders, Effort: a.Status,
|
||||||
NeedsOperator: a.NeedsOperator, Brief: a.Brief, Sections: a.Sections, Homes: a.Homes, Author: caller}
|
NeedsOperator: a.NeedsOperator, Sections: a.Sections, Homes: a.Homes, Author: caller}
|
||||||
if r.Date == "" {
|
if r.Date == "" {
|
||||||
r.Date = k.now().Format("2006-01-02")
|
r.Date = k.now().Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -914,8 +914,8 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
|||||||
Description: "Propose a record whole under a reserved number: its title, topic, date and deciders; for a decision its " +
|
Description: "Propose a record whole under a reserved number: its title, topic, date and deciders; for a decision its " +
|
||||||
"clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " +
|
"clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " +
|
||||||
"Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " +
|
"Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " +
|
||||||
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " +
|
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator. Refused, naming " +
|
||||||
"every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " +
|
"every reason at once and writing nothing, when it breaks one of the 29 rules that judge one record or its prose — a " +
|
||||||
"link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " +
|
"link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " +
|
||||||
"proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " +
|
"proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " +
|
||||||
"stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
|
"stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
|
||||||
@@ -937,19 +937,13 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
|||||||
"to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}},
|
"to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}},
|
||||||
"homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"),
|
"homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"),
|
||||||
"needs-operator": flag("it holds only once the operator approves it"),
|
"needs-operator": flag("it holds only once the operator approves it"),
|
||||||
"brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " +
|
|
||||||
"in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " +
|
|
||||||
"not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " +
|
|
||||||
"costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word",
|
|
||||||
"properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"),
|
|
||||||
"points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}},
|
|
||||||
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
|
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
|
||||||
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
|
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
|
||||||
"why": text("one line: why this version"),
|
"why": text("one line: why this version"),
|
||||||
}),
|
}),
|
||||||
Run: func(m map[string]any) (any, error) {
|
Run: func(m map[string]any) (any, error) {
|
||||||
a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections",
|
a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections",
|
||||||
"links", "homes", "needs-operator", "brief", "status", "insight", "why")
|
"links", "homes", "needs-operator", "status", "insight", "why")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -983,12 +977,6 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
|||||||
if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil {
|
if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if m["brief"] != nil {
|
|
||||||
p.Brief = &Brief{}
|
|
||||||
if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return k.Propose(caller(m), p)
|
return k.Propose(caller(m), p)
|
||||||
}},
|
}},
|
||||||
{Name: Seat + ".import",
|
{Name: Seat + ".import",
|
||||||
|
|||||||
@@ -121,12 +121,6 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
|
|||||||
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
|
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
|
||||||
t.Fatalf("watch %v, tools %v", watch, m.Tools)
|
t.Fatalf("watch %v, tools %v", watch, m.Tools)
|
||||||
}
|
}
|
||||||
// The watch's verdict is whether the database serves, so its check names the provision it exercises: while the
|
|
||||||
// database's provider is unhealthy, this finding is held under the provider's condition (hq ADR 0240 rule 5,
|
|
||||||
// issue 145).
|
|
||||||
if h["needs"] != "postgres-database" {
|
|
||||||
t.Fatalf("watch's check needs %v; it exercises postgres-database", h["needs"])
|
|
||||||
}
|
|
||||||
if watch["env"].(map[string]any)["MESH_KNOWLEDGE_STATUS"] != m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] {
|
if watch["env"].(map[string]any)["MESH_KNOWLEDGE_STATUS"] != m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] {
|
||||||
t.Fatal("watch writes another status file than the health tool reads")
|
t.Fatal("watch writes another status file than the health tool reads")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,48 +84,9 @@ func fingerprint(digest string) string {
|
|||||||
return strings.Join(g, " ")
|
return strings.Join(g, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AskGlance is the form of an ask read at a glance (OperatorAsk.Form, novox/hq issue 639).
|
|
||||||
const AskGlance = 1
|
|
||||||
|
|
||||||
// composeAsk is the ask for a record, composed from the record and its ask alone: asked once, and composed
|
// composeAsk is the ask for a record, composed from the record and its ask alone: asked once, and composed
|
||||||
// again to hold the warrant to it — in the form it was asked in, so an ask made before a form changed is still
|
// again to hold the warrant to it.
|
||||||
// held to the words the operator was shown.
|
|
||||||
func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
|
func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
|
||||||
if a.Form == AskGlance {
|
|
||||||
return glanceAsk(r, a)
|
|
||||||
}
|
|
||||||
return listedAsk(r, a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): the brief's headline, its summary and
|
|
||||||
// points; who proposed it, its review, its fingerprint and how to read every clause are its Details, which the
|
|
||||||
// router notes after the answers. A record without a brief (one proposed before the rule) is asked with its
|
|
||||||
// title as its summary. The clauses are never listed.
|
|
||||||
func glanceAsk(r *Record, a *OperatorAsk) asks.Ask {
|
|
||||||
name := r.Key().String()
|
|
||||||
headline, explanation := "Approve "+name+"?", name+" needs your approval before it holds."
|
|
||||||
if t := strings.TrimSuffix(strings.TrimSpace(r.Title), "."); shown(t) && len([]rune(t)) < asks.SummaryLength {
|
|
||||||
explanation = t + "."
|
|
||||||
}
|
|
||||||
if r.Brief != nil && len(briefProblems(r.Brief)) == 0 {
|
|
||||||
headline, explanation = strings.TrimSpace(r.Brief.Headline), r.Brief.Explanation()
|
|
||||||
}
|
|
||||||
author := r.Author
|
|
||||||
if author == "" {
|
|
||||||
author = "an agent session"
|
|
||||||
}
|
|
||||||
clauses := fmt.Sprintf("Read its %d clauses with the knowledge base's get, number %d.", len(r.Clauses), r.Number)
|
|
||||||
if len(r.Clauses) == 0 {
|
|
||||||
clauses = fmt.Sprintf("Read it whole with the knowledge base's get, number %d.", r.Number)
|
|
||||||
}
|
|
||||||
details := fmt.Sprintf("%s, proposed by %s; the knowledge base's own review approved it. Fingerprint %s.\n%s",
|
|
||||||
name, author, fingerprint(r.Digest()), clauses)
|
|
||||||
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: explanation, Details: details})
|
|
||||||
}
|
|
||||||
|
|
||||||
// listedAsk is the ask as it was composed before the brief: every clause's lead listed. Composed only to hold
|
|
||||||
// the warrant of an ask made then.
|
|
||||||
func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
|
|
||||||
name := r.Key().String()
|
name := r.Key().String()
|
||||||
headline := "Accept " + name + "?"
|
headline := "Accept " + name + "?"
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -150,15 +111,9 @@ func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "\n\nRead it whole with the knowledge base's get, number %d; its fingerprint is %s.", r.Number, fingerprint(r.Digest()))
|
fmt.Fprintf(&b, "\n\nRead it whole with the knowledge base's get, number %d; its fingerprint is %s.", r.Number, fingerprint(r.Digest()))
|
||||||
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
|
out := asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String(), Who: asks.Operator,
|
||||||
}
|
Expires: a.At.Add(AskLasts), OnExpiry: "the record stays proposed; nothing of it is in force",
|
||||||
|
About: "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)}
|
||||||
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its
|
|
||||||
// two options, each binding its act.
|
|
||||||
func withOptions(r *Record, a *OperatorAsk, out asks.Ask) asks.Ask {
|
|
||||||
out.Who, out.Expires = asks.Operator, a.At.Add(AskLasts)
|
|
||||||
out.OnExpiry = "the record stays proposed; nothing of it is in force"
|
|
||||||
out.About = "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)
|
|
||||||
for _, o := range []struct{ id, label, does string }{
|
for _, o := range []struct{ id, label, does string }{
|
||||||
{OptionApprove, "Approve", "the record is accepted and its clauses are in force"},
|
{OptionApprove, "Approve", "the record is accepted and its clauses are in force"},
|
||||||
{OptionDecline, "Decline", "the record stays proposed; nothing of it is in force"},
|
{OptionDecline, "Decline", "the record stays proposed; nothing of it is in force"},
|
||||||
@@ -202,7 +157,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
|
|||||||
if k.Asks == nil {
|
if k.Asks == nil {
|
||||||
return unasked("the operator cannot be asked from here")
|
return unasked("the operator cannot be asked from here")
|
||||||
}
|
}
|
||||||
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting, Form: AskGlance}
|
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting}
|
||||||
ask := composeAsk(w, oa)
|
ask := composeAsk(w, oa)
|
||||||
if err := ask.Check(k.now()); err != nil {
|
if err := ask.Check(k.now()); err != nil {
|
||||||
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
|
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
|
||||||
|
|||||||
@@ -248,9 +248,6 @@ type OperatorAsk struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
// Form is how the ask was composed, so it is composed again the same way to hold the warrant to it: 0 for
|
|
||||||
// an ask made before the brief (the clause leads listed), AskGlance for one read at a glance (issue 639).
|
|
||||||
Form int `json:"form,omitempty"`
|
|
||||||
// Said is what came of it: waiting, approved, declined, expired, refused (and why).
|
// Said is what came of it: waiting, approved, declined, expired, refused (and why).
|
||||||
Said string `json:"said"`
|
Said string `json:"said"`
|
||||||
Warrant string `json:"warrant,omitempty"`
|
Warrant string `json:"warrant,omitempty"`
|
||||||
@@ -296,9 +293,6 @@ type Record struct {
|
|||||||
// Effort is the status a proposed research effort takes when it is accepted.
|
// Effort is the status a proposed research effort takes when it is accepted.
|
||||||
Effort string `json:"effort,omitempty"`
|
Effort string `json:"effort,omitempty"`
|
||||||
NeedsOperator bool `json:"needs-operator,omitempty"`
|
NeedsOperator bool `json:"needs-operator,omitempty"`
|
||||||
// Brief is what the operator's phone shows when the record asks for their Approve: written by its author,
|
|
||||||
// in the operator's words (novox/hq issue 639). Required of a record that needs the operator (the rule brief).
|
|
||||||
Brief *Brief `json:"brief,omitempty"`
|
|
||||||
Clauses []Clause `json:"clauses,omitempty"`
|
Clauses []Clause `json:"clauses,omitempty"`
|
||||||
Links []Link `json:"links,omitempty"`
|
Links []Link `json:"links,omitempty"`
|
||||||
Sections []Section `json:"sections,omitempty"`
|
Sections []Section `json:"sections,omitempty"`
|
||||||
@@ -335,25 +329,6 @@ type Insight struct {
|
|||||||
By string `json:"by,omitempty"`
|
By string `json:"by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brief is a record as the operator is asked about it, on a phone, at a glance (novox/hq issue 639): a headline
|
|
||||||
// that says what the record changes for the operator — not its title — a summary of two or three short
|
|
||||||
// sentences on what changes, why, and what it costs or risks, and at most three points, only where they add
|
|
||||||
// something. The clauses are never listed in the ask; it says how to read them.
|
|
||||||
type Brief struct {
|
|
||||||
Headline string `json:"headline"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Points []string `json:"points,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explanation is the brief as the ask explains it: the summary, then a line for each point.
|
|
||||||
func (b Brief) Explanation() string {
|
|
||||||
out := strings.TrimSpace(b.Summary)
|
|
||||||
for _, p := range b.Points {
|
|
||||||
out += "\n• " + strings.TrimSpace(p)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// PendingState marks a version that is a proposal over an accepted record: a progressive insight waiting for
|
// PendingState marks a version that is a proposal over an accepted record: a progressive insight waiting for
|
||||||
// its review. The accepted version stays the one served until it is accepted.
|
// its review. The accepted version stays the one served until it is accepted.
|
||||||
type PendingState struct {
|
type PendingState struct {
|
||||||
@@ -467,12 +442,6 @@ func Render(r *Record) string {
|
|||||||
if r.NeedsOperator {
|
if r.NeedsOperator {
|
||||||
field("needs operator", "yes")
|
field("needs operator", "yes")
|
||||||
}
|
}
|
||||||
if r.Brief != nil {
|
|
||||||
field("brief", r.Brief.Headline)
|
|
||||||
for _, l := range strings.Split(r.Brief.Explanation(), "\n") {
|
|
||||||
field("brief", l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
field("homes", strings.Join(r.Homes, ", "))
|
field("homes", strings.Join(r.Homes, ", "))
|
||||||
keys := make([]string, 0, len(r.Extra))
|
keys := make([]string, 0, len(r.Extra))
|
||||||
for k := range r.Extra {
|
for k := range r.Extra {
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ func proposed(t *testing.T, w *world, author string, needsOperator bool) int {
|
|||||||
n := w.reserved(t, author)
|
n := w.reserved(t, author)
|
||||||
p := valid(n)
|
p := valid(n)
|
||||||
p.NeedsOperator = needsOperator
|
p.NeedsOperator = needsOperator
|
||||||
if needsOperator {
|
|
||||||
p.Brief = &Brief{Headline: "Merges land after ninety seconds of quiet",
|
|
||||||
Summary: "A merge window closes after ninety seconds without a merge, so merges land sooner."}
|
|
||||||
}
|
|
||||||
if _, err := w.k.Propose(author, p); err != nil {
|
if _, err := w.k.Propose(author, p); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -249,8 +245,7 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ask := w.router.asked[0]
|
ask := w.router.asked[0]
|
||||||
if err := ask.Check(w.clock); err != nil || ask.Options[0].Level != asks.Approve || ask.Options[1].Level != asks.Approve ||
|
if err := ask.Check(w.clock); err != nil || ask.Options[0].Level != asks.Approve || ask.Options[1].Level != asks.Approve ||
|
||||||
!strings.Contains(ask.Details, "the knowledge base's own review") || ask.Headline != "Merges land after ninety seconds of quiet" ||
|
!strings.Contains(ask.Explanation, "the knowledge base's own review") {
|
||||||
asks.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") {
|
|
||||||
t.Fatalf("the ask: %v %+v", err, ask)
|
t.Fatalf("the ask: %v %+v", err, ask)
|
||||||
}
|
}
|
||||||
// Without the warrant: nothing is accepted, however often the router's record is read or the reviews run.
|
// Without the warrant: nothing is accepted, however often the router's record is read or the reviews run.
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package main
|
|||||||
|
|
||||||
// The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5).
|
// The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5).
|
||||||
//
|
//
|
||||||
// **30 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every
|
// **29 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every
|
||||||
// one it breaks at once. They are hq's checks (00-META/checks/records.py, cycle.py, index.py, words.py) as they
|
// one it breaks at once. They are hq's checks (00-META/checks/records.py, cycle.py, index.py, words.py) as they
|
||||||
// judge one record, moved to the write: 25 on its structure, 4 on its prose; and the brief a record that needs
|
// judge one record, moved to the write: 25 on its structure, 4 on its prose. Each has an id, said in the refusal,
|
||||||
// the operator is asked with (novox/hq issue 639). Each has an id, said in the refusal,
|
|
||||||
// and one case in the table test (TestEveryPerRecordRuleRefusesAProposal).
|
// and one case in the table test (TestEveryPerRecordRuleRefusesAProposal).
|
||||||
//
|
//
|
||||||
// **4 rules judge the whole record set** (wholeSetRules) and run after every acceptance, and in the check
|
// **4 rules judge the whole record set** (wholeSetRules) and run after every acceptance, and in the check
|
||||||
@@ -16,8 +15,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rule is one rule that judges one record: its id and what it holds.
|
// Rule is one rule that judges one record: its id and what it holds.
|
||||||
@@ -28,7 +25,7 @@ type Rule struct {
|
|||||||
From string
|
From string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PerRecordRules are the 30, in the order they are judged.
|
// PerRecordRules are the 29, in the order they are judged.
|
||||||
var PerRecordRules = []Rule{
|
var PerRecordRules = []Rule{
|
||||||
{"kind", "a record is a decision or a research effort", "cycle.py"},
|
{"kind", "a record is a decision or a research effort", "cycle.py"},
|
||||||
{"reserved", "its number was reserved for its kind and holds no other record", "cycle.py (duplicate numbers)"},
|
{"reserved", "its number was reserved for its kind and holds no other record", "cycle.py (duplicate numbers)"},
|
||||||
@@ -59,7 +56,6 @@ var PerRecordRules = []Rule{
|
|||||||
{"daemon-line", "it writes no daemon's line", "words.py lines"},
|
{"daemon-line", "it writes no daemon's line", "words.py lines"},
|
||||||
{"deferral", "a paragraph that puts work off names the issue that tracks it", "cycle.py deferrals"},
|
{"deferral", "a paragraph that puts work off names the issue that tracks it", "cycle.py deferrals"},
|
||||||
{"insight-form", "a progressive insight its text mentions is in the dated, marked form", "records.py insights"},
|
{"insight-form", "a progressive insight its text mentions is in the dated, marked form", "records.py insights"},
|
||||||
{"brief", "a record that needs the operator carries a brief its ask reads at a glance: a headline, a summary, at most three points, each fit for the phone", "novox/hq issue 639"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refusal is one rule a record breaks, and how.
|
// Refusal is one rule a record breaks, and how.
|
||||||
@@ -120,7 +116,7 @@ func allText(r *Record) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// JudgeRecord holds a proposed record to the 30 rules. held is the entry its number holds (a reservation, the
|
// JudgeRecord holds a proposed record to the 29 rules. held is the entry its number holds (a reservation, the
|
||||||
// proposal being revised, or the accepted record an insight corrects); lookup reads every other record.
|
// proposal being revised, or the accepted record an insight corrects); lookup reads every other record.
|
||||||
func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal {
|
func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal {
|
||||||
var out []Refusal
|
var out []Refusal
|
||||||
@@ -353,11 +349,6 @@ func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusa
|
|||||||
refuse("insight-form", "a progressive insight dated %s predates the record (%s)", mk[1], r.Date)
|
refuse("insight-form", "a progressive insight dated %s predates the record (%s)", mk[1], r.Date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.NeedsOperator || r.Brief != nil {
|
|
||||||
for _, why := range briefProblems(r.Brief) {
|
|
||||||
refuse("brief", "%s", why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,7 +597,6 @@ var RuleFrom = map[string]string{
|
|||||||
"consequences": "2026-10-11",
|
"consequences": "2026-10-11",
|
||||||
"checked": "2026-10-11",
|
"checked": "2026-10-11",
|
||||||
"live-citation": "2026-10-11",
|
"live-citation": "2026-10-11",
|
||||||
"brief": "2026-10-13",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecisionFrom overrides RuleFrom for a decision record: hq's words check never read 02-DECISIONS ("a decision
|
// DecisionFrom overrides RuleFrom for a decision record: hq's words check never read 02-DECISIONS ("a decision
|
||||||
@@ -625,35 +615,3 @@ func Holds(rule, kind, date string) bool {
|
|||||||
}
|
}
|
||||||
return !dated || !isDay.MatchString(date) || date >= from
|
return !dated || !isDay.MatchString(date) || date >= from
|
||||||
}
|
}
|
||||||
|
|
||||||
// briefProblems are the ways a brief would not read at a glance on the operator's phone (novox/hq issue 639):
|
|
||||||
// missing; a headline empty or longer than an ask's; a summary or a point the router could refuse, or that
|
|
||||||
// names a path, an address or a long word (shown); more than three points, or one too long (asks.Glance).
|
|
||||||
func briefProblems(b *Brief) []string {
|
|
||||||
if b == nil {
|
|
||||||
return []string{"a record that needs the operator carries a brief (headline, summary, points): what it changes for the operator, " +
|
|
||||||
"in their words, which its ask shows in place of its clauses"}
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
h := strings.TrimSpace(b.Headline)
|
|
||||||
if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > asks.HeadlineLength {
|
|
||||||
out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", asks.HeadlineLength))
|
|
||||||
}
|
|
||||||
if s := strings.TrimSpace(b.Summary); s == "" || strings.Contains(s, "\n") {
|
|
||||||
out = append(out, "the brief's summary is empty or more than one line")
|
|
||||||
}
|
|
||||||
for i, p := range b.Points {
|
|
||||||
if p = strings.TrimSpace(p); p == "" || strings.Contains(p, "\n") {
|
|
||||||
out = append(out, fmt.Sprintf("the brief's point %d is empty or more than one line", i+1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := asks.Glance(b.Explanation()); err != nil {
|
|
||||||
out = append(out, "the brief "+strings.TrimPrefix(err.Error(), "the explanation "))
|
|
||||||
}
|
|
||||||
for _, line := range append([]string{h}, strings.Split(b.Explanation(), "\n")...) {
|
|
||||||
if !shown(line) {
|
|
||||||
out = append(out, fmt.Sprintf("the brief's line %q names a path, an address, a dotted name or a long word, which the operator's phone is not shown", line))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -90,9 +90,6 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) {
|
|||||||
{"insight-form", func(p *ProposeAsk, w *world, t *testing.T) {
|
{"insight-form", func(p *ProposeAsk, w *world, t *testing.T) {
|
||||||
p.Sections[2].Text = "A progressive insight corrected the count."
|
p.Sections[2].Text = "A progressive insight corrected the count."
|
||||||
}},
|
}},
|
||||||
{"brief", func(p *ProposeAsk, w *world, t *testing.T) {
|
|
||||||
p.Date, p.NeedsOperator = "2026-10-13", true
|
|
||||||
}},
|
|
||||||
}
|
}
|
||||||
covered := map[string]bool{}
|
covered := map[string]bool{}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -103,8 +100,8 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) {
|
|||||||
t.Errorf("the rule %s has no case", r.ID)
|
t.Errorf("the rule %s has no case", r.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(PerRecordRules) != 30 {
|
if len(PerRecordRules) != 29 {
|
||||||
t.Errorf("%d per-record rules; ADR 0290 counts 29, and issue 639 adds the brief", len(PerRecordRules))
|
t.Errorf("%d per-record rules; ADR 0290 counts 29", len(PerRecordRules))
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.rule, func(t *testing.T) {
|
t.Run(c.rule, func(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module mesh-knowledge
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.51.0
|
github.com/nats-io/nats.go v1.51.0
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf h1:ED/+RszKwm86U7H/NZRbS4b5LzQ8n7rubqKmeFqFtvM=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf h1:ED/+RszKwm86U7H/NZRbS4b5LzQ8n7rubqKmeFqFtvM=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -156,7 +156,6 @@
|
|||||||
"health": {
|
"health": {
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"tool": "knowledge_health",
|
"tool": "knowledge_health",
|
||||||
"needs": "postgres-database",
|
|
||||||
"interval": "60s",
|
"interval": "60s",
|
||||||
"timeout": "10s",
|
"timeout": "10s",
|
||||||
"looks": 2,
|
"looks": 2,
|
||||||
|
|||||||
@@ -129,23 +129,8 @@ operator (urgent at its bound, or its words now say "Needs you:"). An urgent con
|
|||||||
|
|
||||||
**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router
|
**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router
|
||||||
calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one
|
calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one
|
||||||
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen, on a
|
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A
|
||||||
condition's message. A warning kept quiet is also said once after a day.
|
warning kept quiet is also said once after a day.
|
||||||
|
|
||||||
**An ask's message reads at a glance** (hq issue 639). Its title is the asker's headline and its body the asker's
|
|
||||||
explanation alone — a summary, then at most three points (`asks.Glance`; an ask that does not hold to it is shown
|
|
||||||
and said in the log). Everything else is the message's note (`asks.Message.Note`), which a channel shows apart and
|
|
||||||
smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered;
|
|
||||||
and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers
|
|
||||||
and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to
|
|
||||||
the secret rule alone, as the whole words are. Answered, the message is one line —
|
|
||||||
"You chose Approve at 16:06 on Telegram · <headline>" — and the router's word on the asker is its note.
|
|
||||||
|
|
||||||
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
|
|
||||||
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
|
|
||||||
are. The whole is shown only on a kind the controller issued as `verified-sender` and that was given the ask's
|
|
||||||
answers; everywhere else, and for every condition, the explanation under the content rule stands. A secret's
|
|
||||||
shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole (`CheckSecret`).
|
|
||||||
|
|
||||||
| When | What |
|
| When | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -367,8 +367,8 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
|
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
|
||||||
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
|
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
|
||||||
edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." {
|
!strings.HasPrefix(edited.Body, "Told to mesh-delivery, which acts on it itself.\n") {
|
||||||
t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
|
t.Errorf("%s still offers %q, or does not say how it ended: %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
|
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
|
||||||
@@ -625,7 +625,7 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
|
|||||||
edited = m
|
edited = m
|
||||||
}
|
}
|
||||||
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
|
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
|
||||||
edited.Body != "" || edited.Note != "The delivery stays held." {
|
!strings.HasPrefix(edited.Body, "The delivery stays held.") {
|
||||||
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
|
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,50 +163,6 @@ func (h *Holder) openAsk(r *Record) *AskRecord {
|
|||||||
return &rec
|
return &rec
|
||||||
}
|
}
|
||||||
|
|
||||||
// wholeOf is an ask's explanation with its values whole (asks.Ask.Whole), where an ask's own message on one
|
|
||||||
// kind shows it in place of the explanation (novox/hq issue 383): on a kind that proves who answers by the
|
|
||||||
// controller's fresh word (Promises, as every other verified-sender judgement here), and that the router gave
|
|
||||||
// the ask's answers (a ticket there, so the kind was linked and root-free when the ask was taken). Empty where
|
|
||||||
// the kind proves nothing, the ask has no whole words, or the record is a condition's — a condition's words
|
|
||||||
// stay under the whole content rule, whatever an ask about it carries — then the explanation, held to that
|
|
||||||
// rule, is shown. The ask's state does not matter: a message edited to say how the ask ended still shows what
|
|
||||||
// was approved.
|
|
||||||
func (h *Holder) wholeOf(r *Record, kind string) string {
|
|
||||||
if a := h.provenAsk(r, kind); a != nil {
|
|
||||||
return a.Whole
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// wholeDetailsOf is an ask's Details with their values whole (asks.Ask.WholeDetails), shown in the note in place of
|
|
||||||
// its Details where wholeOf would show its whole words: every value an approval sets, so the operator reads exactly
|
|
||||||
// what they approve (the review of mesh-controller #304). Empty elsewhere.
|
|
||||||
func (h *Holder) wholeDetailsOf(r *Record, kind string) string {
|
|
||||||
if a := h.provenAsk(r, kind); a != nil {
|
|
||||||
return a.WholeDetails
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// provenAsk is the ask of an ask's own record when the kind proves who answers and was given the ask's answers;
|
|
||||||
// nil otherwise.
|
|
||||||
func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask {
|
|
||||||
if r == nil || r.Origin != "ask" || r.Ask == "" || kind == "" || h.Bench == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
e := h.askOf(r.Ask)
|
|
||||||
if e == nil || !h.Bench.Promises(kind, "verified-sender") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, tk := range e.rec.Tickets {
|
|
||||||
if tk.Kind == kind {
|
|
||||||
a := e.rec.Ask
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
|
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
|
||||||
// approve verifying their sender with an account of the operator's linked, where the controller says now
|
// approve verifying their sender with an account of the operator's linked, where the controller says now
|
||||||
// that no agent can become root (rootFree, asked live).
|
// that no agent can become root (rootFree, asked live).
|
||||||
@@ -483,18 +439,6 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
|||||||
h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words")
|
h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// An ask that does not read at a glance is said, and shown all the same: the bound is its asker's to keep
|
|
||||||
// (asks.Glance, issue 639), and refusing it would leave the operator unasked.
|
|
||||||
if err := asks.Glance(a.Explanation); err != nil {
|
|
||||||
h.Logf("[messenger] %s's ask %q is a wall of text on the phone: %v", asker, a.Headline, err)
|
|
||||||
}
|
|
||||||
// The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand
|
|
||||||
// in them; a secret's shape leaves the mesh nowhere, and an ask carrying one is refused as a whole, never
|
|
||||||
// shown with its explanation alone (novox/hq issue 383).
|
|
||||||
if refusal, ok := CheckSecret(a.Whole + "\n" + a.WholeDetails); !ok {
|
|
||||||
h.refuseAsk(asker, a, "its whole words carry "+refusal.String()+", which may not leave the mesh on any channel: withhold it")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
open, replaced := 0, ""
|
open, replaced := 0, ""
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
for k, e := range h.asks {
|
for k, e := range h.asks {
|
||||||
@@ -1096,7 +1040,7 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) {
|
|||||||
if h.Publish == nil {
|
if h.Publish == nil {
|
||||||
return "", errors.New("this router cannot reach the bus")
|
return "", errors.New("this router cannot reach the bus")
|
||||||
}
|
}
|
||||||
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
|
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
|
||||||
Secret: m.Secret}
|
Secret: m.Secret}
|
||||||
if out.To == "" && h.Bench != nil && h.Bench.To != nil {
|
if out.To == "" && h.Bench != nil && h.Bench.To != nil {
|
||||||
out.To = h.Bench.To(kind)
|
out.To = h.Bench.To(kind)
|
||||||
@@ -1121,7 +1065,7 @@ func (h *Holder) showOn(kind string, m Message) error {
|
|||||||
if h.Publish == nil {
|
if h.Publish == nil {
|
||||||
return errors.New("this router cannot reach the bus")
|
return errors.New("this router cannot reach the bus")
|
||||||
}
|
}
|
||||||
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
|
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
|
||||||
if h.Bench != nil && h.Bench.To != nil {
|
if h.Bench != nil && h.Bench.To != nil {
|
||||||
out.To = h.Bench.To(kind)
|
out.To = h.Bench.To(kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,59 +65,6 @@ func Check(text string) (Refusal, bool) {
|
|||||||
return Refusal{"address", "a hardware address"}, false
|
return Refusal{"address", "a hardware address"}, false
|
||||||
case reIPv6.MatchString(text):
|
case reIPv6.MatchString(text):
|
||||||
return Refusal{"address", "an IPv6 address"}, false
|
return Refusal{"address", "an IPv6 address"}, false
|
||||||
}
|
|
||||||
if refusal, ok := secretShape(text); !ok {
|
|
||||||
return refusal, false
|
|
||||||
}
|
|
||||||
if reWinPath.MatchString(text) {
|
|
||||||
return Refusal{"path", "a drive path"}, false
|
|
||||||
}
|
|
||||||
if refusal, ok := randomRun(text); !ok {
|
|
||||||
return refusal, false
|
|
||||||
}
|
|
||||||
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
|
|
||||||
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
|
||||||
}) {
|
|
||||||
w := strings.TrimRight(word, ".:!?")
|
|
||||||
if isPath(w) {
|
|
||||||
return Refusal{"path", "a file path"}, false
|
|
||||||
}
|
|
||||||
if isHostName(w) {
|
|
||||||
return Refusal{"address", "a host name"}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Refusal{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
|
|
||||||
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
|
||||||
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
|
|
||||||
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
|
|
||||||
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
|
|
||||||
// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever
|
|
||||||
// the run holds.
|
|
||||||
func CheckSecret(text string) (Refusal, bool) {
|
|
||||||
if refusal, ok := secretShape(text); !ok {
|
|
||||||
return refusal, false
|
|
||||||
}
|
|
||||||
return randomRunOutsidePaths(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
|
|
||||||
func randomRunOutsidePaths(text string) (Refusal, bool) {
|
|
||||||
for _, run := range reRun.FindAllString(text, -1) {
|
|
||||||
for _, piece := range strings.Split(run, "/") {
|
|
||||||
if len(piece) >= 20 && looksRandom(piece) {
|
|
||||||
return Refusal{"secret", "a long random-looking string"}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Refusal{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretShape is the secret shapes a pattern names.
|
|
||||||
func secretShape(text string) (Refusal, bool) {
|
|
||||||
switch {
|
|
||||||
case rePEM.MatchString(text):
|
case rePEM.MatchString(text):
|
||||||
return Refusal{"secret", "a key block"}, false
|
return Refusal{"secret", "a key block"}, false
|
||||||
case reJWT.MatchString(text):
|
case reJWT.MatchString(text):
|
||||||
@@ -130,17 +77,25 @@ func secretShape(text string) (Refusal, bool) {
|
|||||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||||
case reHex.MatchString(text):
|
case reHex.MatchString(text):
|
||||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||||
|
case reWinPath.MatchString(text):
|
||||||
|
return Refusal{"path", "a drive path"}, false
|
||||||
}
|
}
|
||||||
return Refusal{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomRun is a long unbroken run of characters spread as a random string's are.
|
|
||||||
func randomRun(text string) (Refusal, bool) {
|
|
||||||
for _, run := range reRun.FindAllString(text, -1) {
|
for _, run := range reRun.FindAllString(text, -1) {
|
||||||
if looksRandom(run) {
|
if looksRandom(run) {
|
||||||
return Refusal{"secret", "a long random-looking string"}, false
|
return Refusal{"secret", "a long random-looking string"}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
|
||||||
|
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
||||||
|
}) {
|
||||||
|
w := strings.TrimRight(word, ".:!?")
|
||||||
|
if isPath(w) {
|
||||||
|
return Refusal{"path", "a file path"}, false
|
||||||
|
}
|
||||||
|
if isHostName(w) {
|
||||||
|
return Refusal{"address", "a host name"}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
return Refusal{}, true
|
return Refusal{}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) {
|
|||||||
for _, m := range tg.edits {
|
for _, m := range tg.edits {
|
||||||
edited = m
|
edited = m
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") {
|
if !strings.HasPrefix(edited.Body, "Told to mesh-delivery as soon as the bus takes it") {
|
||||||
t.Errorf("the question claims its asker was told: %q", edited.Note)
|
t.Errorf("the question claims its asker was told: %q", edited.Body)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
|
|
||||||
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered
|
|
||||||
// it is one line with the headline.
|
|
||||||
|
|
||||||
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
|
|
||||||
func decisionAsk(c *clock) asks.Ask {
|
|
||||||
return asks.Ask{ID: "kb-decision-343-0d1e2f3a", Headline: "Your agents' memory follows you to every machine",
|
|
||||||
Explanation: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
|
|
||||||
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
|
|
||||||
"are screened, though not perfectly.\n" +
|
|
||||||
"• Only memory folders travel: links and files over 128 KiB stay where they are\n" +
|
|
||||||
"• A conflict reaches you as a warning that needs you\n" +
|
|
||||||
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
|
|
||||||
Who: asks.Operator, Expires: c.now().Add(23*time.Hour + 50*time.Minute), OnExpiry: "the record stays proposed; nothing of it is in force",
|
|
||||||
About: "knowledge.decision.343",
|
|
||||||
Details: "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
|
|
||||||
"Read its 13 clauses with the knowledge base's get, number 343.",
|
|
||||||
Options: []asks.Option{
|
|
||||||
{ID: "approve", Label: "Approve", Does: "the record is accepted and its clauses are in force", Level: asks.Approve, Binds: bound("approve")},
|
|
||||||
{ID: "decline", Label: "Decline", Does: "the record stays proposed; nothing of it is in force", Level: asks.Approve, Binds: bound("decline")},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
|
|
||||||
h, tg, _, c, store, _ := newRouter(t)
|
|
||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
|
||||||
a := decisionAsk(c)
|
|
||||||
submit(t, h, "mesh-knowledge", a)
|
|
||||||
settle(h, c)
|
|
||||||
if len(tg.sends) != 1 {
|
|
||||||
t.Fatalf("telegram got %d messages", len(tg.sends))
|
|
||||||
}
|
|
||||||
m := tg.sends[0]
|
|
||||||
if m.Title != a.Headline || m.Body != a.Explanation {
|
|
||||||
t.Errorf("the body is not the asker's summary and points alone:\n%s\n%s", m.Title, m.Body)
|
|
||||||
}
|
|
||||||
note := strings.Split(m.Note, "\n")
|
|
||||||
asked := c.now().In(time.UTC).Format("15:04")
|
|
||||||
want := []string{
|
|
||||||
"Asked by mesh-knowledge at " + asked + ". Unanswered by tomorrow " + a.Expires.In(time.UTC).Format("15:04") +
|
|
||||||
": the record stays proposed; nothing of it is in force.",
|
|
||||||
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.",
|
|
||||||
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.",
|
|
||||||
"Read its 13 clauses with the knowledge base's get, number 343.",
|
|
||||||
}
|
|
||||||
if strings.Join(note, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Errorf("the note:\n%s\nwant:\n%s", m.Note, strings.Join(want, "\n"))
|
|
||||||
}
|
|
||||||
if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 {
|
|
||||||
t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text())
|
|
||||||
}
|
|
||||||
// Answered, it is one line with the headline; the body is not said again.
|
|
||||||
if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" {
|
|
||||||
t.Fatalf("Approve: %q %v", out, err)
|
|
||||||
}
|
|
||||||
var edited Message
|
|
||||||
for _, e := range tg.edits {
|
|
||||||
edited = e
|
|
||||||
}
|
|
||||||
if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" ||
|
|
||||||
strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 {
|
|
||||||
t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An ask that does not read at a glance is shown all the same, and said: refusing it would leave the operator
|
|
||||||
// unasked, and the bound is its asker's to keep.
|
|
||||||
func TestAnAskThatIsAWallOfTextIsShownAndSaid(t *testing.T) {
|
|
||||||
h, tg, _, c, store, _ := newRouter(t)
|
|
||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
|
||||||
var said []string
|
|
||||||
h.Logf = func(f string, a ...any) { said = append(said, f) }
|
|
||||||
a := decisionAsk(c)
|
|
||||||
a.Explanation += "\nA fourth point\nA fifth point"
|
|
||||||
submit(t, h, "mesh-knowledge", a)
|
|
||||||
settle(h, c)
|
|
||||||
if len(tg.sends) != 1 || !strings.Contains(strings.Join(said, "\n"), "is a wall of text on the phone") {
|
|
||||||
t.Errorf("sent %d, said %v", len(tg.sends), said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,9 +92,6 @@ const (
|
|||||||
type Message struct {
|
type Message struct {
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
// Note is the secondary words, after the body and the answers: who asked, what each answer does, the
|
|
||||||
// expiry, the asker's Details (asks.Message.Note, issue 639).
|
|
||||||
Note string
|
|
||||||
Urgent bool
|
Urgent bool
|
||||||
Quiet bool // a clearing: shown without urgency
|
Quiet bool // a clearing: shown without urgency
|
||||||
Actions []Choice
|
Actions []Choice
|
||||||
@@ -114,13 +111,10 @@ type Choice struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Message) Text() string {
|
func (m Message) Text() string {
|
||||||
t := m.Title
|
if m.Body == "" {
|
||||||
for _, more := range []string{m.Body, m.Note} {
|
return m.Title
|
||||||
if more != "" {
|
|
||||||
t += "\n" + more
|
|
||||||
}
|
}
|
||||||
}
|
return m.Title + "\n" + m.Body
|
||||||
return t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel is one way to the operator.
|
// Channel is one way to the operator.
|
||||||
@@ -845,16 +839,6 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
|||||||
if what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" {
|
if what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
// On a kind that proves who answers, an ask's explanation is shown with its values whole (issue 383): the
|
|
||||||
// path or the address the operator approves is readable there; a secret's shape still is not.
|
|
||||||
whole := h.wholeOf(r, kind)
|
|
||||||
if whole != "" {
|
|
||||||
if wm, ok := h.wholeWords(r, what, whole); ok {
|
|
||||||
m = wm
|
|
||||||
} else {
|
|
||||||
whole = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.Answered != "" {
|
if r.Answered != "" {
|
||||||
m = h.answeredWords(r, m)
|
m = h.answeredWords(r, m)
|
||||||
}
|
}
|
||||||
@@ -863,13 +847,11 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
|||||||
}
|
}
|
||||||
withheld := map[string]bool{} // the labels of answers whose effect could not be said
|
withheld := map[string]bool{} // the labels of answers whose effect could not be said
|
||||||
if rec := h.openAsk(r); rec != nil {
|
if rec := h.openAsk(r); rec != nil {
|
||||||
// The note (issue 639): who asks, as the bus proves it (the asker is the subject's, never the body's), what
|
// Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does,
|
||||||
// each answer does, what happens if nobody answers (the review of 2026-10-09), where else it is answered,
|
// and what happens if nobody answers (the review of 2026-10-09).
|
||||||
// and the asker's Details. The body stays the asker's summary and points, read at a glance.
|
for _, line := range h.askLines(rec) {
|
||||||
var note []string
|
|
||||||
for _, line := range h.askLines(rec, r) {
|
|
||||||
if _, ok := Check(line); ok {
|
if _, ok := Check(line); ok {
|
||||||
note = append(note, line)
|
m.Body = strings.TrimSpace(m.Body + "\n" + line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line
|
// **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line
|
||||||
@@ -882,25 +864,9 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
|||||||
}
|
}
|
||||||
if line := h.elsewhere(rec, kind); line != "" {
|
if line := h.elsewhere(rec, kind); line != "" {
|
||||||
if _, ok := Check(line); ok {
|
if _, ok := Check(line); ok {
|
||||||
note = append(note, line)
|
m.Body = strings.TrimSpace(m.Body + "\n" + line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// On a kind that proves who answers, the Details whole (issue 383): every value an approval sets, held to
|
|
||||||
// the secret rule alone, as the whole words are.
|
|
||||||
details, passes := rec.Ask.Details, Check
|
|
||||||
if wd := h.wholeDetailsOf(r, kind); wd != "" {
|
|
||||||
details, passes = wd, CheckSecret
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(details), "\n") {
|
|
||||||
if line = strings.TrimSpace(line); line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if refusal, ok := passes(line); !ok {
|
|
||||||
line = withheldLine(line, refusal, passes)
|
|
||||||
}
|
|
||||||
note = append(note, line)
|
|
||||||
}
|
|
||||||
m.Note = strings.Join(note, "\n")
|
|
||||||
}
|
}
|
||||||
for _, c := range h.choices(r, kind) {
|
for _, c := range h.choices(r, kind) {
|
||||||
if !withheld[c.Label] {
|
if !withheld[c.Label] {
|
||||||
@@ -911,58 +877,38 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// answeredWords is a message that says what came of its question. An ask's own message is the question
|
// answeredWords is a message that says what came of its question. An ask's own message is the question
|
||||||
// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram ·
|
// itself, so its title becomes how it ended — "You chose Approve at 13:30 on Telegram · <headline>" — read at
|
||||||
// <headline>" — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body
|
// a glance on the phone, where the edit rings nothing (issue 372); the rest of what came of it, the
|
||||||
// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's
|
// explanation and when it was asked follow. A condition's message keeps its own title, and says it first.
|
||||||
// message keeps its own title, and says it first.
|
|
||||||
func (h *Holder) answeredWords(r *Record, m Message) Message {
|
func (h *Holder) answeredWords(r *Record, m Message) Message {
|
||||||
if r.Origin == "ask" {
|
if r.Origin == "ask" {
|
||||||
first, rest, _ := strings.Cut(r.Answered, "\n")
|
first, rest, _ := strings.Cut(r.Answered, "\n")
|
||||||
headline, _ := wordsOf(r)
|
headline, explanation := wordsOf(r)
|
||||||
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest),
|
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Urgent: m.Urgent, Quiet: m.Quiet,
|
||||||
Urgent: m.Urgent, Quiet: m.Quiet}
|
Body: strings.TrimSpace(rest + "\n" + explanation + "\nAsked " + when(r.Raised, h.Now(), h.zone()) + ".")}
|
||||||
if _, ok := Check(out.Text()); ok {
|
if _, ok := Check(out.Text()); ok {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
// Its headline refused (it is the asker's): the outcome is still said, before what may leave.
|
// Its words refused (they are the asker's): the outcome is still said, before what may leave.
|
||||||
}
|
}
|
||||||
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
|
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
|
// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry.
|
||||||
// secret's shape once composed; else false, and the message under the whole content rule stands.
|
func (h *Holder) askLines(rec *AskRecord) []string {
|
||||||
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
|
|
||||||
c := *r
|
|
||||||
c.Explanation = whole
|
|
||||||
m := compose(&c, what, h.Now(), h.zone(), 0)
|
|
||||||
if refusal, ok := CheckSecret(m.Text()); !ok {
|
|
||||||
h.Logf("[messenger] the whole words of %s carry %s; shown as on a channel that proves nothing", r.Key, refusal)
|
|
||||||
return Message{}, false
|
|
||||||
}
|
|
||||||
return m, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// askLines are what an ask's message notes of the ask itself, in two lines: who asks and when, with what happens
|
|
||||||
// if nobody answers; then each option's effect.
|
|
||||||
func (h *Holder) askLines(rec *AskRecord, r *Record) []string {
|
|
||||||
asker := rec.Asker
|
asker := rec.Asker
|
||||||
if asker == SelfAsker {
|
if asker == SelfAsker {
|
||||||
asker = "the router itself"
|
asker = "the router itself"
|
||||||
}
|
}
|
||||||
first := "Asked by " + asker + " at " + when(r.Raised, h.Now(), h.zone()) + "."
|
lines := []string{"Asked by " + asker + "."}
|
||||||
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
|
|
||||||
first += " Unanswered by " + by(rec.Ask.Expires, h.Now(), h.zone()) + ": " + strings.TrimSuffix(rec.Ask.OnExpiry, ".") + "."
|
|
||||||
}
|
|
||||||
lines := []string{first}
|
|
||||||
var does []string
|
|
||||||
for _, o := range rec.Ask.Options {
|
for _, o := range rec.Ask.Options {
|
||||||
if o.Does != "" {
|
if o.Does != "" {
|
||||||
does = append(does, o.Label+": "+strings.TrimSuffix(o.Does, ".")+".")
|
lines = append(lines, o.Label+": "+o.Does+".")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(does) > 0 {
|
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
|
||||||
lines = append(lines, strings.Join(does, " "))
|
lines = append(lines, "If nobody answers by "+by(rec.Ask.Expires, h.Now(), h.zone())+": "+rec.Ask.OnExpiry+".")
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@@ -1146,20 +1092,3 @@ func roughly(d time.Duration) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%d days", int(d.Hours()/24))
|
return fmt.Sprintf("%d days", int(d.Hours()/24))
|
||||||
}
|
}
|
||||||
|
|
||||||
// withheldLine is a note line the rule refused, said withheld: a change's line ("+ key: value") keeps its key's name,
|
|
||||||
// so the operator knows which setting holds a value they are not shown (the review of mesh-controller #304); any
|
|
||||||
// other line, or one whose key the rule refuses too, is said withheld whole.
|
|
||||||
func withheldLine(line string, refusal Refusal, passes func(string) (Refusal, bool)) string {
|
|
||||||
if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' {
|
|
||||||
key := line[2:]
|
|
||||||
if i := strings.IndexAny(key, ": "); i > 0 {
|
|
||||||
key = key[:i]
|
|
||||||
}
|
|
||||||
named := line[:2] + key + " (a value carrying " + refusal.What + " is withheld here)"
|
|
||||||
if _, ok := passes(named); ok {
|
|
||||||
return named
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "(a line carrying " + refusal.What + " is withheld here)"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ func TestAnAsksExpiryIsSaidWithItsDay(t *testing.T) {
|
|||||||
rec := &AskRecord{Asker: ConditionOwner}
|
rec := &AskRecord{Asker: ConditionOwner}
|
||||||
rec.Ask.Expires = now.Add(7 * 24 * time.Hour)
|
rec.Ask.Expires = now.Add(7 * 24 * time.Hour)
|
||||||
rec.Ask.OnExpiry = "nothing is done"
|
rec.Ask.OnExpiry = "nothing is done"
|
||||||
lines := strings.Join(h.askLines(rec, &Record{Raised: now}), "\n")
|
lines := strings.Join(h.askLines(rec), "\n")
|
||||||
if !strings.Contains(lines, "Unanswered by Fri 16 Oct 21:33: nothing is done.") {
|
if !strings.Contains(lines, "If nobody answers by Fri 16 Oct 21:33: nothing is done.") {
|
||||||
t.Fatalf("%s", lines)
|
t.Fatalf("%s", lines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,12 +208,11 @@ func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing.
|
|||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||||
settle(h, c)
|
settle(h, c)
|
||||||
// In the note, after the body and the buttons (issue 639): the body is the asker's own words alone.
|
body := tg.sends[0].Body
|
||||||
body := tg.sends[0].Note
|
for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.",
|
||||||
for _, want := range []string{"Asked by mesh-delivery at ", "Release: the delivery goes on. Stop: the delivery ends.",
|
|
||||||
"Silence for a week: nothing more is said of it for a week.",
|
"Silence for a week: nothing more is said of it for a week.",
|
||||||
"Unanswered by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
|
"If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
|
||||||
if !strings.Contains(body, want) || strings.Contains(tg.sends[0].Body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("the message does not say %q:\n%s", want, body)
|
t.Errorf("the message does not say %q:\n%s", want, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
|
|||||||
}
|
}
|
||||||
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
|
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
|
||||||
// dropped here, where the desk's own role sends).
|
// dropped here, where the desk's own role sends).
|
||||||
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
|
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
|
||||||
Secret: m.Secret}
|
Secret: m.Secret}
|
||||||
if c.Bench.To != nil {
|
if c.Bench.To != nil {
|
||||||
out.To = c.Bench.To(kind)
|
out.To = c.Bench.To(kind)
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq issue 383: a settings proposal's values are what the operator approves, so where the sender is proven
|
|
||||||
// they are shown whole; where nothing is proven, the explanation under the whole content rule stands.
|
|
||||||
|
|
||||||
// proposalAsk is a proposal as the controller composes it: the explanation with its values masked, the whole
|
|
||||||
// with them whole, and Details with the fingerprint and where to read it, which the note shows.
|
|
||||||
func proposalAsk(c *clock, id string) asks.Ask {
|
|
||||||
a := heldAsk(c, id)
|
|
||||||
a.Headline = "Set mounts on shanks?"
|
|
||||||
a.Options = a.Options[:2]
|
|
||||||
a.Explanation = "+ shares: media=‹path›\n+ sources: recalbox=‹address›@‹path›\nProposed by an agent at 15:11."
|
|
||||||
a.Whole = "+ shares: media=/storage/media\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox\nProposed by an agent at 15:11."
|
|
||||||
a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\nRead it whole: settings proposals " + id + " at the controller's terminal."
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *testing.T) {
|
|
||||||
h, tg, _, c, store, w := newRouter(t)
|
|
||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
|
||||||
submit(t, h, "mesh-controller", proposalAsk(c, "s-1"))
|
|
||||||
settle(h, c)
|
|
||||||
if len(tg.sends) != 1 {
|
|
||||||
t.Fatalf("telegram got %d messages", len(tg.sends))
|
|
||||||
}
|
|
||||||
phone := tg.sends[0]
|
|
||||||
for _, want := range []string{"+ shares: media=/storage/media", "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "Proposed by an agent at 15:11."} {
|
|
||||||
if !strings.Contains(phone.Body, want) {
|
|
||||||
t.Errorf("the phone, which proves who answers, lacks %q:\n%s", want, phone.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(phone.Body, "‹") || strings.Contains(phone.Body, "Fingerprint") || strings.Contains(phone.Body, "settings proposals") {
|
|
||||||
t.Errorf("the phone shows a masked value, or the Details' words, in the body:\n%s", phone.Body)
|
|
||||||
}
|
|
||||||
// The Details are the note's, after what the answers do (issue 639): no answer of their own.
|
|
||||||
if !strings.Contains(phone.Note, "Fingerprint 1234 5678 9abc def0 1234 5678.") ||
|
|
||||||
!strings.Contains(phone.Note, "Read it whole: settings proposals s-1 at the controller's terminal.") {
|
|
||||||
t.Errorf("the phone's note lacks the Details:\n%s", phone.Note)
|
|
||||||
}
|
|
||||||
if phone.Title != "Set mounts on shanks?" || ticketFor(phone, "Release") == "" || ticketFor(phone, DetailsLabel) != "" {
|
|
||||||
t.Errorf("the phone's message: %q offering %s", phone.Title, labelsOf(phone))
|
|
||||||
}
|
|
||||||
// The desk proves nothing: the same record composed for it shows the explanation, its values masked.
|
|
||||||
r := h.open["ask.mesh-controller.s-1"]
|
|
||||||
if r == nil {
|
|
||||||
t.Fatal("no record of the ask")
|
|
||||||
}
|
|
||||||
desk := h.sayOn(r, "raised", "desktop")
|
|
||||||
if !strings.Contains(desk.Body, "+ shares: media=‹path›") || strings.Contains(desk.Text(), "/storage/media") || strings.Contains(desk.Text(), "nas.lan") {
|
|
||||||
t.Errorf("the desk shows a value whole:\n%s", desk.Text())
|
|
||||||
}
|
|
||||||
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
|
|
||||||
t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
|
|
||||||
}
|
|
||||||
// Approved, the message is edited to one line that says so, with the headline; the body is not said again
|
|
||||||
// (issue 639).
|
|
||||||
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
|
|
||||||
t.Fatalf("Release: %q %v", out, err)
|
|
||||||
}
|
|
||||||
edited, ok := tg.edits["1"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") ||
|
|
||||||
edited.Body != "" || len(edited.Actions) != 0 {
|
|
||||||
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mutation on the switch: the same ask without whole words is shown on the phone as on the desk, from its
|
|
||||||
// explanation; the whole words alone make the difference, and only where the sender is proven.
|
|
||||||
func TestAnAskWithoutWholeWordsIsShownFromItsExplanationEverywhere(t *testing.T) {
|
|
||||||
h, tg, _, c, store, _ := newRouter(t)
|
|
||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
|
||||||
a := proposalAsk(c, "s-2")
|
|
||||||
a.Whole, a.Details = "", ""
|
|
||||||
submit(t, h, "mesh-controller", a)
|
|
||||||
settle(h, c)
|
|
||||||
if len(tg.sends) != 1 || !strings.Contains(tg.sends[0].Body, "+ shares: media=‹path›") || strings.Contains(tg.sends[0].Body, "/storage/media") {
|
|
||||||
t.Fatalf("telegram: %+v", tg.sends)
|
|
||||||
}
|
|
||||||
if ticketFor(tg.sends[0], DetailsLabel) != "" || strings.Contains(tg.sends[0].Note, "Fingerprint") {
|
|
||||||
t.Errorf("an ask that says nothing for Details offers or notes it: %s\n%s", labelsOf(tg.sends[0]), tg.sends[0].Note)
|
|
||||||
}
|
|
||||||
// A kind that proves nothing never shows the whole words, whatever the ask says.
|
|
||||||
a = proposalAsk(c, "s-3")
|
|
||||||
submit(t, h, "mesh-controller", a)
|
|
||||||
settle(h, c)
|
|
||||||
if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "desktop"); whole != "" {
|
|
||||||
t.Errorf("the desk is given the whole words: %q", whole)
|
|
||||||
}
|
|
||||||
if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "telegram"); whole != a.Whole {
|
|
||||||
t.Errorf("the phone is not given the whole words: %q", whole)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A secret's shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole, so the
|
|
||||||
// phone never sees it, with or without the proven sender.
|
|
||||||
func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) {
|
|
||||||
// A path is read as a path, whatever its case and digits; a random run outside one is still a secret.
|
|
||||||
for text, may := range map[string]bool{
|
|
||||||
"+ shares: photos=/mnt/Photos_2024/Jochen": true,
|
|
||||||
"+ sources: games=smb://nas.lan/Recalbox_Games2024": true,
|
|
||||||
"+ library: /srv/media/Series_Archive/Season01": true,
|
|
||||||
"+ key: xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC": false,
|
|
||||||
} {
|
|
||||||
if _, ok := CheckSecret(text); ok != may {
|
|
||||||
t.Errorf("CheckSecret(%q) lets it leave: %v", text, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A condition's record never shows an ask's whole words, whichever kind: its words are its own.
|
|
||||||
{
|
|
||||||
h, _, _, c, store, _ := newRouter(t)
|
|
||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
|
||||||
k := cond("delivery.hq.held", Warning, "the delivery of hq is held")
|
|
||||||
k.Raised = c.now()
|
|
||||||
h.Condition(EventRaised, k)
|
|
||||||
settle(h, c)
|
|
||||||
a := proposalAsk(c, "s-5")
|
|
||||||
a.About = "delivery.hq.held"
|
|
||||||
submit(t, h, "mesh-controller", a)
|
|
||||||
settle(h, c)
|
|
||||||
if r := h.open["delivery.hq.held"]; r == nil || r.Ask != "mesh-controller.s-5" || h.wholeOf(r, "telegram") != "" {
|
|
||||||
t.Errorf("a condition's record is given an ask's whole words")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, secret := range []string{
|
|
||||||
"+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789",
|
|
||||||
"+ password=hunter2",
|
|
||||||
"+ key: 0123456789abcdef0123456789abcdef",
|
|
||||||
"-----BEGIN PRIVATE KEY-----",
|
|
||||||
} {
|
|
||||||
h, tg, _, c, store, w := newRouter(t)
|
|
||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
|
||||||
a := proposalAsk(c, "s-4")
|
|
||||||
a.Whole = "+ shares: media=/storage/media\n" + secret
|
|
||||||
submit(t, h, "mesh-controller", a)
|
|
||||||
settle(h, c)
|
|
||||||
got := w.of("mesh-controller")
|
|
||||||
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
|
|
||||||
t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got)
|
|
||||||
}
|
|
||||||
if _, kept := store.asks["mesh-controller.s-4"]; kept {
|
|
||||||
t.Errorf("%q: the refused ask was recorded", secret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The review of mesh-controller #304: a proposal of five keys, one of them 300 characters long, shows two keys at
|
|
||||||
// a glance; every key and every value, whole, is in the note on a kind that proves who answers, so the operator can
|
|
||||||
// read exactly what Approve sets. The desk, which proves nothing, shows them masked.
|
|
||||||
func TestEveryKeyAndItsWholeValueIsInTheNoteWhereTheSenderIsProven(t *testing.T) {
|
|
||||||
h, tg, _, c, store, _ := newRouter(t)
|
|
||||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
|
||||||
var long strings.Builder
|
|
||||||
long.WriteString("library=/storage")
|
|
||||||
for i := 0; long.Len() < 300; i++ {
|
|
||||||
long.WriteString(fmt.Sprintf("/album-%02d", i))
|
|
||||||
}
|
|
||||||
value := long.String()[:300]
|
|
||||||
keys := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value}
|
|
||||||
masked := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": "library=‹path›"}
|
|
||||||
lines := func(values map[string]string) string {
|
|
||||||
var out []string
|
|
||||||
for _, k := range []string{"a", "b", "c", "d", "shares"} {
|
|
||||||
out = append(out, "+ "+k+": "+values[k])
|
|
||||||
}
|
|
||||||
return strings.Join(out, "\n")
|
|
||||||
}
|
|
||||||
a := proposalAsk(c, "s-9")
|
|
||||||
a.Explanation = "Changes 5 setting(s) of mounts on shanks.\n+ a: 1\n+ b: 2\nand 3 more, each whole below: c, d, shares"
|
|
||||||
a.Whole = ""
|
|
||||||
a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(masked)
|
|
||||||
a.WholeDetails = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(keys)
|
|
||||||
submit(t, h, "mesh-controller", a)
|
|
||||||
settle(h, c)
|
|
||||||
if len(tg.sends) != 1 {
|
|
||||||
t.Fatalf("telegram got %d messages", len(tg.sends))
|
|
||||||
}
|
|
||||||
phone := tg.sends[0]
|
|
||||||
for k, v := range keys {
|
|
||||||
if !strings.Contains(phone.Note, "+ "+k+": "+v) {
|
|
||||||
t.Errorf("the phone's note lacks %s whole:\n%s", k, phone.Note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(phone.Body, "/storage") || strings.Count(phone.Body, "\n") > 3 {
|
|
||||||
t.Errorf("the body is not the glance:\n%s", phone.Body)
|
|
||||||
}
|
|
||||||
desk := h.sayOn(h.open["ask.mesh-controller.s-9"], "raised", "desktop")
|
|
||||||
if strings.Contains(desk.Text(), "/storage") || !strings.Contains(desk.Note, "+ shares: library=‹path›") {
|
|
||||||
t.Errorf("the desk shows a value whole, or not every key:\n%s", desk.Note)
|
|
||||||
}
|
|
||||||
// A secret's shape in the whole details refuses the ask as a whole.
|
|
||||||
b := proposalAsk(c, "s-10")
|
|
||||||
b.WholeDetails = "+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
submit(t, h, "mesh-controller", b)
|
|
||||||
settle(h, c)
|
|
||||||
if _, kept := store.asks["mesh-controller.s-10"]; kept || len(tg.sends) != 1 {
|
|
||||||
t.Errorf("an ask whose whole details carry a secret was taken")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A change's line the rule refuses keeps its key's name in the note (the review of mesh-controller #304): the
|
|
||||||
// operator knows which setting holds a value they are not shown. A line whose key the rule refuses too is said
|
|
||||||
// withheld whole.
|
|
||||||
func TestAWithheldChangeLineKeepsItsKey(t *testing.T) {
|
|
||||||
for line, want := range map[string]string{
|
|
||||||
"+ password: hunter2": "+ password (a value carrying a value given to a secret's name is withheld here)",
|
|
||||||
"~ shares: /mnt/media (was: x)": "~ shares (a value carrying a file path is withheld here)",
|
|
||||||
"Read it at /etc/x": "(a line carrying a file path is withheld here)",
|
|
||||||
"+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "(a line carrying ",
|
|
||||||
} {
|
|
||||||
refusal, ok := Check(line)
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("%q passes the content rule", line)
|
|
||||||
}
|
|
||||||
if got := withheldLine(line, refusal, Check); !strings.HasPrefix(got, want) {
|
|
||||||
t.Errorf("%q: %q, want %q", line, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -76,14 +76,7 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
|
|||||||
since += "."
|
since += "."
|
||||||
}
|
}
|
||||||
body := since
|
body := since
|
||||||
if r.Origin == "ask" && what != "reminder" && what != "quiet-reminder" {
|
if explanation != "" {
|
||||||
// An ask's own message (issue 639): its body is the asker's summary and points; when it was asked is in
|
|
||||||
// its note, with who asked.
|
|
||||||
body = ""
|
|
||||||
}
|
|
||||||
if explanation != "" && body == "" {
|
|
||||||
body = explanation
|
|
||||||
} else if explanation != "" {
|
|
||||||
body = explanation + "\n" + since
|
body = explanation + "\n" + since
|
||||||
}
|
}
|
||||||
return Message{Title: title, Body: body, Urgent: urgent}
|
return Message{Title: title, Body: body, Urgent: urgent}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module messenger
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||||
|
|||||||
@@ -1,8 +1,2 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
|
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
|
|||||||
@@ -97,13 +97,6 @@
|
|||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"health": {
|
|
||||||
"kind": "http",
|
|
||||||
"endpoint": "web",
|
|
||||||
"path": "/healthz/readiness",
|
|
||||||
"status": 200,
|
|
||||||
"needs": "postgres-database"
|
|
||||||
},
|
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"artifact": "server",
|
"artifact": "server",
|
||||||
"network": "n8n",
|
"network": "n8n",
|
||||||
|
|||||||
@@ -83,14 +83,6 @@
|
|||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"health": {
|
|
||||||
"kind": "http",
|
|
||||||
"endpoint": "web",
|
|
||||||
"path": "/status.php",
|
|
||||||
"status": 200,
|
|
||||||
"body": "\"installed\":true",
|
|
||||||
"needs": "postgres-database"
|
|
||||||
},
|
|
||||||
"name": "nextcloud",
|
"name": "nextcloud",
|
||||||
"image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08",
|
"image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ as the operator.
|
|||||||
| | fixed here |
|
| | fixed here |
|
||||||
|---|---|
|
|---|---|
|
||||||
| D1 | up to 4096 characters arrive whole; longer is cut and the cut said |
|
| D1 | up to 4096 characters arrive whole; longer is cut and the cut said |
|
||||||
| issues 383, 639 | shown formatted (HTML), never reworded: the title bold, the body as it is with a key line in monospace, the note apart in a blockquote collapsed to its first lines, kept whole when a message is too long (the body is cut first, and the cut said); `<`, `>` and `&` escaped first, so nothing in the words is read as markup. A tap is compared by the plain words, whitespace aside |
|
|
||||||
| D4 | a warning or a clearing arrives without sound; an urgent message or a question rings |
|
| D4 | a warning or a clearing arrives without sound; an urgent message or a question rings |
|
||||||
| D5 | a rate limit is waited out as told |
|
| D5 | a rate limit is waited out as told |
|
||||||
| D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success |
|
| D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success |
|
||||||
|
|||||||
@@ -249,10 +249,9 @@ func keyboard(buttons [][2]string) map[string]any {
|
|||||||
return map[string]any{"inline_keyboard": rows}
|
return map[string]any{"inline_keyboard": rows}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send posts a message, its text in ParseMode (rendered or escaped by the caller), and answers its id. silent:
|
// Send posts a message and answers its id. silent: it arrives without sound (D4).
|
||||||
// it arrives without sound (D4).
|
|
||||||
func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) {
|
func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) {
|
||||||
body := map[string]any{"chat_id": chat, "text": text, "parse_mode": ParseMode, "disable_notification": silent,
|
body := map[string]any{"chat_id": chat, "text": text, "disable_notification": silent,
|
||||||
"link_preview_options": map[string]any{"is_disabled": true}}
|
"link_preview_options": map[string]any{"is_disabled": true}}
|
||||||
if len(buttons) > 0 {
|
if len(buttons) > 0 {
|
||||||
body["reply_markup"] = keyboard(buttons)
|
body["reply_markup"] = keyboard(buttons)
|
||||||
@@ -262,10 +261,9 @@ func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (i
|
|||||||
return out.MessageID, err
|
return out.MessageID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit replaces a message's text (in ParseMode, rendered or escaped by the caller) and buttons; none given, the
|
// Edit replaces a message's text and buttons; none given, the buttons go.
|
||||||
// buttons go.
|
|
||||||
func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error {
|
func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error {
|
||||||
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, "parse_mode": ParseMode,
|
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text,
|
||||||
"reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil)
|
"reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil)
|
||||||
if notModified(err) {
|
if notModified(err) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ package main
|
|||||||
//
|
//
|
||||||
// - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each
|
// - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each
|
||||||
// carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's
|
// carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's
|
||||||
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). The words are shown
|
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a
|
||||||
// as the router gave them, formatted (render.go): the title bold, a key line in monospace, the note in a
|
|
||||||
// collapsed blockquote after the body, escaped so that none of them is read as markup (issues 383, 639). A warning or a
|
|
||||||
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
|
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
|
||||||
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
|
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
|
||||||
// text is a success (D6). A message that cannot be delivered after three tries is said as failed. A
|
// text is a success (D6). A message that cannot be delivered after three tries is said as failed. A
|
||||||
@@ -90,18 +88,10 @@ type Store interface {
|
|||||||
Show(chat, id int64, digest string) error
|
Show(chat, id int64, digest string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — how the
|
// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — its line
|
||||||
// whitespace between them runs aside, which Telegram may change around a blockquote (issue 639) — so a message
|
// endings and outer spaces aside — so a message whose words another holder of the token changed is told
|
||||||
// whose words another holder of the token changed is told from the one this channel showed (the review of
|
// from the one this channel showed (the review of 2026-10-09).
|
||||||
// 2026-10-09).
|
|
||||||
func wordsDigest(words string) string {
|
func wordsDigest(words string) string {
|
||||||
sum := sha256.Sum256([]byte(strings.Join(strings.Fields(words), " ")))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// linesDigest is the digest a message shown before issue 639 was kept under: its line endings and outer spaces
|
|
||||||
// aside. A tap on such a message is still compared by it.
|
|
||||||
func linesDigest(words string) string {
|
|
||||||
w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n"))
|
w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n"))
|
||||||
sum := sha256.Sum256([]byte(w))
|
sum := sha256.Sum256([]byte(w))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
@@ -165,6 +155,20 @@ func (c *Channel) init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// text is a message as the chat shows it, cut at MaxLength and the cut said (D1).
|
||||||
|
func text(m asks.Message) string {
|
||||||
|
t := m.Title
|
||||||
|
if m.Body != "" {
|
||||||
|
t += "\n" + m.Body
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(t) <= MaxLength {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
runes := []rune(t)
|
||||||
|
keep := MaxLength - utf8.RuneCountInString(cutNote)
|
||||||
|
return string(runes[:keep]) + cutNote
|
||||||
|
}
|
||||||
|
|
||||||
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
|
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
|
||||||
// bytes Telegram allows; one that does not is left out and said.
|
// bytes Telegram allows; one that does not is left out and said.
|
||||||
func (c *Channel) buttons(m asks.Message) [][2]string {
|
func (c *Channel) buttons(m asks.Message) [][2]string {
|
||||||
@@ -227,7 +231,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
|
|||||||
c.fail(m.Handle, err, true)
|
c.fail(m.Handle, err, true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
said, buttons := partsOf(m), c.buttons(m)
|
words, buttons := text(m), c.buttons(m)
|
||||||
silent := m.Silent || (!m.Urgent && len(buttons) == 0)
|
silent := m.Silent || (!m.Urgent && len(buttons) == 0)
|
||||||
// **A message said again under a handle already shown replaces it** (issue 373): a reminder or an
|
// **A message said again under a handle already shown replaces it** (issue 373): a reminder or an
|
||||||
// escalation is one message per problem, not one more beside it. An edit here notifies nobody, so this one
|
// escalation is one message per problem, not one more beside it. An edit here notifies nobody, so this one
|
||||||
@@ -249,15 +253,15 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
|
|||||||
var id int64
|
var id int64
|
||||||
switch {
|
switch {
|
||||||
case place != nil:
|
case place != nil:
|
||||||
err = c.Bot.Edit(place.Chat, place.ID, said.html(), buttons)
|
err = c.Bot.Edit(place.Chat, place.ID, words, buttons)
|
||||||
if err == nil || notModified(err) {
|
if err == nil || notModified(err) {
|
||||||
c.keepShown(place.Chat, place.ID, said.plain())
|
c.keepShown(place.Chat, place.ID, words)
|
||||||
}
|
}
|
||||||
if gone(err) {
|
if gone(err) {
|
||||||
place, err = nil, c.newMessage(m.Handle, chat, said, buttons, silent, &id)
|
place, err = nil, c.newMessage(m.Handle, chat, words, buttons, silent, &id)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
err = c.newMessage(m.Handle, chat, said, buttons, silent, &id)
|
err = c.newMessage(m.Handle, chat, words, buttons, silent, &id)
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if earlier != nil {
|
if earlier != nil {
|
||||||
@@ -293,15 +297,13 @@ func (c *Channel) replaced(p Placed) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Logf("[telegram] an earlier message could not be deleted (%v); its buttons are taken away instead", err)
|
c.Logf("[telegram] an earlier message could not be deleted (%v); its buttons are taken away instead", err)
|
||||||
if err := c.Bot.Edit(p.Chat, p.ID, escape("Replaced by a newer message below."), nil); err != nil && !gone(err) {
|
if err := c.Bot.Edit(p.Chat, p.ID, "Replaced by a newer message below.", nil); err != nil && !gone(err) {
|
||||||
c.Logf("[telegram] an earlier message could not be marked replaced either: %v", err)
|
c.Logf("[telegram] an earlier message could not be marked replaced either: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMessage sends words, rendered (render.go), and keeps where they are and what they say, plain.
|
func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error {
|
||||||
func (c *Channel) newMessage(handle string, chat int64, parts shown, buttons [][2]string, silent bool, id *int64) error {
|
got, err := c.Bot.Send(chat, words, buttons, silent)
|
||||||
words := parts.plain()
|
|
||||||
got, err := c.Bot.Send(chat, parts.html(), buttons, silent)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -351,7 +353,7 @@ func (c *Channel) Handle(u Update) {
|
|||||||
// Only on a message showing exactly what this channel put there: one whose words another holder of
|
// Only on a message showing exactly what this channel put there: one whose words another holder of
|
||||||
// the token changed, or one it never showed, answers nothing (the review of 2026-10-09).
|
// the token changed, or one it never showed, answers nothing (the review of 2026-10-09).
|
||||||
if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" ||
|
if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" ||
|
||||||
(want != wordsDigest(cb.Message.Text) && want != linesDigest(cb.Message.Text)) {
|
want != wordsDigest(cb.Message.Text) {
|
||||||
words := "That answer was not taken: this message does not show what the mesh put there. Nothing was done."
|
words := "That answer was not taken: this message does not show what the mesh put there. Nothing was done."
|
||||||
c.answerTap(cb.ID, words)
|
c.answerTap(cb.ID, words)
|
||||||
// Said on the phone alone, never in the chat: the tapper may be somebody the buttons reached, who is told
|
// Said on the phone alone, never in the chat: the tapper may be somebody the buttons reached, who is told
|
||||||
@@ -459,7 +461,7 @@ func (c *Channel) remember(id string, chat int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Channel) reply(chat int64, words string) {
|
func (c *Channel) reply(chat int64, words string) {
|
||||||
if _, err := c.Bot.Send(chat, escape(words), nil, true); err != nil {
|
if _, err := c.Bot.Send(chat, words, nil, true); err != nil {
|
||||||
c.Logf("[telegram] could not answer in the chat: %v", err)
|
c.Logf("[telegram] could not answer in the chat: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
|
|||||||
}
|
}
|
||||||
b := sent[0].Body
|
b := sent[0].Body
|
||||||
kb, _ := json.Marshal(b["reply_markup"])
|
kb, _ := json.Marshal(b["reply_markup"])
|
||||||
if b["chat_id"] != float64(42) || b["text"] != "<b>Delivery of hq held</b>\nNeeds you: release it." || b["disable_notification"] != false ||
|
if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false ||
|
||||||
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
|
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
|
||||||
t.Errorf("sent %v %s", b, kb)
|
t.Errorf("sent %v %s", b, kb)
|
||||||
}
|
}
|
||||||
@@ -230,83 +230,12 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
|
|||||||
if err := c.Take(subject, body); err != nil {
|
if err := c.Take(subject, body); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The bound is on the words, as Telegram's is on the text once its markup is read: the tags do not count.
|
got := f.of("sendMessage")[0].Body["text"].(string)
|
||||||
got := strings.NewReplacer("<b>", "", "</b>", "").Replace(f.of("sendMessage")[0].Body["text"].(string))
|
|
||||||
got = strings.ReplaceAll(got, ">", ">")
|
|
||||||
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
|
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
|
||||||
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
|
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A message is shown formatted, never reworded (issues 383 and 639): the title bold, a key line in monospace, the
|
|
||||||
// note apart in a collapsed blockquote, and every '<', '>' and '&' escaped, so a message carrying one neither breaks
|
|
||||||
// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back.
|
|
||||||
func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
|
|
||||||
c, f, s, _ := newChannel(t, goodToken)
|
|
||||||
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
|
|
||||||
Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)",
|
|
||||||
Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.",
|
|
||||||
To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}})
|
|
||||||
if err := c.Take(subject, body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sent := f.of("sendMessage")
|
|
||||||
want := "<b>Set mounts on <shanks> & co?</b>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
|
|
||||||
"<code>~ sources: a=b (was: c)</code>\n\n<blockquote expandable>Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.</blockquote>"
|
|
||||||
if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" {
|
|
||||||
t.Fatalf("sent %v", sent)
|
|
||||||
}
|
|
||||||
f.mu.Lock()
|
|
||||||
shown := f.next
|
|
||||||
f.mu.Unlock()
|
|
||||||
// Telegram gives the words back plain, the space around the blockquote as it pleases: the tap on them is taken.
|
|
||||||
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
|
||||||
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
|
|
||||||
Text: "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
|
||||||
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
|
||||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
||||||
t.Fatalf("the tap on the rendered message was not taken: %s", got)
|
|
||||||
}
|
|
||||||
// Words changed by another holder of the token are still told apart.
|
|
||||||
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
|
||||||
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
|
|
||||||
Text: "Set mounts on <shanks> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
|
||||||
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
|
||||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
||||||
t.Fatalf("a tap on changed words was taken: %s", got)
|
|
||||||
}
|
|
||||||
// The edit says what was chosen, one line with the headline, and is in the same mode.
|
|
||||||
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
|
|
||||||
Note: "Told to mesh-controller, which acts on it itself.", To: "42"})
|
|
||||||
if err := c.Take(subject, body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ed := f.of("editMessageText")
|
|
||||||
if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" ||
|
|
||||||
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?</b>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
|
|
||||||
t.Errorf("edited %v", ed)
|
|
||||||
}
|
|
||||||
// A plain answer in the chat is escaped too.
|
|
||||||
c.reply(42, "a <b>tag</b> & more")
|
|
||||||
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "a <b>tag</b> & more" {
|
|
||||||
t.Errorf("a reply was not escaped: %v", last[len(last)-1].Body["text"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A message shown before issue 639 was kept under the digest of its lines: a tap on it is still taken.
|
|
||||||
func TestATapOnAMessageShownBeforeIsStillTaken(t *testing.T) {
|
|
||||||
c, _, s, st := newChannel(t, goodToken)
|
|
||||||
words := "Delivery held\nRelease or Stop."
|
|
||||||
if err := st.Show(42, 9, linesDigest(words)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
|
||||||
Message: &Msg{MessageID: 9, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
|
|
||||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
||||||
t.Fatalf("the tap on a message shown before was not taken: %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
|
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
|
||||||
c, f, s, st := newChannel(t, goodToken)
|
c, f, s, st := newChannel(t, goodToken)
|
||||||
st.Place("m1", Placed{Chat: 42, ID: 7})
|
st.Place("m1", Placed{Chat: 42, ID: 7})
|
||||||
@@ -599,7 +528,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T
|
|||||||
if err := c.Take(subject, body); err != nil {
|
if err := c.Take(subject, body); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "<b>You chose Release: recorded.</b>" {
|
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." {
|
||||||
t.Errorf("the late word was not said in the chat: %v", sent)
|
t.Errorf("the late word was not said in the chat: %v", sent)
|
||||||
}
|
}
|
||||||
// Telegram refuses an acknowledgement that came too late: the word goes to the chat instead.
|
// Telegram refuses an acknowledgement that came too late: the word goes to the chat instead.
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// How a message looks in the chat (novox/hq issues 383 and 639, the operator's words: "no bold text, no
|
|
||||||
// formatting", "too much text, no formatting, no summary"). The router's words are shown as given —
|
|
||||||
// `exact-render` — and formatted, never reworded:
|
|
||||||
//
|
|
||||||
// - the title in bold: the headline, or how an answered ask ended;
|
|
||||||
// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value")
|
|
||||||
// in monospace;
|
|
||||||
// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line,
|
|
||||||
// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap.
|
|
||||||
//
|
|
||||||
// Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither
|
|
||||||
// breaks nor injects. What the operator taps on is compared by its plain words (wordsDigest): Telegram gives a
|
|
||||||
// message's text back without its formatting, so the digest is over the words before they are rendered.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseMode is the formatting the Bot API is told the text is in.
|
|
||||||
const ParseMode = "HTML"
|
|
||||||
|
|
||||||
var keyLine = regexp.MustCompile(`^[+~=-] `)
|
|
||||||
|
|
||||||
// escape is a text with the characters Telegram's HTML reads as markup written as entities.
|
|
||||||
func escape(s string) string {
|
|
||||||
return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// shown is a message's three parts as the chat carries them.
|
|
||||||
type shown struct{ title, body, note string }
|
|
||||||
|
|
||||||
// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1). The body is cut
|
|
||||||
// first: the note carries what each answer does and every value an approval sets, so it is kept whole as long as
|
|
||||||
// the title and the note fit; only then is the note cut, and that said too. The bound is on the words, as
|
|
||||||
// Telegram's is on the text once its markup is read.
|
|
||||||
func partsOf(m asks.Message) shown {
|
|
||||||
s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)}
|
|
||||||
over := utf8.RuneCountInString(s.plain()) - MaxLength
|
|
||||||
if over <= 0 {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
mark := utf8.RuneCountInString(cutNote)
|
|
||||||
if r := []rune(s.body); len(r) >= over+mark {
|
|
||||||
s.body = string(r[:len(r)-over-mark]) + cutNote
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
// The body cannot make room alone: what is left of it is the cut's word, and the note is cut too.
|
|
||||||
s.body = strings.TrimPrefix(cutNote, "\n")
|
|
||||||
if over = utf8.RuneCountInString(s.plain()) - MaxLength; over > 0 {
|
|
||||||
r := []rune(s.note)
|
|
||||||
s.note = string(r[:max(len(r)-over-mark, 0)]) + cutNote
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// plain is the words as Telegram gives them back: the title, the body on the lines after it, the note after a
|
|
||||||
// blank line.
|
|
||||||
func (s shown) plain() string {
|
|
||||||
t := s.title
|
|
||||||
if s.body != "" {
|
|
||||||
t += "\n" + s.body
|
|
||||||
}
|
|
||||||
if s.note != "" {
|
|
||||||
t += "\n\n" + s.note
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// html is the same words as the chat shows them.
|
|
||||||
func (s shown) html() string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("<b>" + escape(s.title) + "</b>")
|
|
||||||
if s.body != "" {
|
|
||||||
for _, line := range strings.Split(s.body, "\n") {
|
|
||||||
b.WriteString("\n")
|
|
||||||
if keyLine.MatchString(line) {
|
|
||||||
b.WriteString("<code>" + escape(line) + "</code>")
|
|
||||||
} else {
|
|
||||||
b.WriteString(escape(line))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.note != "" {
|
|
||||||
b.WriteString("\n\n<blockquote expandable>" + escape(s.note) + "</blockquote>")
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// adr0343 is the ask for ADR 0343 as the router now sends it to this channel (novox/hq issue 639): the knowledge
|
|
||||||
// base's brief as its title and body, the rest in its note. On 2026-10-13 the same ask arrived as about thirty plain
|
|
||||||
// lines — every one of its thirteen clauses on a line of its own — and the operator would not read it.
|
|
||||||
var adr0343 = asks.Message{Handle: "h-343", To: "42",
|
|
||||||
Title: "Your agents' memory follows you to every machine",
|
|
||||||
Body: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
|
|
||||||
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
|
|
||||||
"are screened, though not perfectly.\n" +
|
|
||||||
"• Only memory folders travel: links and files over 128 KiB stay where they are\n" +
|
|
||||||
"• A conflict reaches you as a warning that needs you\n" +
|
|
||||||
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
|
|
||||||
Note: "Asked by mesh-knowledge at 16:05. Unanswered by tomorrow 15:55: the record stays proposed; nothing of it is in force.\n" +
|
|
||||||
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" +
|
|
||||||
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
|
|
||||||
"Read its 13 clauses with the knowledge base's get, number 343.",
|
|
||||||
Buttons: []asks.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}}
|
|
||||||
|
|
||||||
// The ADR 0343 ask fits a phone's glance: a bold headline, the summary and its points, then the rest apart and
|
|
||||||
// collapsed — at most twelve lines in all, and never a clause.
|
|
||||||
func TestTheADR0343AskFitsAGlance(t *testing.T) {
|
|
||||||
c, f, _, _ := newChannel(t, goodToken)
|
|
||||||
subject, body := work(asks.Show, adr0343)
|
|
||||||
if err := c.Take(subject, body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sent := f.of("sendMessage")
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("sent %d", len(sent))
|
|
||||||
}
|
|
||||||
html := sent[0].Body["text"].(string)
|
|
||||||
t.Logf("as sent:\n%s", html)
|
|
||||||
lines := strings.Split(html, "\n")
|
|
||||||
if len(lines) > 12 {
|
|
||||||
t.Errorf("the ask is %d lines, more than 12:\n%s", len(lines), html)
|
|
||||||
}
|
|
||||||
if lines[0] != "<b>Your agents' memory follows you to every machine</b>" && lines[0] != "<b>Your agents' memory follows you to every machine</b>" {
|
|
||||||
t.Errorf("the headline is not first and bold: %q", lines[0])
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(lines[1], "What an agent learns") || strings.Count(html, "\n• ") != 3 {
|
|
||||||
t.Errorf("the summary and its three points do not follow the headline:\n%s", html)
|
|
||||||
}
|
|
||||||
if !strings.Contains(html, "\n\n<blockquote expandable>Asked by mesh-knowledge at 16:05.") || !strings.HasSuffix(html, "number 343.</blockquote>") {
|
|
||||||
t.Errorf("the note is not apart and collapsed:\n%s", html)
|
|
||||||
}
|
|
||||||
for _, lead := range []string{"Only the memory folders are carried", "How it is checked", "13 clause(s)", "**"} {
|
|
||||||
if strings.Contains(html, lead) {
|
|
||||||
t.Errorf("the ask carries %q", lead)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sent[0].Body["parse_mode"] != "HTML" {
|
|
||||||
t.Errorf("not sent as HTML: %v", sent[0].Body["parse_mode"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The note is kept whole when the message is too long: the body is cut first and the cut said; only a note that
|
|
||||||
// cannot fit beside the title is cut, and that said too (the review of mesh-catalog #278).
|
|
||||||
func TestALongBodyIsCutBeforeTheNote(t *testing.T) {
|
|
||||||
m := asks.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."}
|
|
||||||
s := partsOf(m)
|
|
||||||
if s.note != m.Note || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength {
|
|
||||||
t.Errorf("note %q, body ends %q, %d characters", s.note, s.body[len(s.body)-20:], len([]rune(s.plain())))
|
|
||||||
}
|
|
||||||
m.Body, m.Note = "the summary", strings.Repeat("n", 5000)
|
|
||||||
s = partsOf(m)
|
|
||||||
if !strings.HasSuffix(s.note, cutNote) || !strings.Contains(s.body, "cut here") || len([]rune(s.plain())) != MaxLength {
|
|
||||||
t.Errorf("body %q, note ends %q, %d characters", s.body, s.note[len(s.note)-20:], len([]rune(s.plain())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A '<', '>' or '&' in the note is escaped like any other part: it neither breaks the blockquote nor injects.
|
|
||||||
func TestTheNoteIsEscaped(t *testing.T) {
|
|
||||||
got := partsOf(asks.Message{Title: "t", Note: "Asked by <b>x</b> & y </blockquote><i>"}).html()
|
|
||||||
want := "<b>t</b>\n\n<blockquote expandable>Asked by <b>x</b> & y </blockquote><i></blockquote>"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("%q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,4 +2,4 @@ module telegram
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||||
|
|||||||
@@ -1,8 +1,2 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
|
|||||||
@@ -89,15 +89,6 @@
|
|||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"health": {
|
|
||||||
"kind": "http",
|
|
||||||
"endpoint": "web",
|
|
||||||
"path": "/api/share/mesh-health",
|
|
||||||
"status": 404,
|
|
||||||
"body": "\"code\":\"not-found\"",
|
|
||||||
"timeout": "10s",
|
|
||||||
"needs": "postgres-database"
|
|
||||||
},
|
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"image": "ghcr.io/umami-software/umami@sha256:85909afc45bdcda1917394594a087421fdbb05610fded0fa9f6fb861abb2f367",
|
"image": "ghcr.io/umami-software/umami@sha256:85909afc45bdcda1917394594a087421fdbb05610fded0fa9f6fb861abb2f367",
|
||||||
"network": "umami",
|
"network": "umami",
|
||||||
|
|||||||
Reference in New Issue
Block a user