Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2130f99612 | ||
|
|
ed5690cee4 | ||
|
|
fadf5025cc | ||
|
|
baa5ef802e | ||
|
|
5b20e419ec | ||
|
|
7e3e62062b | ||
|
|
7e0001e304 | ||
|
|
482232e8c5 | ||
|
|
70c1110c53 | ||
|
|
e223262408 | ||
|
|
dbe2949f84 | ||
|
|
b7d9590fdc | ||
|
|
7ee2869d60 | ||
|
|
0b74c04aa8 | ||
|
|
e1c8965ddb | ||
|
|
47ef2644ac | ||
|
|
cb7bf94a77 | ||
|
|
8e821ef449 | ||
|
|
07b9bd0ed4 | ||
|
|
02427a60ef | ||
|
|
8661bcfa22 | ||
|
|
6049537655 | ||
|
|
bf3e801f67 | ||
|
|
50861c74f8 | ||
|
|
b15846dd6e | ||
|
|
a44d490d97 | ||
|
|
af5cf53a34 | ||
|
|
2bb5d344f6 | ||
|
|
e846c289c8 | ||
|
|
0fcc560fec | ||
|
|
acf8a76646 | ||
|
|
b62bb15839 | ||
|
|
ff9d3ee6a0 | ||
|
|
c8255eb15d | ||
|
|
4ded07bc63 | ||
|
|
054dc9618c | ||
|
|
ebf7f3773d | ||
|
|
c876ad4a83 | ||
|
|
12a5e7be73 | ||
|
|
23a2e1e204 | ||
|
|
66d22dd53e | ||
|
|
7ed119995f | ||
|
|
faebc3569a | ||
|
|
1faee6e279 | ||
|
|
6f7d06726c | ||
|
|
f09daabec3 | ||
|
|
bb5a342863 | ||
|
|
eadf301f9f | ||
|
|
9c166398f2 | ||
|
|
e209d46faf | ||
|
|
f1f866050e | ||
|
|
4d15a155e6 | ||
|
|
e46eeb8298 | ||
|
|
5f491e0c09 | ||
|
|
73404a3c3f | ||
|
|
305c2ba247 | ||
|
|
8ad121d5c3 | ||
|
|
305601d25e | ||
|
|
910bc58fe0 | ||
|
|
0096beeff9 | ||
|
|
6aa30eae66 | ||
|
|
bb60de6d61 | ||
|
|
a84d0094a0 | ||
|
|
dd39d4ace7 | ||
|
|
5bd40fa406 | ||
|
|
0d723484a8 | ||
|
|
8d8dcd5ebb | ||
|
|
cd23928320 | ||
|
|
8922c89059 | ||
|
|
789d2d9d64 | ||
|
|
1725ffc799 | ||
|
|
a72eb3dbb2 | ||
|
|
c0726ac264 | ||
|
|
1906081937 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
66
|
||||
62
|
||||
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
|
||||
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,6 +13,11 @@ 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
|
||||
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 (`npm-package-registry`, provided by the gitea module) is an optional requirement.
|
||||
|
||||
@@ -115,3 +115,57 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
|
||||
}
|
||||
t.Fatal("the build agent has no environment file")
|
||||
}
|
||||
|
||||
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
|
||||
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
|
||||
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
|
||||
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
|
||||
raw, err := os.ReadFile("module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Data struct {
|
||||
Own []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Class string `json:"class"`
|
||||
} `json:"own"`
|
||||
} `json:"data"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Volumes []string `json:"volumes"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, data, env, mounted := false, false, false, false
|
||||
for _, d := range m.Data.Own {
|
||||
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
|
||||
data = true
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
switch {
|
||||
case r.ID == "judge-cache" && r.Type == "directory":
|
||||
dir = true
|
||||
case r.ID == "agent-env":
|
||||
env = strings.Contains(r.Content, "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
|
||||
case r.ID == "server":
|
||||
for _, v := range r.Volumes {
|
||||
if v == "${dir:judge-cache}:${dir:judge-cache}" {
|
||||
mounted = true
|
||||
}
|
||||
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
|
||||
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dir || !data || !env || !mounted {
|
||||
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
"path": "${dir:workspace}",
|
||||
"class": "cache",
|
||||
"why": "a build's working copy, cloned again for every build"
|
||||
},
|
||||
{
|
||||
"id": "judge-cache",
|
||||
"path": "${dir:judge-cache}",
|
||||
"class": "cache",
|
||||
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -54,12 +60,17 @@
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "judge-cache",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "agent-env",
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/build-agent.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
|
||||
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\nMESH_JUDGE_CACHE=${dir:judge-cache}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
@@ -72,6 +83,7 @@
|
||||
"volumes": [
|
||||
"${dir:mesh-state}:/run/mesh:ro",
|
||||
"${dir:workspace}:${dir:workspace}",
|
||||
"${dir:judge-cache}:${dir:judge-cache}",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"restart-on": [
|
||||
@@ -85,6 +97,9 @@
|
||||
"timeout": "5s",
|
||||
"looks": 3,
|
||||
"grace": "60s"
|
||||
},
|
||||
"busy": {
|
||||
"command": "/usr/local/bin/mesh-builder busy"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -11,8 +11,9 @@ 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
|
||||
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
|
||||
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). Unassigned, the module leaves the directory: the host
|
||||
in it (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
|
||||
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
|
||||
removes a directory only when it is empty.
|
||||
|
||||
And, for where the agent runs as an account of its own (hq ADR 0266):
|
||||
@@ -69,8 +70,37 @@ 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 |
|
||||
| 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 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) |
|
||||
|
||||
## 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 agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
||||
@@ -149,9 +179,12 @@ 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
|
||||
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
|
||||
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may
|
||||
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with
|
||||
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it.
|
||||
proves who answered (Telegram) carries either. The ask reads at a glance (hq issue 639): its explanation is one
|
||||
line saying what the proposal does — the proposer's `summary`, at most 300 characters, or the module's own words —
|
||||
and its Details, which the phone shows collapsed after the answers, carry the text (whole when it is short and every
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -285,6 +285,25 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625).
|
||||
func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
|
||||
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
|
||||
if !strings.Contains(done, want) {
|
||||
t.Fatalf("the line %s lacks %s", done, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(done, "at-7") {
|
||||
t.Fatal("the line carries the token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
||||
|
||||
@@ -152,6 +152,36 @@ func (s stateOf) Create(key string, value any) error {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
e, err := s.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
@@ -354,6 +384,9 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
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.",
|
||||
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)...)
|
||||
judge := NewJudge(p)
|
||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||
@@ -430,6 +463,12 @@ func main() {
|
||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||
Now: time.Now, Write: writeManaged}
|
||||
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)
|
||||
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||
say("%v", err)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,968 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,9 @@ type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
// Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625).
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||
@@ -433,6 +436,15 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The line says which binding this was and why the manager moved it, so a node's journal alone answers
|
||||
// what changed here and who asked (novox/hq issue 625).
|
||||
out["generation"] = b.Generation
|
||||
if b.Why != "" {
|
||||
out["why"] = b.Why
|
||||
}
|
||||
if b.By != "" {
|
||||
out["by"] = b.By
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
||||
text, _ = a["content"].(string)
|
||||
}
|
||||
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
||||
Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now())
|
||||
Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now())
|
||||
}},
|
||||
{Name: "claude_code_proposals",
|
||||
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.",
|
||||
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?'"),
|
||||
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
|
||||
"context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"),
|
||||
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"label": str("the button's words, at most 24 characters"),
|
||||
"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"},
|
||||
}, "required": []string{"label", "means"}}},
|
||||
"why": str("why the recommended option is the one you recommend"),
|
||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
||||
"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"),
|
||||
"expires_minutes": map[string]any{"type": "integer",
|
||||
|
||||
@@ -104,6 +104,12 @@ type Proposal struct {
|
||||
Digest string `json:"digest"`
|
||||
From string `json:"from"`
|
||||
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 string `json:"asked,omitempty"`
|
||||
}
|
||||
@@ -164,9 +170,109 @@ func (p Proposal) Act(option string) asks.Act {
|
||||
"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
|
||||
// router's warrant to it.
|
||||
// router's warrant to it — in the form it was asked in.
|
||||
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]
|
||||
shown, whole := excerpt(p.Text)
|
||||
part := ""
|
||||
@@ -617,6 +723,8 @@ type ProposeInput struct {
|
||||
Name string
|
||||
Text string
|
||||
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.
|
||||
@@ -696,7 +804,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
return nil, err
|
||||
}
|
||||
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
||||
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
|
||||
if problem := SummaryProblem(in.Summary); problem != "" {
|
||||
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 {
|
||||
case in.Remove:
|
||||
if !isActive {
|
||||
|
||||
@@ -147,14 +147,17 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
||||
if err := a.Check(t0); err != nil {
|
||||
t.Fatalf("the router would refuse the ask: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") {
|
||||
t.Errorf("the phone does not say what is asked first: %q", a.Explanation)
|
||||
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
|
||||
// router notes collapsed after the answers.
|
||||
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'?" {
|
||||
t.Errorf("headline: %q", a.Headline)
|
||||
}
|
||||
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) {
|
||||
t.Errorf("a short text is shown whole: %q", a.Explanation)
|
||||
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
|
||||
t.Errorf("a short text is not shown whole: %q", a.Details)
|
||||
}
|
||||
if len(a.Options) != 2 {
|
||||
t.Fatalf("options: %+v", a.Options)
|
||||
@@ -294,7 +297,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
||||
t.Fatal(err)
|
||||
}
|
||||
rid := out["proposed"].(string)
|
||||
if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") {
|
||||
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
|
||||
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
||||
}
|
||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
||||
@@ -326,7 +329,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := out["proposed"].(string)
|
||||
if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") {
|
||||
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
|
||||
t.Errorf("%q", r.published[id].Explanation)
|
||||
}
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
@@ -417,15 +420,16 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
||||
}
|
||||
id := out["proposed"].(string)
|
||||
a := r.published[id]
|
||||
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") {
|
||||
t.Errorf("a line with a path was shown: %q", a.Explanation)
|
||||
if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[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, or the cut not said: %q", a.Details)
|
||||
}
|
||||
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||
!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.Explanation)
|
||||
if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
|
||||
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
|
||||
}
|
||||
if len(a.Explanation) > 3000 {
|
||||
t.Errorf("the explanation is %d bytes", len(a.Explanation))
|
||||
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
|
||||
}
|
||||
list, err := ListProposals(p, store, r, id, t0)
|
||||
if err != nil {
|
||||
@@ -739,3 +743,33 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
||||
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.
|
||||
const (
|
||||
FewestQuestionOptions = 2
|
||||
MostContextBytes = 1500
|
||||
MostContextBytes = asks.SummaryLength
|
||||
MostMeansBytes = 200
|
||||
MostWhyBytes = 300
|
||||
MostWhyBytes = 120
|
||||
MostOnExpiryBytes = 200
|
||||
)
|
||||
|
||||
@@ -131,6 +131,8 @@ type Question struct {
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
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 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
|
||||
@@ -160,8 +162,41 @@ func (q Question) Act(option string) asks.Act {
|
||||
return act
|
||||
}
|
||||
|
||||
// Ask is the ask for a question, composed from it alone.
|
||||
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
|
||||
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
|
||||
b.WriteString(strings.TrimSpace(q.Context))
|
||||
if rec, ok := q.recommended(); ok {
|
||||
@@ -199,8 +234,10 @@ func QuestionProblem(q Question) string {
|
||||
switch {
|
||||
case strings.TrimSpace(q.Context) == "":
|
||||
add("it gives no context: say in plain words what the operator decides and why it matters")
|
||||
case len(q.Context) > MostContextBytes:
|
||||
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
|
||||
case len([]rune(q.Context)) > MostContextBytes:
|
||||
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(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 {
|
||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
||||
@@ -322,7 +359,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),
|
||||
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
||||
Expires: now.UTC().Add(life)}
|
||||
Expires: now.UTC().Add(life), Form: AskGlance}
|
||||
for i, o := range in.Options {
|
||||
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})
|
||||
|
||||
@@ -68,10 +68,13 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.",
|
||||
"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||
if !strings.Contains(a.Explanation, want) {
|
||||
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation)
|
||||
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
|
||||
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the explanation does not read at a glance:\n%s", 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 {
|
||||
|
||||
@@ -2,6 +2,6 @@ module claude-code
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
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.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
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=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"holdings",
|
||||
"config",
|
||||
"proposals",
|
||||
"questions"
|
||||
"questions",
|
||||
"memory"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
@@ -32,6 +33,7 @@
|
||||
"claude_code_grant",
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_registrations",
|
||||
"claude_code_memory",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister",
|
||||
|
||||
@@ -253,6 +253,7 @@ func text(a map[string]any, k string) (string, error) {
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
|
||||
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
@@ -260,7 +261,8 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run(context.Background(), m, a)
|
||||
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
|
||||
return run(ctx, m, a)
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -288,7 +290,7 @@ func tools() []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Bind(ctx, c, l, "bind")
|
||||
return m.Bind(ctx, c, l)
|
||||
}),
|
||||
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
|
||||
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
|
||||
@@ -297,7 +299,7 @@ func tools() []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Bind(ctx, c, l, "switch")
|
||||
return m.Bind(ctx, c, l)
|
||||
}),
|
||||
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
|
||||
map[string]any{"consumer": consumer},
|
||||
@@ -306,7 +308,7 @@ func tools() []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Release(ctx, c, "release")
|
||||
return m.Release(ctx, c)
|
||||
}),
|
||||
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
|
||||
map[string]any{"licence": str("a licence; absent for every due one")},
|
||||
|
||||
@@ -59,6 +59,54 @@ type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
|
||||
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
||||
type Cause struct {
|
||||
Why string
|
||||
By string
|
||||
}
|
||||
|
||||
// Itself names this module where it moved a binding by a rule of its own, with no caller.
|
||||
const Itself = "claude-licence-manager"
|
||||
|
||||
type causeKey struct{}
|
||||
|
||||
// WithCause is ctx carrying why what runs in it changes a binding, and who asked.
|
||||
func WithCause(ctx context.Context, c Cause) context.Context {
|
||||
return context.WithValue(ctx, causeKey{}, c)
|
||||
}
|
||||
|
||||
// CauseOf is the cause ctx carries, or "a rule of this module's own" when none: never empty, so a record always
|
||||
// says something, and a change nobody named a cause for reads as this module's own.
|
||||
func CauseOf(ctx context.Context) Cause {
|
||||
if c, ok := ctx.Value(causeKey{}).(Cause); ok && c.By != "" {
|
||||
return c
|
||||
}
|
||||
return Cause{Why: "a rule of this module's own", By: Itself}
|
||||
}
|
||||
|
||||
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
|
||||
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
|
||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
|
||||
_ = m.Store.Audit(ctx, what, detail)
|
||||
m.Log("%s", line)
|
||||
_ = m.Emit("licence."+what, detail)
|
||||
}
|
||||
|
||||
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
|
||||
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
|
||||
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
|
||||
if from != "" {
|
||||
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
|
||||
}
|
||||
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
|
||||
"why": c.Why, "by": c.By})
|
||||
}
|
||||
|
||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
||||
@@ -267,11 +315,12 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
|
||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
|
||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -351,21 +400,21 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
|
||||
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
||||
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
||||
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
||||
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
||||
ctx = WithCause(ctx, cause)
|
||||
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
||||
return "", err
|
||||
} else if b == nil || b.Licence != l.Name {
|
||||
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
|
||||
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
from := ""
|
||||
if b != nil {
|
||||
from = b.Licence
|
||||
}
|
||||
_ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil],
|
||||
map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"})
|
||||
if b != nil {
|
||||
m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name)
|
||||
}
|
||||
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
|
||||
}
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||
@@ -374,8 +423,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
|
||||
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
|
||||
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
|
||||
}
|
||||
}
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
@@ -399,14 +449,17 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
|
||||
l.RotatedAt = m.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
|
||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
||||
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
|
||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
||||
bindings, err := m.Store.Advance(ctx, l.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := CauseOf(ctx)
|
||||
for _, b := range bindings {
|
||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -482,7 +535,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
|
||||
c := CauseOf(ctx)
|
||||
if force {
|
||||
c.Why = "a refresh asked for: " + c.Why
|
||||
} else {
|
||||
c = Cause{Why: "due: older than its cadence or near its expiry", By: Itself}
|
||||
if by := CauseOf(ctx); by.By != Itself {
|
||||
c.By = by.By
|
||||
}
|
||||
}
|
||||
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
||||
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
||||
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
|
||||
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
|
||||
if err := m.publishAdvance(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -594,8 +659,9 @@ func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
|
||||
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
|
||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
||||
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
||||
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
||||
l, err := m.Store.Licence(ctx, licence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -611,19 +677,21 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
from, what := "", "bound"
|
||||
from := ""
|
||||
if before != nil {
|
||||
from, what = before.Licence, "switched"
|
||||
from = before.Licence
|
||||
}
|
||||
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
|
||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
c := CauseOf(ctx)
|
||||
m.moved(ctx, consumer, from, licence, b.Generation, c)
|
||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||
}
|
||||
|
||||
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
|
||||
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
|
||||
func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, error) {
|
||||
was, err := m.Store.Binding(ctx, consumer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -634,7 +702,9 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]
|
||||
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
|
||||
c := CauseOf(ctx)
|
||||
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
|
||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||
}
|
||||
|
||||
@@ -685,7 +755,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from})
|
||||
c := CauseOf(ctx)
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
|
||||
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
|
||||
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -132,8 +132,13 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
|
||||
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
||||
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
||||
}
|
||||
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
|
||||
t.Fatalf("events %v", mm.events)
|
||||
// The adoption is said, and so is each binding it made (novox/hq issue 625).
|
||||
events := strings.Join(mm.events, "|")
|
||||
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
|
||||
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
|
||||
if !strings.Contains(events, want) {
|
||||
t.Fatalf("events lack %s: %v", want, mm.events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,13 +239,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
||||
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
||||
}
|
||||
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
|
||||
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := mm.state["laptop"]; ok {
|
||||
@@ -454,3 +459,73 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
||||
t.Fatal("nothing was kept")
|
||||
}
|
||||
}
|
||||
|
||||
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
||||
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
|
||||
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
var lines []string
|
||||
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
||||
t.Fatalf("a login's binding said %+v", s)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "key")
|
||||
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||
t.Fatalf("the node is told %+v", s)
|
||||
}
|
||||
said := strings.Join(lines, "\n")
|
||||
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
|
||||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
||||
t.Fatalf("the journal said:\n%s", said)
|
||||
}
|
||||
var switched string
|
||||
for _, e := range mm.events {
|
||||
if strings.HasPrefix(e, "licence.switched ") {
|
||||
switched = e
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
||||
if !strings.Contains(switched, want) {
|
||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
||||
}
|
||||
}
|
||||
last := mm.store.Audits[len(mm.store.Audits)-1]
|
||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
||||
t.Fatalf("the audit kept %s", raw)
|
||||
}
|
||||
|
||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
||||
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
|
||||
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
||||
}
|
||||
|
||||
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
||||
t.Fatalf("the journal said:\n%s", said)
|
||||
}
|
||||
for _, e := range mm.events {
|
||||
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
||||
t.Fatalf("an event carries a secret: %s", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A call with no cause is said as this module's own, never as nobody.
|
||||
func TestNoCauseReadsAsTheModulesOwn(t *testing.T) {
|
||||
if c := CauseOf(context.Background()); c.By != Itself || c.Why == "" {
|
||||
t.Fatalf("no cause read as %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module claude-licence-manager
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
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.20261012082113-9747f3e1b1ac h1:jnZ/n+fFMo7VQPNNXK1gjStHIJPw3SPbzPxsf/PpZqw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "claude_licences"
|
||||
"name": "claude_licences",
|
||||
"readable": [
|
||||
"audit",
|
||||
"binding"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -59,6 +63,10 @@
|
||||
"emits": [
|
||||
"licence.adopted",
|
||||
"licence.refused",
|
||||
"licence.bound",
|
||||
"licence.switched",
|
||||
"licence.released",
|
||||
"licence.rotated",
|
||||
"licence.failing",
|
||||
"usage.read"
|
||||
],
|
||||
|
||||
@@ -233,7 +233,13 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
||||
case m.Silent:
|
||||
urgency = "low"
|
||||
}
|
||||
args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "urgency": urgency, "app_name": "mesh"}
|
||||
// The note — who asked, what each answer does, the expiry — follows the body after a blank line: a
|
||||
// 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 {
|
||||
args["replace_id"] = replace
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@ 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) {
|
||||
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"}}})
|
||||
|
||||
@@ -2,4 +2,4 @@ module deskchannel
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
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.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=
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# dev-tools
|
||||
|
||||
The tools a machine needs to develop the mesh's own code (novox/hq issue 626). Every node is a development
|
||||
machine: an agent working on a change builds it and runs its tests there, with these tools, before it opens a
|
||||
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
|
||||
scope, and is not where development happens.
|
||||
|
||||
Assign it to every node.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | package | class (ADR 0182) |
|
||||
|---|---|---|
|
||||
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
|
||||
| a C compiler, for `go test -race` and cgo | `gcc` | package |
|
||||
| make | `make` | package |
|
||||
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
|
||||
| Python, for the scripts some checks run | `python` | package |
|
||||
| git and jq | `git`, `jq` | package |
|
||||
|
||||
All are in the distribution's official repositories, so the node-engine's package manager installs them on any
|
||||
node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a
|
||||
pull request, and a local run is the developer's early answer, not the verdict.
|
||||
|
||||
Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock
|
||||
file (`go.mod`, `package-lock.json`), as in the toolchain images.
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"module": "dev-tools",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "c-compiler",
|
||||
"type": "package",
|
||||
"package": "gcc"
|
||||
},
|
||||
{
|
||||
"id": "make",
|
||||
"type": "package",
|
||||
"package": "make"
|
||||
},
|
||||
{
|
||||
"id": "nodejs",
|
||||
"type": "package",
|
||||
"package": "nodejs"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"type": "package",
|
||||
"package": "python"
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"type": "package",
|
||||
"package": "git"
|
||||
},
|
||||
{
|
||||
"id": "jq",
|
||||
"type": "package",
|
||||
"package": "jq"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,13 @@ 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`,
|
||||
`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)
|
||||
|
||||
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
|
||||
|
||||
+125
-5
@@ -101,6 +101,92 @@ export interface OpenPull {
|
||||
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
|
||||
* that started and never answered — pending — from one that answered. */
|
||||
checks: Record<string, HeadCheck>;
|
||||
/** The pull request's labels, by name. */
|
||||
labels?: string[];
|
||||
/** Its ready mark (novox/hq issue 722): the label `mesh/ready` and the newest comment "ready at <full head sha>", the
|
||||
* head a reviewer approved. Absent without the label; `head` empty when the label names no head in a comment. */
|
||||
ready?: ReadyMark;
|
||||
}
|
||||
|
||||
/** The label that marks a pull request ready to merge at the head a comment names (novox/hq issue 722). */
|
||||
export const READY_LABEL = "mesh/ready";
|
||||
|
||||
/** A comment that names the head a pull request is marked ready at: "ready at <full sha>", on a line of its own. */
|
||||
const READY_AT = /^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b/im;
|
||||
|
||||
/** A pull request's ready mark: the head it names, who said it and when, and the label's id, to take it off. */
|
||||
export interface ReadyMark {
|
||||
head: string;
|
||||
by?: string;
|
||||
at?: string;
|
||||
label_id: number;
|
||||
}
|
||||
|
||||
/** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which
|
||||
* the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors=<account> <account> …`. A file
|
||||
* that cannot be read leaves the mesh's own forge account alone, said; never an empty list read as everyone. */
|
||||
export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER,
|
||||
read: (f: string) => string = (f) => readFileSync(f, "utf8")): string[] {
|
||||
const fallback = own ? [own] : [];
|
||||
if (!file) return fallback;
|
||||
try {
|
||||
const line = read(file).split("\n").find((l) => l.startsWith("authors="));
|
||||
const authors = (line ?? "").slice("authors=".length).split(/[\s,]+/).filter(Boolean);
|
||||
if (authors.length) return authors;
|
||||
console.error(`[gitea] ${file} names no account that may mark a pull request ready; only ${fallback.join(", ") || "none"} may`);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${file} cannot be read (${err instanceof Error ? err.message : err}); only ` +
|
||||
`${fallback.join(", ") || "none"} may mark a pull request ready`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** One event of an issue's or pull request's timeline, as the forge keeps it, oldest first: a comment, a push to a
|
||||
* pull request's branch (`pull_push`), a label added (body "1") or taken off (body ""), and the rest. */
|
||||
export interface TimelineEvent {
|
||||
id: number;
|
||||
type: string;
|
||||
body: string;
|
||||
created_at?: string;
|
||||
user?: string;
|
||||
label?: { id: number; name: string };
|
||||
}
|
||||
|
||||
/** The comments that wrote "ready at" without counting, by id: each said once in the log (novox/hq issue 722). */
|
||||
const ignoredSaid = new Set<number>();
|
||||
|
||||
/** **The ready mark a pull request's timeline makes** (novox/hq issue 722), read in the forge's order — every event is
|
||||
* one row of one table, numbered as it happened, so the order is exact where times share a second. A "ready at"
|
||||
* comment counts only when its author is one of the accounts given, and only when it was written after the label
|
||||
* mesh/ready was last added and after the last push to the pull request's branch: a push or the label taken off
|
||||
* undoes every mark before it, so a mark older than the latest push never counts, even with the label still on. A
|
||||
* comment by any other account is ignored, and said once. */
|
||||
export function readyMarkOf(labelID: number, events: TimelineEvent[], authors: string[],
|
||||
say: (line: string) => void = (l) => console.error(l)): ReadyMark {
|
||||
const listed = new Set(authors.map((a) => a.toLowerCase()));
|
||||
let labelled = false;
|
||||
let mark: ReadyMark | undefined;
|
||||
for (const e of [...events].sort((x, y) => x.id - y.id)) {
|
||||
if (e.type === "pull_push") {
|
||||
mark = undefined;
|
||||
} else if (e.type === "label" && e.label?.name === READY_LABEL) {
|
||||
labelled = e.body === "1";
|
||||
mark = undefined;
|
||||
} else if (e.type === "comment") {
|
||||
const m = READY_AT.exec(e.body);
|
||||
if (!m) continue;
|
||||
if (!listed.has(String(e.user ?? "").toLowerCase())) {
|
||||
if (!ignoredSaid.has(e.id)) {
|
||||
ignoredSaid.add(e.id);
|
||||
say(`[gitea] a "ready at ${m[1].slice(0, 12)}" comment (${e.id}) by ${e.user ?? "an unknown account"} is ignored: ` +
|
||||
`only ${authors.join(", ") || "no account"} may mark a pull request ready (novox/hq issue 722)`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (labelled) mark = { head: m[1].toLowerCase(), by: e.user, at: e.created_at, label_id: labelID };
|
||||
}
|
||||
}
|
||||
return mark ?? { head: "", label_id: labelID };
|
||||
}
|
||||
|
||||
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
|
||||
@@ -332,8 +418,9 @@ export class GiteaClient {
|
||||
|
||||
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
|
||||
* page by page (novox/hq issue 347): each with its repository and its own update time. */
|
||||
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
|
||||
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
|
||||
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string;
|
||||
labels?: { id: number; name: string }[] }[]> {
|
||||
const out: { owner: string; name: string; number: number; updated_at?: string; labels?: { id: number; name: string }[] }[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
|
||||
if (since) qs.set("since", since);
|
||||
@@ -343,7 +430,8 @@ export class GiteaClient {
|
||||
const [fo, fn] = String(r.full_name ?? "").split("/");
|
||||
const owner = String(r.owner ?? fo ?? "");
|
||||
const name = String(r.name ?? fn ?? "");
|
||||
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
|
||||
const labels = (Array.isArray(i?.labels) ? i.labels : []).map((l: any) => ({ id: Number(l?.id ?? 0), name: String(l?.name ?? "") }));
|
||||
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at, labels });
|
||||
}
|
||||
if (!found || found.length < 50) break;
|
||||
}
|
||||
@@ -354,7 +442,7 @@ export class GiteaClient {
|
||||
* requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull
|
||||
* request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a
|
||||
* fault in the announcer cannot hide itself. Reads only. */
|
||||
async openPulls(): Promise<OpenPull[]> {
|
||||
async openPulls(readyAuthors: string[] = []): Promise<OpenPull[]> {
|
||||
const out: OpenPull[] = [];
|
||||
const rules = new Map<string, BranchProtection[] | undefined>();
|
||||
for (const found of await this.searchOpenPulls()) {
|
||||
@@ -364,9 +452,14 @@ export class GiteaClient {
|
||||
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
|
||||
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
|
||||
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
|
||||
const labels = found.labels ?? [];
|
||||
// The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label.
|
||||
const label = labels.find((l) => l.name === READY_LABEL);
|
||||
const ready = label ? readyMarkOf(label.id, await this.timeline(found.owner, found.name, pull.number), readyAuthors)
|
||||
: undefined;
|
||||
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
|
||||
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
|
||||
statuses: Object.keys(checks), checks });
|
||||
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name), ...(ready ? { ready } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -639,6 +732,24 @@ export class GiteaClient {
|
||||
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
|
||||
}
|
||||
|
||||
/** **Every event of an issue's or pull request's timeline**, oldest first, page by page (novox/hq issue 722): a long
|
||||
* thread hides nothing. Bounded at 100 pages of 50; a longer one is said, and its first 5000 events read. */
|
||||
async timeline(owner: string, repo: string, index: number): Promise<TimelineEvent[]> {
|
||||
indexOf(index);
|
||||
const out: TimelineEvent[] = [];
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/issues/${index}/timeline?page=${page}&limit=50`);
|
||||
for (const e of raw ?? []) {
|
||||
out.push({ id: Number(e?.id ?? 0), type: String(e?.type ?? ""), body: String(e?.body ?? ""),
|
||||
created_at: e?.created_at, user: e?.user?.login,
|
||||
...(e?.label ? { label: { id: Number(e.label.id ?? 0), name: String(e.label.name ?? "") } } : {}) });
|
||||
}
|
||||
if (!raw || raw.length < 50) return out;
|
||||
}
|
||||
console.error(`[gitea] ${owner}/${repo}#${index} has more than 5000 timeline events; only the first 5000 are read`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every comment on an issue or pull request, oldest first. */
|
||||
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
|
||||
indexOf(index);
|
||||
@@ -905,6 +1016,15 @@ export class GiteaAdmin {
|
||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||
}
|
||||
|
||||
/** Take a user off a team; one not on it is already off. */
|
||||
async removeUserFromTeam(teamId: number, username: string): Promise<void> {
|
||||
const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.status === 204 || res.status === 200 || res.status === 404) return;
|
||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's user logs in with exactly this password and is still a member of the
|
||||
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"npm-package-registry": {
|
||||
"scheme": "http",
|
||||
"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": {
|
||||
"scheme": "http",
|
||||
@@ -108,6 +109,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"ready-authors": {
|
||||
"kind": "preference",
|
||||
"default": "mesh-admin jschoubben",
|
||||
"why": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the mesh's own forge account and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -121,6 +129,14 @@
|
||||
"path": "${dir:mesh-state}/state",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "ready-authors",
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/ready-authors",
|
||||
"mode": "0600",
|
||||
"trusted": true,
|
||||
"content": "# The accounts whose \"ready at <sha>\" comment marks a pull request ready (novox/hq issue 722), from the setting\n# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.\nauthors=${setting:ready-authors}\n"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -148,6 +164,13 @@
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"health": {
|
||||
"kind": "http",
|
||||
"endpoint": "web",
|
||||
"path": "/api/healthz",
|
||||
"status": 200,
|
||||
"needs": "postgres-database"
|
||||
},
|
||||
"name": "gitea",
|
||||
"image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef",
|
||||
"env": {
|
||||
@@ -199,7 +222,8 @@
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
"merge": "json",
|
||||
"takes-settings": []
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
@@ -238,7 +262,8 @@
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json",
|
||||
"MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//
|
||||
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
|
||||
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
|
||||
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
|
||||
// `novox`; a consumer is a gitea *user* placed on that org's package team.
|
||||
// read packages under the `@novox` scope, and write them unless it asked for read only (teams.ts). The
|
||||
// registry's npm owner is the gitea org `novox`; a consumer is a gitea *user* on one of that org's teams.
|
||||
//
|
||||
// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives
|
||||
// the login and hands it to both ends, and mints the password. gitea creates a user under exactly
|
||||
@@ -24,11 +24,9 @@
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { GiteaAdmin } from "../client.js";
|
||||
import { grant, holds } from "./teams.js";
|
||||
|
||||
// The npm registry owner: a gitea org named `novox`, whose package team every consumer joins so it
|
||||
// can read and write packages under the `@novox` scope (ADR 0076).
|
||||
const ORG = "novox";
|
||||
const PACKAGE_TEAM = "packages";
|
||||
// Which team a consumer joins — `packages` to publish, `readers` to read only — is teams.ts's.
|
||||
|
||||
const gitea = GiteaAdmin.fromEnv();
|
||||
|
||||
@@ -38,19 +36,10 @@ const gitea = GiteaAdmin.fromEnv();
|
||||
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
|
||||
runProvisioner("npm-package-registry", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// The org and its package team are the same for every consumer; ensuring them per-create is
|
||||
// idempotent and needs no separate bootstrap step.
|
||||
await gitea.ensureOrg(ORG);
|
||||
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
|
||||
// The user carries the consumer's login and the mesh's minted password, set every run so a
|
||||
// rotation takes. Membership of the package team is what grants read+write on packages.
|
||||
//
|
||||
// The address is gitea's own convention for one that is not real: its email validation
|
||||
// requires a dotted domain, so `@localhost` was refused at create — the fault that had this
|
||||
// grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives
|
||||
// hidden addresses.
|
||||
await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
||||
await gitea.addUserToTeam(teamId, p.as);
|
||||
// The org and its teams are ensured per create: idempotent, and no separate bootstrap step. The
|
||||
// user carries the consumer's login and the mesh's minted password, set every run so a rotation
|
||||
// takes; membership of its team is what grants it what it may do.
|
||||
await grant(gitea, p);
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
@@ -60,6 +49,6 @@ runProvisioner("npm-package-registry", {
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
|
||||
return holds(gitea, p);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Which team of the forge's npm owner a consumer of `npm-package-registry` joins, and the per-consumer
|
||||
// half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in.
|
||||
//
|
||||
// **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes —
|
||||
// the build agent, whose builds publish the SDK and every TypeScript module's packages — joins `packages`:
|
||||
// code read on the org's repositories and write on its packages. A consumer that contributes
|
||||
// `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it
|
||||
// clones branches that are not yet reviewed and runs their code beside its credential, and code that is
|
||||
// not reviewed must not be able to publish the mesh's packages with it.
|
||||
|
||||
/** The npm registry's owner: the gitea org every consumer's packages live under (ADR 0076). */
|
||||
export const ORG = "novox";
|
||||
/** The team whose members read and write the org's packages. */
|
||||
export const PACKAGE_TEAM = "packages";
|
||||
/** The team whose members read the org's code and packages, and write nothing. */
|
||||
export const READ_TEAM = "readers";
|
||||
|
||||
/** The team a consumer joins, from what it contributed; an access it may not ask for is refused. */
|
||||
export function teamFor(values: Readonly<Record<string, unknown>> | undefined): { team: string; packageWrite: boolean } {
|
||||
const access = values?.access;
|
||||
if (access === undefined || access === "write") return { team: PACKAGE_TEAM, packageWrite: true };
|
||||
if (access === "read") return { team: READ_TEAM, packageWrite: false };
|
||||
throw new Error(`npm-package-registry: access ${JSON.stringify(access)} is not "read" or "write"`);
|
||||
}
|
||||
|
||||
/** What the adapter asks of the forge's admin API (client.ts GiteaAdmin). */
|
||||
export interface Forge {
|
||||
ensureOrg(org: string): Promise<void>;
|
||||
ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number>;
|
||||
ensureUser(username: string, password: string, email: string): Promise<void>;
|
||||
addUserToTeam(teamId: number, username: string): Promise<void>;
|
||||
removeUserFromTeam(teamId: number, username: string): Promise<void>;
|
||||
holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface Consumer {
|
||||
readonly as: string;
|
||||
readonly password: string;
|
||||
readonly values: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* grant makes the consumer's user, with exactly the mesh's password, a member of its one team. A consumer
|
||||
* that asks for read only is also taken out of the package team, so one that asked for write before and
|
||||
* read now loses the write at once.
|
||||
*/
|
||||
export async function grant(forge: Forge, p: Consumer): Promise<void> {
|
||||
const { team, packageWrite } = teamFor(p.values);
|
||||
await forge.ensureOrg(ORG);
|
||||
const teamId = await forge.ensureTeam(ORG, team, packageWrite);
|
||||
// The address is gitea's own convention for one that is not real: its email validation requires a
|
||||
// dotted domain, so `@localhost` was refused at create, while `@noreply.localhost` is the shape gitea
|
||||
// itself gives hidden addresses.
|
||||
await forge.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
||||
await forge.addUserToTeam(teamId, p.as);
|
||||
if (!packageWrite) {
|
||||
await forge.removeUserFromTeam(await forge.ensureTeam(ORG, PACKAGE_TEAM, true), p.as);
|
||||
}
|
||||
}
|
||||
|
||||
/** holds is whether the forge still holds the consumer as the mesh gave it: its login, in its team. */
|
||||
export async function holds(forge: Forge, p: Consumer): Promise<boolean> {
|
||||
return forge.holdsTeamMember(ORG, teamFor(p.values).team, p.as, p.password);
|
||||
}
|
||||
@@ -137,3 +137,120 @@ test("a merged pull request whose branch was deleted is announced with its branc
|
||||
assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head");
|
||||
assert.equal(headBranchOf(undefined), undefined);
|
||||
});
|
||||
|
||||
// **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the
|
||||
// newest comment "ready at <full head sha>" by a listed account, written after the label was added and after the last
|
||||
// push. Read from the pull request's whole timeline, page by page; without the label nothing is read.
|
||||
const head = "c".repeat(40), older = "d".repeat(40);
|
||||
const ev = (id: number, type: string, body: string, user = "mesh-admin", label?: { id: number; name: string }) =>
|
||||
({ id, type, body, user: { login: user }, created_at: `2026-10-14T10:${String(id % 60).padStart(2, "0")}:00Z`,
|
||||
...(label ? { label } : {}) });
|
||||
const readyLabel = { id: 31, name: "mesh/ready" };
|
||||
|
||||
function forgeWith(timeline: unknown[]) {
|
||||
const asked: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "";
|
||||
asked.push(url);
|
||||
const send = (v: unknown) => res.end(JSON.stringify(v));
|
||||
if (url.startsWith("/api/v1/repos/issues/search")) {
|
||||
return send(url.includes("page=1") ? [
|
||||
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" },
|
||||
labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] },
|
||||
{ number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] },
|
||||
] : []);
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
|
||||
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "x", sha: head }, html_url: "h" });
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) {
|
||||
return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" });
|
||||
}
|
||||
const t = /\/issues\/7\/timeline\?page=(\d+)&limit=50/.exec(url);
|
||||
if (t) {
|
||||
const page = Number(t[1]);
|
||||
return send(timeline.slice((page - 1) * 50, page * 50));
|
||||
}
|
||||
if (url.includes("/branch_protections")) return send([]);
|
||||
if (url.includes("/status")) return send({ statuses: [] });
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
return { server, asked };
|
||||
}
|
||||
|
||||
async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const { server, asked } = forgeWith(timeline);
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
try {
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
return { open: await client.openPulls(authors), asked };
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
test("a pull request's ready mark is read: the label and the head the newest listed ready comment names", async () => {
|
||||
const { open, asked } = await readOpen([
|
||||
ev(1, "pull_push", JSON.stringify({ commit_ids: [head] })),
|
||||
ev(2, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(3, "comment", `ready at ${older}`),
|
||||
ev(4, "comment", `Reviewed: approve.\nready at ${head}\n`),
|
||||
ev(5, "comment", "thanks", "jschoubben"),
|
||||
]);
|
||||
assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]);
|
||||
assert.deepEqual(open[0].ready, { head, by: "mesh-admin", at: "2026-10-14T10:04:00Z", label_id: 31 });
|
||||
assert.equal(open[1].ready, undefined, "no label, no mark");
|
||||
assert.ok(!asked.some((u) => u.includes("/issues/8/")), "no timeline read without the label");
|
||||
});
|
||||
|
||||
test("a ready comment by an account not listed is ignored, and said", async () => {
|
||||
const { readyMarkOf } = await import("../client.ts");
|
||||
const said: string[] = [];
|
||||
const mark = readyMarkOf(31, [
|
||||
ev(1, "pull_push", "{}") as any, ev(2, "label", "1", "mesh-admin", readyLabel) as any,
|
||||
{ ...ev(3, "comment", `ready at ${head}`, "outsider"), user: "outsider" },
|
||||
].map((e: any) => ({ ...e, user: typeof e.user === "string" ? e.user : e.user.login })), ["mesh-admin"], (l) => said.push(l));
|
||||
assert.equal(mark.head, "", "an outsider's mark counts");
|
||||
assert.equal(said.length, 1);
|
||||
assert.match(said[0], /by outsider is ignored/);
|
||||
});
|
||||
|
||||
test("a ready comment older than the latest push, or than the label, never counts", async () => {
|
||||
const pushedAfter = await readOpen([
|
||||
ev(1, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(2, "comment", `ready at ${head}`),
|
||||
ev(3, "pull_push", JSON.stringify({ commit_ids: [head] })),
|
||||
]);
|
||||
assert.equal(pushedAfter.open[0].ready?.head, "", "a mark written before the latest push counts, the label still on");
|
||||
const beforeLabel = await readOpen([
|
||||
ev(1, "pull_push", "{}"),
|
||||
ev(2, "comment", `ready at ${head}`),
|
||||
ev(3, "label", "1", "mesh-admin", readyLabel),
|
||||
]);
|
||||
assert.equal(beforeLabel.open[0].ready?.head, "", "a mark written before the label was added counts");
|
||||
const takenOff = await readOpen([
|
||||
ev(1, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(2, "comment", `ready at ${head}`),
|
||||
ev(3, "label", "", "mesh-admin", readyLabel),
|
||||
]);
|
||||
assert.equal(takenOff.open[0].ready?.head, "", "a mark whose label was taken off since counts");
|
||||
});
|
||||
|
||||
test("a long thread hides nothing: every page of the timeline is read", async () => {
|
||||
const events: unknown[] = [ev(1, "pull_push", "{}"), ev(2, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(3, "comment", `ready at ${older}`)];
|
||||
for (let id = 4; id < 130; id++) events.push(ev(id, "comment", "a long discussion", "jschoubben"));
|
||||
events.push(ev(130, "comment", `ready at ${head}`, "jschoubben"));
|
||||
const { open, asked } = await readOpen(events);
|
||||
assert.equal(open[0].ready?.head, head, "the newest mark, on the third page, is not read");
|
||||
assert.equal(asked.filter((u) => u.includes("/issues/7/timeline")).length, 3);
|
||||
});
|
||||
|
||||
test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => {
|
||||
const { readyAuthors } = await import("../client.ts");
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]);
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }), ["mesh-admin"]);
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n"), ["mesh-admin"]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { grant, holds, PACKAGE_TEAM, READ_TEAM, teamFor, type Forge } from "../provisioner/teams.ts";
|
||||
|
||||
// Defends novox/hq issue 624: the lab reads the forge with a credential of its own that can write no
|
||||
// package. A consumer that contributes {"access": "read"} joins a team with code and package read only.
|
||||
|
||||
function aForge(): { forge: Forge; did: string[] } {
|
||||
const did: string[] = [];
|
||||
const teams: Record<string, number> = {};
|
||||
const forge: Forge = {
|
||||
async ensureOrg(org) { did.push(`org ${org}`); },
|
||||
async ensureTeam(org, team, packageWrite) {
|
||||
did.push(`team ${org}/${team} packages:${packageWrite ? "write" : "read"}`);
|
||||
return (teams[team] ??= Object.keys(teams).length + 1);
|
||||
},
|
||||
async ensureUser(username) { did.push(`user ${username}`); },
|
||||
async addUserToTeam(id, username) { did.push(`add ${username} to ${id}`); },
|
||||
async removeUserFromTeam(id, username) { did.push(`remove ${username} from ${id}`); },
|
||||
async holdsTeamMember(org, team, username) { did.push(`holds ${username} in ${org}/${team}`); return true; },
|
||||
};
|
||||
return { forge, did };
|
||||
}
|
||||
|
||||
test("a consumer that asks nothing, or write, publishes: the package team", () => {
|
||||
assert.deepEqual(teamFor({}), { team: PACKAGE_TEAM, packageWrite: true });
|
||||
assert.deepEqual(teamFor({ access: "write" }), { team: PACKAGE_TEAM, packageWrite: true });
|
||||
});
|
||||
|
||||
test("a consumer that asks to read joins the readers, who write no package", () => {
|
||||
assert.deepEqual(teamFor({ access: "read" }), { team: READ_TEAM, packageWrite: false });
|
||||
assert.throws(() => teamFor({ access: "admin" }), /not "read" or "write"/);
|
||||
});
|
||||
|
||||
test("a reader is made, put on the readers team, and taken off the package team", async () => {
|
||||
const { forge, did } = aForge();
|
||||
await grant(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
|
||||
assert.deepEqual(did, [
|
||||
"org novox",
|
||||
"team novox/readers packages:read",
|
||||
"user lab-shanks",
|
||||
"add lab-shanks to 1",
|
||||
"team novox/packages packages:write",
|
||||
"remove lab-shanks from 2",
|
||||
]);
|
||||
did.length = 0;
|
||||
await holds(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
|
||||
assert.deepEqual(did, ["holds lab-shanks in novox/readers"]);
|
||||
});
|
||||
|
||||
test("a publisher is made and put on the package team, as before", async () => {
|
||||
const { forge, did } = aForge();
|
||||
await grant(forge, { as: "build-agent-ace", password: "pw", values: {} });
|
||||
assert.deepEqual(did, ["org novox", "team novox/packages packages:write", "user build-agent-ace", "add build-agent-ace to 1"]);
|
||||
});
|
||||
@@ -173,3 +173,29 @@ test("a verdict that landed out of date keeps its account on the pull request",
|
||||
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
|
||||
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
|
||||
});
|
||||
|
||||
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
|
||||
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
|
||||
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
|
||||
// branch's move puts its open verdicts out of date, and leaves those into main alone.
|
||||
const release = "feat/57-config-and-provisioning";
|
||||
const heads: Record<string, string> = { main: TRUNK, [release]: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" };
|
||||
const pulls = [{ number: 315, base: release, head: "3".repeat(40) }, { number: 1, base: "main", head: "1".repeat(40) }];
|
||||
const { forge, statuses } = aForge(pulls, heads);
|
||||
const gates = new GateWriter(forge);
|
||||
const trunks: Record<string, string> = {};
|
||||
await weighTrunks(forge, gates, trunks, "novox", "mesh-controller");
|
||||
|
||||
heads[release] = MOVED;
|
||||
assert.equal(await weighTrunks(forge, gates, trunks, "novox", "mesh-controller"), true);
|
||||
const s = statuses.get("3".repeat(40))!;
|
||||
assert.equal(s["mesh/merge-gate"].state, "pending");
|
||||
assert.match(s["mesh/merge-gate"].description!, /judged against trunk eeeeeeeeeeee, the trunk is now d28ca95ccccc/);
|
||||
assert.equal(s["mesh/repo-check"].state, "pending");
|
||||
assert.equal(statuses.get("1".repeat(40))!["mesh/merge-gate"].state, "success", "a pull request into main is left");
|
||||
assert.equal(trunks[`novox/mesh-controller:${release}`], MOVED);
|
||||
|
||||
// The merge tool's rule reads the same: a verdict judged against the release branch's old head is not current.
|
||||
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
|
||||
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient, type BranchProtection } from "../client.js";
|
||||
import { GiteaClient, readyAuthors, type BranchProtection } from "../client.js";
|
||||
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
|
||||
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
|
||||
import {
|
||||
@@ -514,9 +514,9 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
},
|
||||
{
|
||||
name: "gitea_open_pulls",
|
||||
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.",
|
||||
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head named by the newest comment \"ready at <full head sha>\" that a listed account (the setting ready-authors) wrote after the label was added and after the last push — which mesh-delivery merges on (issue 722). Reads only.",
|
||||
input: {},
|
||||
run: async () => ({ pulls: await gitea.openPulls() }),
|
||||
run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }),
|
||||
},
|
||||
{
|
||||
name: "gitea_contains",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "provisioner/teams.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
+18
-2
@@ -5,6 +5,20 @@
|
||||
"container-runtime",
|
||||
"virtualisation"
|
||||
],
|
||||
"requires": [
|
||||
"npm-package-registry"
|
||||
],
|
||||
"contributes": {
|
||||
"npm-package-registry": {
|
||||
"access": "read"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"npm-package-registry": "${dir:state}/package-registry.json"
|
||||
},
|
||||
"secrets": {
|
||||
"npm-package-registry": "${dir:state}/package-registry.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
@@ -33,7 +47,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/lab.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_LAB_FORGE=${setting:forge}\n"
|
||||
"content": "MESH_LAB_FORGE=${setting:forge}\nMESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json\nMESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret\n"
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
@@ -90,7 +104,9 @@
|
||||
],
|
||||
"env": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
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;/);
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts";
|
||||
import { installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, 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
|
||||
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
|
||||
@@ -98,3 +98,391 @@ test("a run checks out mesh-tools-go and says where its checkouts are", () => {
|
||||
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
|
||||
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
|
||||
});
|
||||
|
||||
// Defends novox/hq issue 624, its second half: mesh-tools-go is private, and the lab cloned over HTTP with
|
||||
// no credential ("could not read Username"). A run clones with the forge credential the mesh binds and
|
||||
// seals to the lab, through git's credential store, and the secret is in no script, URL or argument.
|
||||
import { credentialArgs, credentialPath, forgeCredential } from "../tools/runs.ts";
|
||||
|
||||
function aBinding(as: string, secret: string): { binding: string; secret: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-binding-"));
|
||||
writeFileSync(join(dir, "package-registry.json"), JSON.stringify({ provision: "npm-package-registry", at: "10.42.0.1", as,
|
||||
serves: { scheme: "http", port: 3000 } }));
|
||||
writeFileSync(join(dir, "package-registry.secret"), `${secret}\n`);
|
||||
return { binding: join(dir, "package-registry.json"), secret: join(dir, "package-registry.secret") };
|
||||
}
|
||||
|
||||
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
|
||||
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
|
||||
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
|
||||
});
|
||||
|
||||
test("a run clones through git's credential store, and its script carries no secret", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
|
||||
const path = credentialPath("/work", "run-x");
|
||||
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
|
||||
assert.ok(credential);
|
||||
assert.doesNotMatch(s, /s3cret/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
|
||||
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
|
||||
assert.equal(credentialArgs(undefined), "");
|
||||
});
|
||||
|
||||
// Held against git itself: the credential in the store is offered to the forge's origin when it asks, and
|
||||
// a clone without it fails at once rather than prompting.
|
||||
test("git presents the stored credential to the forge, and only when one is given", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
|
||||
const store = join(dir, "git-credentials");
|
||||
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
assert.match(fill.stdout, /username=lab-shanks/);
|
||||
assert.match(fill.stdout, /password=pw/);
|
||||
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
assert.doesNotMatch(other.stdout, /password=pw/);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Review 196 on mesh-catalog #272: the credential file outlived a stop, because SIGTERM to the run's
|
||||
// process group killed the wrapper before its cleanup. It is now removed right after the clones, on
|
||||
// every way the script ends, and at the next run's start and in lab_check for what a crash left.
|
||||
import { cleanCredentials, credentialGuard } from "../tools/runs.ts";
|
||||
|
||||
test("a stopped script removes the credential, whatever ends it", () => {
|
||||
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-guard-"));
|
||||
const file = join(dir, ".run-x.git-credentials");
|
||||
writeFileSync(file, "http://u:p@h\n", { mode: 0o600 });
|
||||
const ran = spawnSync("bash", ["-c", `${credentialGuard(file)}\nkill -${signal.slice(3)} $$\nsleep 5`], { encoding: "utf8", timeout: 10_000 });
|
||||
assert.notEqual(ran.status, 0, `${signal}: the script was not stopped`);
|
||||
assert.equal(existsSync(file), false, `${signal} left the credential on disk`);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the script removes the credential as soon as its clones are done", () => {
|
||||
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const lastClone = s.lastIndexOf("clone --quiet");
|
||||
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
|
||||
const building = s.indexOf("npm ci");
|
||||
assert.ok(lastClone > 0 && removed > lastClone && removed < building, "not removed between the clones and the build");
|
||||
assert.match(s, /trap 'rm -f '\\''\/work\/\.run-x\.git-credentials'\\''' EXIT/);
|
||||
});
|
||||
|
||||
test("credentials a crashed run left are removed, but not the running one's", () => {
|
||||
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
||||
for (const n of [".run-a.git-credentials", ".run-b.git-credentials", "run-a", "other"]) writeFileSync(join(work, n), "x");
|
||||
const removed = cleanCredentials(work, join(work, ".run-b.git-credentials"));
|
||||
assert.deepEqual(removed, [join(work, ".run-a.git-credentials")]);
|
||||
assert.ok(existsSync(join(work, ".run-b.git-credentials")) && existsSync(join(work, "run-a")) && existsSync(join(work, "other")));
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The lab asks the forge for read only (review 196): its credential is for clones of branches nobody has
|
||||
// reviewed yet, and must not be able to publish the mesh's packages.
|
||||
test("the lab asks the package registry for read only", () => {
|
||||
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
|
||||
requires: unknown[]; contributes?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
assert.ok(m.requires.includes("npm-package-registry"));
|
||||
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");
|
||||
});
|
||||
|
||||
+121
-32
@@ -4,7 +4,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun,
|
||||
launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem,
|
||||
} from "./runs.js";
|
||||
|
||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
||||
@@ -12,6 +19,18 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||
// 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(/\/+$/, "");
|
||||
// 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,
|
||||
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"));
|
||||
return [
|
||||
{
|
||||
name: "lab_check",
|
||||
@@ -20,17 +39,39 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
run: async () => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const dir = `${work}/check`;
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
||||
if (clone.status !== 0) return { ok: false, output: clone.stderr };
|
||||
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
||||
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
||||
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
|
||||
// directory of its own either way, and removes it when it is done.
|
||||
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" });
|
||||
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
|
||||
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" });
|
||||
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
|
||||
// repository it clones answers on the forge, and every one it installs has a lock file where the run
|
||||
// installs from.
|
||||
const problems = setupProblems(forge, `${work}/check-setup`);
|
||||
const output = [`${check.stdout}${check.stderr}`.trim(), ...problems].filter(Boolean).join("\n");
|
||||
// A credential a stopped, crashed or rebooted run or rebuild left behind is removed, and said; one still in
|
||||
// use by a running run or rebuild is not.
|
||||
const busy = [running(work)?.id, buildingBase(work)?.id].filter((id): id is string => !!id);
|
||||
const left = cleanCredentials(work, busy.map((id) => credentialPath(work, id)));
|
||||
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run or rebuild left behind: ${left.join(", ")}` : "";
|
||||
// **And how old the base image is** (novox/hq issue 698): past the bound, missing, or of an age that cannot
|
||||
// be read is a problem here, though lab_run refuses only the first.
|
||||
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 };
|
||||
},
|
||||
},
|
||||
@@ -62,9 +103,43 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
}
|
||||
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(", ")}` };
|
||||
const busy = running(work);
|
||||
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
|
||||
return { started: true, run: start(work, forge, tests, refs) };
|
||||
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
|
||||
// review 256): the registry asked first, then the check and the start with nothing awaited between.
|
||||
const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() },
|
||||
(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 };
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -99,30 +174,44 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
|
||||
* would go through.
|
||||
*/
|
||||
export function setupProblems(forge: string, dir: string): string[] {
|
||||
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
|
||||
const problems: string[] = [];
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
for (const repo of REPOSITORIES) {
|
||||
const url = `${forge}/novox/${repo}.git`;
|
||||
const remote = spawnSync("git", ["ls-remote", "--heads", url, "main"], { encoding: "utf8" });
|
||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||
problems.push(`a run checks out ${repo}, and ${url} answers no main branch`);
|
||||
continue;
|
||||
}
|
||||
if (!NPM_REPOSITORIES.includes(repo)) continue;
|
||||
const at = `${dir}/${repo}`;
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", url, at], { encoding: "utf8" });
|
||||
if (clone.status !== 0) {
|
||||
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
|
||||
continue;
|
||||
}
|
||||
const plan = installPlan(at);
|
||||
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
|
||||
if (plan.install.length === 0 && plan.unlocked.length === 0) {
|
||||
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
|
||||
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
|
||||
const keep = mkdtempSync(join(tmpdir(), "lab-check-"));
|
||||
const file = writeCredential(join(keep, "git-credentials"), credential);
|
||||
const git = (args: string[]) =>
|
||||
spawnSync("sh", ["-c", `git ${credentialArgs(file)}${args.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")}`], {
|
||||
encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||
});
|
||||
try {
|
||||
for (const repo of REPOSITORIES) {
|
||||
const url = `${forge}/novox/${repo}.git`;
|
||||
const remote = git(["ls-remote", "--heads", url, "main"]);
|
||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||
const said = remote.stderr.trim().split("\n")[0] ?? "";
|
||||
problems.push(`a run checks out ${repo}, and ${url} could not be read` +
|
||||
(said ? `: ${said}` : ": no main branch") +
|
||||
(credential ? "" : " — the lab holds no forge credential (its npm-package-registry binding and secret)"));
|
||||
continue;
|
||||
}
|
||||
if (!NPM_REPOSITORIES.includes(repo)) continue;
|
||||
const at = `${dir}/${repo}`;
|
||||
const clone = git(["clone", "--quiet", "--depth", "1", url, at]);
|
||||
if (clone.status !== 0) {
|
||||
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
|
||||
continue;
|
||||
}
|
||||
const plan = installPlan(at);
|
||||
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
|
||||
if (plan.install.length === 0 && plan.unlocked.length === 0) {
|
||||
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(keep, { recursive: true, force: true });
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
}
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
+593
-12
@@ -6,8 +6,8 @@
|
||||
// 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.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
@@ -60,6 +60,224 @@ export function installStep(root: string, build: boolean): string {
|
||||
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 —
|
||||
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
|
||||
*
|
||||
* **The mesh's credential for the forge, the way the build agent holds it** (novox/hq issue 624): the lab
|
||||
* requires the package registry, the forge's provisioner binds it an account (`as`) and seals that
|
||||
* account's password, and the forge that answers npm is the forge that hosts the repositories. Private
|
||||
* repositories (mesh-tools-go) are cloned with it; public ones clone as before. Written to a file git reads
|
||||
* through its credential store, never into a URL, an argument, the run's script or its log, and offered
|
||||
* only to the forge's own scheme, host and port.
|
||||
*/
|
||||
export function forgeCredential(forge: string, bindingFile: string, secretFile: string): string | undefined {
|
||||
if (!forge || !bindingFile || !secretFile) return undefined;
|
||||
let as = "";
|
||||
let secret = "";
|
||||
try {
|
||||
as = String((JSON.parse(readFileSync(bindingFile, "utf8")) as { as?: string }).as ?? "");
|
||||
secret = readFileSync(secretFile, "utf8").trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!as || !secret) return undefined;
|
||||
let origin: URL;
|
||||
try {
|
||||
origin = new URL(forge);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const line = new URL(`${origin.protocol}//${origin.host}`);
|
||||
line.username = as;
|
||||
line.password = secret;
|
||||
return line.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Where a run keeps its forge credential: beside its directory, never in it, and removed when it ends. */
|
||||
export function credentialPath(work: string, id: string): string {
|
||||
return join(work, `.${id}.git-credentials`);
|
||||
}
|
||||
|
||||
/** writeCredential puts a credential-store line where git reads it, readable by this account alone. */
|
||||
export function writeCredential(path: string, line: string | undefined): string | undefined {
|
||||
if (!line) return undefined;
|
||||
writeFileSync(path, `${line}\n`, { mode: 0o600 });
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* credentialGuard is the shell that removes the credential however the script ends — its end, an error,
|
||||
* or a stop's SIGTERM to the run's process group, which kills the wrapper before its own cleanup runs
|
||||
* (review 196 on mesh-catalog #272). The script also removes it as soon as its clones are done.
|
||||
*/
|
||||
export function credentialGuard(path: string | undefined): string {
|
||||
if (!path) return "";
|
||||
const rm = `rm -f ${shellQuote(path)}`;
|
||||
return `trap ${shellQuote(rm)} EXIT
|
||||
trap ${shellQuote(`${rm}; exit 143`)} TERM
|
||||
trap ${shellQuote(`${rm}; exit 130`)} INT
|
||||
trap ${shellQuote(`${rm}; exit 129`)} HUP`;
|
||||
}
|
||||
|
||||
/**
|
||||
* cleanCredentials removes every run's and every base rebuild's credential file left in the work directory
|
||||
* but the ones named — what a crash or a reboot left behind, which no trap could remove. Answers what it
|
||||
* removed.
|
||||
*/
|
||||
export function cleanCredentials(work: string, keep?: string | string[]): string[] {
|
||||
if (!existsSync(work)) return [];
|
||||
const kept = new Set([keep ?? []].flat());
|
||||
const removed: string[] = [];
|
||||
for (const name of readdirSync(work)) {
|
||||
if (!/^\.(run|base)-.*\.git-credentials$/.test(name)) continue;
|
||||
const path = join(work, name);
|
||||
if (kept.has(path)) continue;
|
||||
rmSync(path, { force: true });
|
||||
removed.push(path);
|
||||
}
|
||||
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. */
|
||||
export function credentialArgs(path: string | undefined): string {
|
||||
if (!path) return "";
|
||||
return `-c credential.helper= -c ${shellQuote(`credential.helper=store --file=${path}`)} `;
|
||||
}
|
||||
|
||||
export interface RunStatus {
|
||||
id: string;
|
||||
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
|
||||
@@ -70,6 +288,117 @@ export interface RunStatus {
|
||||
commits?: Record<string, string>;
|
||||
exit?: 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 {
|
||||
@@ -84,16 +413,23 @@ export function readStatus(work: string, id: string): RunStatus | undefined {
|
||||
try {
|
||||
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
|
||||
// the machine restarted under it. Said, rather than left reading as running for ever.
|
||||
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
|
||||
// the machine restarted under it. Said, rather than left reading as running for ever. One that never
|
||||
// recorded a process (the runtime died between writing its status and starting it) is lost too: it
|
||||
// would otherwise hold up every run after it (review 256).
|
||||
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && gone(s.pid)) {
|
||||
s.state = "lost";
|
||||
}
|
||||
return s;
|
||||
return settled(work, s);
|
||||
} catch {
|
||||
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 {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
@@ -125,7 +461,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
|
||||
* 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>): string {
|
||||
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||
const dir = runDir(work, id);
|
||||
const setState = (state: string) =>
|
||||
`node -e ${shellQuote(
|
||||
@@ -134,22 +471,26 @@ export function script(work: string, id: string, forge: string, tests: string[],
|
||||
const clones = REPOSITORIES.map((repo) => {
|
||||
const ref = refs[repo] ?? "main";
|
||||
return [
|
||||
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
const bin = join(dir, "bin");
|
||||
return `set -euo pipefail
|
||||
# A repository the forge will not show fails its clone at once, rather than waiting on a prompt.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
${credentialGuard(credential)}
|
||||
cd ${shellQuote(dir)}
|
||||
${setState("checking-out")}
|
||||
${clones}
|
||||
${credential ? `# The forge credential is for the clones, and nothing after them runs with it on disk.\nrm -f ${shellQuote(credential)}` : ""}
|
||||
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))`,
|
||||
)}
|
||||
${setState("building")}
|
||||
# The @novox scope resolves from the mesh's own package registry on the forge, as the builder
|
||||
# resolves it; nothing else is asked of it.
|
||||
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||
# The @novox scope resolves from the mesh's own package registry on the forge, named by the address it
|
||||
# names itself by (npmRegistry), which is the address its lock files hold; nothing else is asked of it.
|
||||
printf '%s\n' ${shellQuote(`@novox:registry=${registry}`)} > ${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")}
|
||||
mkdir -p ${shellQuote(bin)}
|
||||
@@ -174,13 +515,17 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
|
||||
}
|
||||
|
||||
/** start begins a run and returns at once with its status. */
|
||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>): RunStatus {
|
||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry?: string): RunStatus {
|
||||
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = runDir(work, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
|
||||
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
|
||||
// What a crashed or rebooted run left behind goes before this run writes its own.
|
||||
cleanCredentials(work);
|
||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
|
||||
|
||||
// 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).
|
||||
@@ -188,6 +533,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
|
||||
node -e ${shellQuote(
|
||||
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"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))}
|
||||
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
|
||||
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
@@ -220,3 +566,238 @@ export function tail(work: string, id: string, lines: number): string {
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
"policy": "record",
|
||||
"why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)"
|
||||
},
|
||||
"unhealthy": {
|
||||
"severity": "urgent",
|
||||
"why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
@@ -56,6 +60,19 @@
|
||||
"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": [
|
||||
"user.created",
|
||||
"user.deleted",
|
||||
@@ -371,7 +388,7 @@
|
||||
"id": "resolver",
|
||||
"type": "container",
|
||||
"name": "mailu-resolver",
|
||||
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
|
||||
"image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e",
|
||||
"health": {
|
||||
"kind": "runtime"
|
||||
},
|
||||
@@ -443,7 +460,12 @@
|
||||
"name": "mailu-smtp",
|
||||
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
||||
"health": {
|
||||
"kind": "runtime"
|
||||
"kind": "tool",
|
||||
"tool": "mailu_health",
|
||||
"interval": "60s",
|
||||
"timeout": "30s",
|
||||
"looks": 3,
|
||||
"grace": "120s"
|
||||
},
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
@@ -560,7 +582,12 @@
|
||||
"name": "mailu-front",
|
||||
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
||||
"health": {
|
||||
"kind": "runtime"
|
||||
"kind": "exec",
|
||||
"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",
|
||||
"env-file": [
|
||||
@@ -637,6 +664,7 @@
|
||||
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
||||
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_SMTP_CONTAINER": "mailu-smtp",
|
||||
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
@@ -673,7 +701,7 @@
|
||||
"jails": [
|
||||
{
|
||||
"name": "mailu-front",
|
||||
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
|
||||
"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,",
|
||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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,6 +4,27 @@
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
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[] {
|
||||
return [
|
||||
@@ -143,12 +164,14 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
|
||||
// than failing the whole runtime.
|
||||
// The admin tools exist only when the admin API is configured; without it, mailu contributes only its
|
||||
// health tool rather than failing the whole runtime.
|
||||
registerModuleTools("mailu", (env) => {
|
||||
let tools: ToolDefinition[] = [];
|
||||
try {
|
||||
return getMailuTools(MailuClient.fromEnv(env));
|
||||
tools = getMailuTools(MailuClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
tools = [];
|
||||
}
|
||||
return [...tools, getHealthTool(env)];
|
||||
});
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// What the catalogue makes of a build outcome it hears (novox/hq issue 712).
|
||||
//
|
||||
// Kept apart from index.ts, which subscribes at its top level, so the decision can be tested without a bus.
|
||||
|
||||
import type { Made } from "./store.js";
|
||||
|
||||
/** What the build seat says when it has built something, and what the control plane replays. */
|
||||
export interface Built {
|
||||
module?: string;
|
||||
commit?: string;
|
||||
repository?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
manifest?: unknown;
|
||||
/** What this build published, each pinned as anything else would name it. */
|
||||
made?: Made[];
|
||||
/**
|
||||
* Every artifact this was built on top of, so the edge is derived rather than declared.
|
||||
*
|
||||
* References, not module names: the builder sees a pinned image and cannot see which module
|
||||
* produced it. Turning that into an edge between module-versions is this module's job.
|
||||
*/
|
||||
against?: string[];
|
||||
/**
|
||||
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
|
||||
*
|
||||
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
|
||||
* not something anything should act on now: emitting `upgraded` would have the control plane
|
||||
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
|
||||
* current.
|
||||
*/
|
||||
replay?: boolean;
|
||||
/** Why the build failed; a failed build made no module-version. */
|
||||
failed?: string;
|
||||
/** A dry run is looked at, never taken in (novox/hq issue 240). */
|
||||
"dry-run"?: boolean;
|
||||
/** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */
|
||||
"check-outcome"?: unknown;
|
||||
check?: unknown;
|
||||
/** The repository's default branch as the build seat read the forge at the build (ADR 0238); empty from a seat older than the rule. */
|
||||
trunk?: string;
|
||||
/** Whether the commit is on `trunk`. */
|
||||
"on-trunk"?: boolean;
|
||||
/** The branches the commit is on. */
|
||||
branches?: string[];
|
||||
}
|
||||
|
||||
/** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */
|
||||
export const BUILD_EVENTS = [
|
||||
// **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the
|
||||
// retired seat below, so every build after the handover reached the graph only through the replay this
|
||||
// module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start.
|
||||
"node-build-agent.built",
|
||||
// The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row.
|
||||
"mesh-build-machine.built",
|
||||
// What the mesh already held, when this module asks what it missed (novox/hq ADR 0134).
|
||||
"mesh-controller.built-before",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Whether a build outcome is a module-version to place in the graph, and why not when it is not.
|
||||
*
|
||||
* It mirrors two of the rules the controller keeps before it registers a build (mesh-controller
|
||||
* cmd/mesh-controller/main.go `builds.Built`, build.go `publishable`): a merge check, a try and a dry run are
|
||||
* never published, and **only a commit on its repository's trunk is** (ADR 0238) — a branch built through
|
||||
* the `build` verb is checked, never a module's version. Where the seat names no trunk (one older than the
|
||||
* rule) it is placed, as the controller lets it through. A failure made nothing and is passed over too.
|
||||
*
|
||||
* Not mirrored, because the catalogue cannot see what they read: the branch a module follows when it is not
|
||||
* the default one (the controller's `follows`; such a module's builds off the default branch are passed over
|
||||
* here and reach the graph by the replay), ADR 0266's rule that a module registers only from the repository
|
||||
* it is built from, a superseded request, a build in the scratch namespace, one that failed its gate, and a
|
||||
* build whose source is unchanged. Each needs the controller's records. This is safe for what the mesh
|
||||
* sends, which is chosen from the controller's own module table and never from this graph; that the graph
|
||||
* can still hold a build the controller refused under those rules is a follow-up (hq issue 712).
|
||||
*/
|
||||
export function placeable(body: Built):
|
||||
| { placed: "yes"; module: string; commit: string }
|
||||
| { placed: "no"; why: string; fault: boolean } {
|
||||
if (body["check-outcome"] != null || body.check != null) {
|
||||
return { placed: "no", why: "a merge check or a try: nothing of it merged", fault: false };
|
||||
}
|
||||
if (body["dry-run"]) {
|
||||
return { placed: "no", why: "a dry run is looked at, never taken in", fault: false };
|
||||
}
|
||||
if (body.failed) {
|
||||
return { placed: "no", why: "a failed build made no module-version", fault: false };
|
||||
}
|
||||
if (body.trunk && !body["on-trunk"] && !(body.branches ?? []).includes(body.trunk)) {
|
||||
return { placed: "no", why: `the commit is not on its repository's trunk ${body.trunk}: checked, never published (ADR 0238)`, fault: false };
|
||||
}
|
||||
if (!body.module || !body.commit) {
|
||||
return { placed: "no", why: "it named no module or no commit", fault: true };
|
||||
}
|
||||
return { placed: "yes", module: body.module, commit: body.commit };
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
// diamond need no special handling, and nothing holds a plan.
|
||||
|
||||
import { on, emit } from "@novox/mesh-sdk/events";
|
||||
import { Graph, type Made } from "./store.js";
|
||||
import { Graph } from "./store.js";
|
||||
import { BUILD_EVENTS, placeable, type Built } from "./heard.js";
|
||||
|
||||
const graph = Graph.fromEnv();
|
||||
|
||||
@@ -21,54 +22,29 @@ const graph = Graph.fromEnv();
|
||||
// would block the very apply that brings the overlay up — stopped being true when a step's failure
|
||||
// became this module's business and not the machine's (ADR 0136).
|
||||
|
||||
/** What the builder says when it has built something. */
|
||||
interface Built {
|
||||
module?: string;
|
||||
commit?: string;
|
||||
repository?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
manifest?: unknown;
|
||||
/** What this build published, each pinned as anything else would name it. */
|
||||
made?: Made[];
|
||||
/**
|
||||
* Every artifact this was built on top of, so the edge is derived rather than declared.
|
||||
*
|
||||
* References, not module names: the builder sees a pinned image and cannot see which module
|
||||
* produced it. Turning that into an edge between module-versions is this module's job.
|
||||
*/
|
||||
against?: string[];
|
||||
/**
|
||||
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
|
||||
*
|
||||
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
|
||||
* not something anything should act on now: emitting `upgraded` would have the control plane
|
||||
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
|
||||
* current.
|
||||
*/
|
||||
replay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a build means for the graph, wherever it came from.
|
||||
*
|
||||
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
|
||||
* and the control plane says what it already held when this module asks what it missed
|
||||
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
|
||||
* months ago — see `replay` above.
|
||||
* months ago — see `replay` in heard.ts.
|
||||
*/
|
||||
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
const body = (event.body ?? {}) as Built;
|
||||
if (!body.module || !body.commit) {
|
||||
// Said rather than dropped: a build that announced itself without saying what it built is a
|
||||
// fault in the builder, and a silent discard here would make it look like a missing event.
|
||||
console.error("mesh-catalog: a build event named no module or no commit; ignored", body);
|
||||
const placing = placeable(body);
|
||||
if (placing.placed === "no") {
|
||||
// A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One
|
||||
// that names no module or commit is a fault in the builder, said rather than dropped, so it does
|
||||
// not look like a missing event.
|
||||
if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body);
|
||||
return;
|
||||
}
|
||||
const { module, commit } = placing;
|
||||
|
||||
const { upgraded, previous } = await graph.register({
|
||||
module: body.module,
|
||||
commit: body.commit,
|
||||
module,
|
||||
commit,
|
||||
repository: body.repository ?? "",
|
||||
path: body.path ?? "",
|
||||
ref: body.ref ?? "",
|
||||
@@ -80,7 +56,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
if (body.replay) return;
|
||||
|
||||
await emit("registered", {
|
||||
module: body.module, commit: body.commit, upgraded,
|
||||
module, commit, upgraded,
|
||||
});
|
||||
|
||||
// **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward
|
||||
@@ -88,7 +64,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
if (!upgraded) return;
|
||||
|
||||
await emit("upgraded", {
|
||||
module: body.module, commit: body.commit, previous,
|
||||
module, commit, previous,
|
||||
});
|
||||
|
||||
// What can be built now — stale, and waiting on nothing that is itself stale.
|
||||
@@ -101,9 +77,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// As it happens, and what the mesh already held when this module asked what it missed.
|
||||
await on("mesh-build-machine.built", placeTheBuild);
|
||||
await on("mesh-controller.built-before", placeTheBuild);
|
||||
// As it happens — from the build seat that builds now, and the retired one while it still answers — and
|
||||
// what the mesh already held when this module asked what it missed (novox/hq issue 712).
|
||||
for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild);
|
||||
|
||||
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
||||
//
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
},
|
||||
"consumes": [
|
||||
"node-build-agent.built",
|
||||
"mesh-build-machine.built",
|
||||
"mesh-controller.built-before"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { BUILD_EVENTS, placeable } from "../heard.js";
|
||||
|
||||
// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712.
|
||||
const walkBuild = {
|
||||
id: "build-1791997333800041716",
|
||||
repository: "http://novox.internal:20000/novox/mesh-catalog.git",
|
||||
path: "modules/mailu",
|
||||
ref: "main",
|
||||
on: "shanks",
|
||||
trunk: "main",
|
||||
"on-trunk": true,
|
||||
branches: ["main"],
|
||||
module: "mailu",
|
||||
commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87",
|
||||
manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } },
|
||||
made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }],
|
||||
};
|
||||
|
||||
test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => {
|
||||
assert.ok(BUILD_EVENTS.includes("node-build-agent.built"));
|
||||
assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before"));
|
||||
// Subscribing is not enough: the mesh grants and routes only what the manifest consumes.
|
||||
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
|
||||
for (const heard of BUILD_EVENTS) assert.ok(manifest.consumes.includes(heard), `${heard} is not consumed`);
|
||||
});
|
||||
|
||||
test("a walk's build is placed, under its module and commit", () => {
|
||||
assert.deepEqual(placeable(walkBuild), { placed: "yes", module: "mailu", commit: walkBuild.commit });
|
||||
// A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2).
|
||||
assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).placed, "yes");
|
||||
});
|
||||
|
||||
test("a merge check, a try, a dry run and a failure are passed over without a fault", () => {
|
||||
for (const [body, why] of [
|
||||
[{ ...walkBuild, "check-outcome": { verdict: "passed" } }, "merge check"],
|
||||
[{ ...walkBuild, check: { try: true } }, "merge check or a try"],
|
||||
[{ ...walkBuild, "dry-run": true }, "dry run"],
|
||||
[{ ...walkBuild, failed: "exit status 1" }, "failed"],
|
||||
] as const) {
|
||||
const placing = placeable(body);
|
||||
assert.equal(placing.placed, "no");
|
||||
if (placing.placed === "no") {
|
||||
assert.match(placing.why, new RegExp(why));
|
||||
assert.equal(placing.fault, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an outcome that names no module or commit is said as the builder's fault", () => {
|
||||
const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit });
|
||||
assert.equal(placing.placed, "no");
|
||||
if (placing.placed === "no") assert.equal(placing.fault, true);
|
||||
});
|
||||
|
||||
test("a branch built through the build verb is passed over; a commit on the trunk, or from a seat that names none, is placed (ADR 0238)", () => {
|
||||
const branch = { ...walkBuild, ref: "feature", "on-trunk": false, branches: ["feature"] };
|
||||
const placing = placeable(branch);
|
||||
assert.equal(placing.placed, "no");
|
||||
if (placing.placed === "no") {
|
||||
assert.match(placing.why, /not on its repository's trunk main/);
|
||||
assert.equal(placing.fault, false);
|
||||
}
|
||||
// On the trunk by its branches although the seat did not set on-trunk.
|
||||
assert.equal(placeable({ ...branch, branches: ["feature", "main"] }).placed, "yes");
|
||||
// A seat older than the rule says no trunk: placed, as the controller lets it through.
|
||||
const { trunk, "on-trunk": _on, branches, ...older } = walkBuild;
|
||||
assert.equal(placeable(older).placed, "yes");
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
"include": [
|
||||
"pg.d.ts",
|
||||
"store.ts",
|
||||
"heard.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts",
|
||||
"prepare/index.ts"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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,6 +110,18 @@ type fakeController struct {
|
||||
onCheck func()
|
||||
// planned is what Plan answers instead of "builds app", when set.
|
||||
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 {
|
||||
@@ -384,6 +396,9 @@ type fakeForge struct {
|
||||
// open is what OpenPulls answers; openDown a forge that cannot say.
|
||||
open []OpenPull
|
||||
openDown bool
|
||||
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
|
||||
merges, comments, unlabels []string
|
||||
refuse error
|
||||
}
|
||||
|
||||
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
|
||||
@@ -637,3 +652,25 @@ func aWalk(id, repo, commit string, waits bool) Walk {
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Merge, Comment and Unlabel record what was asked (novox/hq issue 722); Merge answers refuse when set.
|
||||
func (f *fakeForge) Merge(owner, repo string, number int, head string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head))
|
||||
return f.refuse
|
||||
}
|
||||
|
||||
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeForge) Unlabel(owner, repo string, number, label int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.unlabels = append(f.unlabels, fmt.Sprintf("%s/%s#%d-%d", owner, repo, number, label))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ type Holder struct {
|
||||
leaveRefused map[string]string
|
||||
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
|
||||
tried owedTries
|
||||
// marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go).
|
||||
marks map[string]*marked
|
||||
}
|
||||
|
||||
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
|
||||
@@ -120,6 +122,7 @@ func (h *Holder) init() {
|
||||
h.askTried = map[string]time.Time{}
|
||||
h.walkAsked = map[string]time.Time{}
|
||||
h.unchecked = map[string]unchecked{}
|
||||
h.marks = map[string]*marked{}
|
||||
h.walks = map[string]seenWalk{}
|
||||
h.orphans = map[string]time.Time{}
|
||||
h.asking = map[string]bool{}
|
||||
@@ -593,9 +596,12 @@ func verdictOfState(s string) string {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, d := range h.deliveries {
|
||||
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
|
||||
d.ClosedUnmerged = true
|
||||
@@ -605,6 +611,16 @@ 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`.
|
||||
@@ -983,6 +999,12 @@ func (h *Holder) Tick() {
|
||||
for _, ask := range h.leaveStopped() {
|
||||
ask()
|
||||
}
|
||||
h.mu.Lock()
|
||||
asks := h.readyAsks(h.Now())
|
||||
h.mu.Unlock()
|
||||
for _, ask := range asks {
|
||||
ask()
|
||||
}
|
||||
}
|
||||
|
||||
// plan works out, under the lock, what to ask; each ask applies its own answer.
|
||||
|
||||
@@ -76,8 +76,9 @@ func run(h *Holder, l *listening) {
|
||||
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
|
||||
}
|
||||
}
|
||||
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
|
||||
if now.Sub(looked) >= 5*time.Minute {
|
||||
// Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a
|
||||
// head nobody checks, and the ready marks a merge is asked on.
|
||||
if now.Sub(looked) >= time.Minute {
|
||||
looked = now
|
||||
h.SeeOpenPulls()
|
||||
}
|
||||
|
||||
@@ -61,11 +61,13 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
}
|
||||
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
|
||||
want := []string{}
|
||||
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks"} {
|
||||
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks", "forget-checks"} {
|
||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||
}
|
||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
|
||||
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls",
|
||||
// The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off.
|
||||
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment", "gitea.gitea_api")
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ type Delivery struct {
|
||||
LetGo time.Time `json:"let_go"`
|
||||
LetGoWhy string `json:"let_go_why,omitempty"`
|
||||
|
||||
// Ready is what this owner did with its pull request's ready mark (novox/hq issue 722, ready.go).
|
||||
Ready *ReadyRun `json:"ready,omitempty"`
|
||||
|
||||
Transitions []Transition `json:"transitions,omitempty"`
|
||||
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
|
||||
// delivery is kept, retried until done.
|
||||
@@ -402,6 +405,9 @@ type WalkModule struct {
|
||||
Build string `json:"build,omitempty"`
|
||||
Gate *WalkGate `json:"gate,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.
|
||||
@@ -592,6 +598,9 @@ type WalkSeen struct {
|
||||
// Unchanged are the modules the walk said matched what runs, with why (novox/hq issue 611): not built, and
|
||||
// nothing for it to deliver.
|
||||
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.
|
||||
@@ -616,6 +625,12 @@ func (w Walk) Seen() *WalkSeen {
|
||||
if m != nil && (m.SentAt != nil || m.Gate != nil && m.Gate.Verdict == "passed") {
|
||||
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)
|
||||
s.MovedSaid = true
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -70,6 +71,8 @@ type Controller interface {
|
||||
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(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.
|
||||
@@ -88,6 +91,13 @@ type Forge interface {
|
||||
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
|
||||
// 347). Reads only.
|
||||
OpenPulls() ([]OpenPull, error)
|
||||
// Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq
|
||||
// issue 722): the one path every merge takes. Its refusal is the error, in its words.
|
||||
Merge(owner, repo string, number int, head string) error
|
||||
// Comment adds a comment to a pull request.
|
||||
Comment(owner, repo string, number int, body string) error
|
||||
// Unlabel takes a label off a pull request, by the label's id.
|
||||
Unlabel(owner, repo string, number, label int) error
|
||||
}
|
||||
|
||||
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
|
||||
@@ -271,6 +281,11 @@ func (c seatController) Deliver(walk, why string) error {
|
||||
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 {
|
||||
_, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by})
|
||||
return err
|
||||
@@ -384,6 +399,19 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (f toolForge) Merge(owner, repo string, number int, head string) error {
|
||||
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head})
|
||||
}
|
||||
|
||||
func (f toolForge) Comment(owner, repo string, number int, body string) error {
|
||||
return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
|
||||
}
|
||||
|
||||
func (f toolForge) Unlabel(owner, repo string, number, label int) error {
|
||||
return f.tool("gitea_api", map[string]any{"method": "DELETE",
|
||||
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)})
|
||||
}
|
||||
|
||||
func (f toolForge) OpenPulls() ([]OpenPull, error) {
|
||||
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Merge on ready (novox/hq issue 722).
|
||||
//
|
||||
// The operator, 2026-10-14: "the reviewer task should merge the request or mark the MR as ready to merge in any case".
|
||||
// Before, a reviewed pull request with green checks waited until a session called the forge's merge tool, and again
|
||||
// after every check the tool asked because the trunk had moved. Now the independent reviewer marks the pull request
|
||||
// ready at the head it approved — the forge label `mesh/ready` and a comment "ready at <full head sha>" (approving
|
||||
// through the forge is the operator's) — and this owner merges it, through the forge's own merge tool
|
||||
// (gitea_merge_pull_request), the one path every merge takes:
|
||||
//
|
||||
// - only at the head the mark names, and only while that is the pull request's head: a push after the mark clears
|
||||
// it — the label taken off, and said on the pull request — and the new head needs a mark of its own;
|
||||
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
|
||||
// again by the merge tool, which asks the check against the trunk's head itself (novox/hq ADR 0323 §5); the merge
|
||||
// is asked again once a minute, and lands on the passing verdict;
|
||||
// - a group member only at its turn in the group's order, and only once the group's composed check passed for its
|
||||
// members' heads as they are;
|
||||
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
|
||||
// controller raises as the condition `delivery.<id>.stalled`. So is a mark the merge tool refused for an hour.
|
||||
//
|
||||
// What this owner does with a mark is kept on the delivery (Ready), said in its log, and the merge's transitions name
|
||||
// it as their author.
|
||||
|
||||
// ReadyMark is a pull request's ready mark as the forge reads it (the gitea module's gitea_open_pulls): the head the
|
||||
// newest "ready at" comment names, who wrote it and when, and the label's id. Head is empty when the label is on and
|
||||
// no comment names a head.
|
||||
type ReadyMark struct {
|
||||
Head string `json:"head"`
|
||||
By string `json:"by,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
LabelID int `json:"label_id"`
|
||||
}
|
||||
|
||||
// ReadyLabel is the label that marks a pull request ready.
|
||||
const ReadyLabel = "mesh/ready"
|
||||
|
||||
// readyTryEvery is how often a marked head's merge is asked while the merge tool refuses it: a check it asked takes
|
||||
// minutes, and a refusal costs the forge a few reads.
|
||||
const readyTryEvery = time.Minute
|
||||
|
||||
// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a
|
||||
// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said.
|
||||
const readyRefusedFor = time.Hour
|
||||
|
||||
// ReadyRun is what this owner did with a delivery's ready mark: kept on the delivery and shown whole by `show`.
|
||||
type ReadyRun struct {
|
||||
// Head, By and MarkedAt are the mark: the head it names, who marked it, when.
|
||||
Head string `json:"head"`
|
||||
By string `json:"by,omitempty"`
|
||||
MarkedAt string `json:"marked_at,omitempty"`
|
||||
// Seen is when this owner first read the mark.
|
||||
Seen time.Time `json:"seen"`
|
||||
// Waits is what the merge waits on, when it waits: a verdict, the group's turn or its composed check.
|
||||
Waits string `json:"waits,omitempty"`
|
||||
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
|
||||
Tries int `json:"tries,omitempty"`
|
||||
LastTry time.Time `json:"last_try"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
// RefusedSince is when the merge tool began refusing it without a break.
|
||||
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
||||
// Merged is when the merge tool merged it.
|
||||
Merged *time.Time `json:"merged,omitempty"`
|
||||
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
|
||||
Failed string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// mergedBy is who a merge this owner made on a ready mark is said to be by, in its transitions.
|
||||
func (r *ReadyRun) mergedBy() string {
|
||||
if r == nil || r.Merged == nil {
|
||||
return ""
|
||||
}
|
||||
by := byOwner + ", on the ready mark at " + shortOf(r.Head, 12)
|
||||
if r.By != "" {
|
||||
by += " by " + r.By
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// marked is a pull request read with a ready mark.
|
||||
type marked struct {
|
||||
pull OpenPull
|
||||
// cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery.
|
||||
cleared time.Time
|
||||
}
|
||||
|
||||
// pullKey is a pull request as a key: owner/repo#number.
|
||||
func pullKey(p OpenPull) string {
|
||||
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
|
||||
}
|
||||
|
||||
// seeMarks keeps the open pull requests that carry a ready mark; under the lock. A pull request no longer open or
|
||||
// no longer marked drops out.
|
||||
func (h *Holder) seeMarks(open []OpenPull) {
|
||||
seen := map[string]*marked{}
|
||||
for _, p := range open {
|
||||
if p.Ready == nil || p.HeadSHA == "" {
|
||||
continue
|
||||
}
|
||||
k := pullKey(p)
|
||||
m := &marked{pull: p}
|
||||
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == p.Ready.Head &&
|
||||
was.pull.HeadSHA == p.HeadSHA {
|
||||
m.cleared = was.cleared
|
||||
}
|
||||
seen[k] = m
|
||||
}
|
||||
h.marks = seen
|
||||
}
|
||||
|
||||
// sameFull is whether a mark names a head: the whole sha, or — read from an older forge module — at least twelve
|
||||
// characters of it.
|
||||
func sameFull(mark, head string) bool {
|
||||
mark, head = strings.ToLower(mark), strings.ToLower(head)
|
||||
return len(mark) >= 12 && strings.HasPrefix(head, mark)
|
||||
}
|
||||
|
||||
// readyAsks works out, under the lock, what each ready mark needs: a mark cleared, a merge asked, a failure said.
|
||||
// The asking happens outside the lock.
|
||||
func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
var asks []func()
|
||||
for k, m := range h.marks {
|
||||
p, mark := m.pull, *m.pull.Ready
|
||||
repository := strings.ToLower(p.Owner + "/" + p.Repo)
|
||||
if !sameFull(mark.Head, p.HeadSHA) {
|
||||
if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery {
|
||||
continue
|
||||
}
|
||||
m.cleared = now
|
||||
asks = append(asks, h.clearMark(k, p, mark))
|
||||
continue
|
||||
}
|
||||
d := h.deliveries[IDOf(repository, p.HeadSHA)]
|
||||
if d == nil || d.State.Final() || d.MergedAs != "" {
|
||||
continue // not announced yet, or past its merge
|
||||
}
|
||||
if d.Ready == nil || d.Ready.Head != mark.Head {
|
||||
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
|
||||
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
|
||||
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
|
||||
h.keep(d)
|
||||
}
|
||||
r := d.Ready
|
||||
if r.Merged != nil {
|
||||
continue
|
||||
}
|
||||
if d.State == Rejected {
|
||||
if r.Failed == "" {
|
||||
r.Failed = "its check failed"
|
||||
if d.Check != nil {
|
||||
r.Failed += ": " + oneLine(d.Check.Summary)
|
||||
if j := d.Check.Judged(); j != "" {
|
||||
r.Failed += " (" + j + ")"
|
||||
}
|
||||
}
|
||||
r.Waits = ""
|
||||
h.Logf("[mesh-delivery] %s is marked ready and not merged: %s", d.ID, r.Failed)
|
||||
h.keep(d)
|
||||
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked "+
|
||||
"ready at %s, and %s. A new head, marked ready once reviewed, or a recheck that passes, merges it.",
|
||||
mark.Head, r.Failed)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.Failed = ""
|
||||
waits := h.readyWaits(d)
|
||||
if waits != r.Waits {
|
||||
r.Waits = waits
|
||||
if waits != "" {
|
||||
h.Logf("[mesh-delivery] %s is marked ready and waits: %s", d.ID, waits)
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
if waits != "" || (!r.LastTry.IsZero() && now.Sub(r.LastTry) < readyTryEvery) {
|
||||
continue
|
||||
}
|
||||
r.Tries++
|
||||
r.LastTry = now
|
||||
h.keep(d)
|
||||
asks = append(asks, h.askMerge(d.ID, p, mark.Head))
|
||||
}
|
||||
return asks
|
||||
}
|
||||
|
||||
// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is
|
||||
// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it.
|
||||
func (h *Holder) readyWaits(d *Delivery) string {
|
||||
switch {
|
||||
case d.State == Ready:
|
||||
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil:
|
||||
case d.State == Proposed && d.AskedAgain != nil:
|
||||
return "its check against the trunk's head is asked and has not answered"
|
||||
default:
|
||||
return "its check has not passed yet (" + string(d.State) + ")"
|
||||
}
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return h.groupTurnToMerge(d)
|
||||
}
|
||||
|
||||
// groupTurnToMerge is why a group member may not merge yet; empty at its turn: the group's composed check passed for
|
||||
// its members' heads as they are and is not out of date, and every member before it in the group's order merged.
|
||||
func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
g := h.groups[d.Group]
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
members := h.membersOf(g)
|
||||
if state, why := GroupState(g, members); state != "ready" && state != "delivering" {
|
||||
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
|
||||
}
|
||||
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
|
||||
!sameMembers(c.Commits, commitsOf(members)) {
|
||||
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
|
||||
}
|
||||
if !containsString(g.Order, d.ID) {
|
||||
return "its group " + g.ID + "'s order is not worked out for it yet"
|
||||
}
|
||||
for _, id := range g.Order {
|
||||
if id == d.ID {
|
||||
return ""
|
||||
}
|
||||
if o := h.deliveries[id]; o != nil && o.MergedAs == "" && !o.State.Final() {
|
||||
return "its group " + g.ID + "'s order merges " + id + " first"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// askMerge asks the forge's merge tool to merge a marked head, and keeps its answer.
|
||||
func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
|
||||
return func() {
|
||||
err := h.Forge.Merge(p.Owner, p.Repo, p.Number, head)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
if err == nil {
|
||||
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
|
||||
h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id,
|
||||
shortOf(head, 12))
|
||||
h.keep(d)
|
||||
return
|
||||
}
|
||||
answer := oneLine(err.Error())
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id,
|
||||
readyTryEvery, answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// clearMark takes a mark off a pull request whose head moved after it, and says so on it.
|
||||
func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
|
||||
return func() {
|
||||
err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID)
|
||||
if err == nil {
|
||||
err = h.Forge.Comment(p.Owner, p.Repo, p.Number, fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+
|
||||
"the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA))
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v",
|
||||
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err)
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
|
||||
shortOf(p.HeadSHA, 12))
|
||||
h.mu.Lock()
|
||||
delete(h.marks, k)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// sayOn comments on a delivery's pull request.
|
||||
func (h *Holder) sayOn(d *Delivery, body string) func() {
|
||||
owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID
|
||||
return func() {
|
||||
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyLines are the marked heads that will not merge: a check that failed at once, a merge refused past its bound;
|
||||
// as stalled lines, which the controller raises as conditions. Under the lock.
|
||||
func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r.Failed != "" && d.State == Rejected:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
|
||||
Bound: "0s", H2: "none: a new head marked ready, or a recheck that passes — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: %s", d.Repository, d.Number,
|
||||
shortOf(r.Head, 12), r.Failed)})
|
||||
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
|
||||
H2: "none: the merge is asked again each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository,
|
||||
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A pull request marked ready is merged by this owner** (novox/hq issue 722): before, a reviewed pull request with
|
||||
// green checks waited until a session called the merge tool, and again after every check the tool asked because the
|
||||
// trunk had moved. Each test below fails on a holder without the ready mark: nothing merged it.
|
||||
|
||||
const (
|
||||
readyHead = "1111111111111111111111111111111111111111"
|
||||
pushed = "2222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
// markedPull is an open pull request as gitea_open_pulls reads it, labelled mesh/ready at the head named.
|
||||
func markedPull(repo string, number int, head, markedAt string) OpenPull {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head, Labels: []string{ReadyLabel},
|
||||
Ready: &ReadyMark{Head: markedAt, By: "reviewer", At: "2026-10-14T19:00:00Z", LabelID: 31}}
|
||||
}
|
||||
|
||||
func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
|
||||
c := verdict(repo, number, sha, gate)
|
||||
c.Trunk = trunk
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("an unmarked pull request was merged: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
||||
t.Fatalf("a ready, marked pull request was merged %v; wanted once, at the marked head", w.forge.merges)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.By != "reviewer" || d.Ready.Tries != 1 {
|
||||
t.Fatalf("what was done with the mark is not kept: %+v", d.Ready)
|
||||
}
|
||||
// Its merge's transition names who merged it.
|
||||
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
|
||||
w.settleAll()
|
||||
d = w.delivery(id)
|
||||
var last Transition
|
||||
for _, tr := range d.Transitions {
|
||||
if tr.Event == EvMerged {
|
||||
last = tr
|
||||
}
|
||||
}
|
||||
if !strings.Contains(last.By, "ready mark at "+readyHead[:12]+" by reviewer") {
|
||||
t.Fatalf("the merge's transition does not name the mark: %+v", last)
|
||||
}
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merged again: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPushAfterTheMarkClearsItAndNothingIsMerged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a head pushed after the mark was merged: %v", w.forge.merges)
|
||||
}
|
||||
if len(w.forge.unlabels) != 1 || w.forge.unlabels[0] != "novox/app#7-31" {
|
||||
t.Fatalf("the stale mark's label was not taken off: %v", w.forge.unlabels)
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "cleared") ||
|
||||
!strings.Contains(w.forge.comments[0], pushed) {
|
||||
t.Fatalf("the cleared mark is not said on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedTrunkIsJudgedAgainByTheMergeToolAndMergedOnItsVerdict(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
// Another merge moves the trunk: its verdict is out of date, and it goes back to proposed.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
if w.state(id) != Proposed {
|
||||
t.Fatalf("out of date, it is %s", w.state(id))
|
||||
}
|
||||
// The merge tool asks the check against the trunk's head and refuses: nothing merged.
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked")
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the merge tool was not asked once to judge it again: %v", w.forge.merges)
|
||||
}
|
||||
// The tool's ask is the head announced again; it waits for that verdict, asking nothing.
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.later(5 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.delivery(id).Ready.Waits, "asked") {
|
||||
t.Fatalf("asked again while its check was on its way: %v (%+v)", w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
// It passes against the trunk's head: merged, without anyone asking.
|
||||
w.forge.refuse = nil
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
w.settleAll()
|
||||
if w.state(id) != Ready || len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
|
||||
t.Fatalf("on its passing verdict it was not merged: %s %v %+v", w.state(id), w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMarkedHeadWhoseCheckFailedIsNotMergedAndIsSaid(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(10 * time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a head whose check failed was merged: %v", w.forge.merges)
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "not merged") {
|
||||
t.Fatalf("the failure is not said once on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Rejected || !strings.Contains(lines[0].Says, "marked ready") {
|
||||
t.Fatalf("the failure is not a stalled line, which the controller raises as a condition: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMarkRefusedForAnHourIsSaid(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending")
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(30 * time.Second)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again within the minute: %v", w.forge.merges)
|
||||
}
|
||||
w.later(readyRefusedFor)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("not asked again after a minute: %v", w.forge.merges)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "refused it since") {
|
||||
t.Fatalf("a mark refused for an hour is not said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupMergesItsMarkedMembersInOrderOnlyOnceComposed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
|
||||
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
|
||||
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a member merged before its group's composed check passed: %v", w.forge.merges)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead {
|
||||
t.Fatalf("composed, the first member in the group's order was not merged alone: %v", w.forge.merges)
|
||||
}
|
||||
if waits := w.delivery(cat).Ready.Waits; !strings.Contains(waits, "merges "+ctl+" first") {
|
||||
t.Fatalf("the second member does not say it waits its turn: %q", waits)
|
||||
}
|
||||
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead {
|
||||
t.Fatalf("after the first member merged, the next was not: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
@@ -498,7 +498,12 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
|
||||
if why == "" {
|
||||
why = r.Guard
|
||||
}
|
||||
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: f.By}
|
||||
by := f.By
|
||||
if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" {
|
||||
// A merge this owner made on a ready mark names it as its author (novox/hq issue 722).
|
||||
by = d.Ready.mergedBy()
|
||||
}
|
||||
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
|
||||
d.State, d.Since = r.To, f.Now
|
||||
d.Transitions = append(d.Transitions, t)
|
||||
if len(d.Transitions) > keptTransitions {
|
||||
|
||||
@@ -72,6 +72,9 @@ type OpenPull struct {
|
||||
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
|
||||
// from an answered one.
|
||||
Checks map[string]HeadCheck `json:"checks,omitempty"`
|
||||
// Labels are its labels by name, and Ready its ready mark, when it carries one (novox/hq issue 722).
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Ready *ReadyMark `json:"ready,omitempty"`
|
||||
}
|
||||
|
||||
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
|
||||
@@ -201,6 +204,7 @@ func (h *Holder) SeeOpenPulls() {
|
||||
seen[id] = u
|
||||
}
|
||||
h.unchecked = seen
|
||||
h.seeMarks(open)
|
||||
}
|
||||
|
||||
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
|
||||
|
||||
@@ -249,6 +249,7 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
}
|
||||
out = append(out, h.withChecks(h.uncheckedLines(now))...)
|
||||
out = append(out, h.orphanLines(now)...)
|
||||
out = append(out, h.readyLines(now)...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -41,12 +41,16 @@
|
||||
"seat:mesh-controller.deliver",
|
||||
"seat:mesh-controller.delivery-stop",
|
||||
"seat:mesh-controller.delivery-walks",
|
||||
"seat:mesh-controller.forget-checks",
|
||||
"gitea.gitea_note_append",
|
||||
"gitea.gitea_delivery_view",
|
||||
"gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses",
|
||||
"gitea.gitea_contains",
|
||||
"gitea.gitea_open_pulls"
|
||||
"gitea.gitea_open_pulls",
|
||||
"gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment",
|
||||
"gitea.gitea_api"
|
||||
],
|
||||
"state": [
|
||||
"deliveries",
|
||||
|
||||
@@ -72,7 +72,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
|
||||
## Writing a record
|
||||
|
||||
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 **29 rules
|
||||
2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **30 rules
|
||||
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:
|
||||
- 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one
|
||||
@@ -81,7 +81,10 @@ 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
|
||||
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
|
||||
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`):
|
||||
- retired words from 2026-10-07 and daemons' lines from 2026-10-09 for a research effort; for a decision record
|
||||
@@ -122,7 +125,10 @@ 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
|
||||
`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
|
||||
Approve and Decline, both at the level approve, each bound to this version's digest. It is
|
||||
Approve and Decline, both at the level approve, each bound to this version's digest. The ask reads at a glance
|
||||
(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
|
||||
composed again. A record changed after it was asked finds the digests differ, and nothing is accepted. The
|
||||
warrant is kept with the record.
|
||||
|
||||
@@ -309,9 +309,11 @@ type Got struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
Reserved string `json:"reserved,omitempty"`
|
||||
NeedsOperat bool `json:"needs-operator,omitempty"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
Bound int `json:"bound"`
|
||||
Read string `json:"read,omitempty"`
|
||||
// Brief is what the operator's ask shows of it (issue 639).
|
||||
Brief *Brief `json:"brief,omitempty"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
Bound int `json:"bound"`
|
||||
Read string `json:"read,omitempty"`
|
||||
|
||||
// Reconstructed, Amended and Touches are the record's fields of those names; Fields, any other it holds as
|
||||
// written (hq issue 487).
|
||||
@@ -349,7 +351,7 @@ func (k *KB) Get(a GetAsk) (*Got, error) {
|
||||
bound := boundOf(a.Bound)
|
||||
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,
|
||||
NeedsOperat: r.NeedsOperator, Bound: bound, Since: k.since(ctx, key),
|
||||
NeedsOperat: r.NeedsOperator, Brief: r.Brief, Bound: bound, Since: k.since(ctx, key),
|
||||
Reconstructed: r.Reconstructed, Amended: r.Amended, Touches: r.Touches, Fields: r.Extra}
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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,6 +238,8 @@ type ProposeAsk struct {
|
||||
Links []LinkAsk
|
||||
Homes []string
|
||||
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 string
|
||||
// Insight, for an accepted record, is what stood before: the proposal is a progressive insight.
|
||||
@@ -280,7 +282,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),
|
||||
Status: StatusProposed, Date: strings.TrimSpace(a.Date), Deciders: a.Deciders, Effort: a.Status,
|
||||
NeedsOperator: a.NeedsOperator, Sections: a.Sections, Homes: a.Homes, Author: caller}
|
||||
NeedsOperator: a.NeedsOperator, Brief: a.Brief, Sections: a.Sections, Homes: a.Homes, Author: caller}
|
||||
if r.Date == "" {
|
||||
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 " +
|
||||
"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 " +
|
||||
"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 29 rules that judge one record or its prose — a " +
|
||||
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. 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 " +
|
||||
"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 " +
|
||||
"stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
|
||||
@@ -937,13 +937,19 @@ 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"}}},
|
||||
"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"),
|
||||
"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"),
|
||||
"why": text("one line: why this version"),
|
||||
"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"},
|
||||
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
|
||||
"why": text("one line: why this version"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections",
|
||||
"links", "homes", "needs-operator", "status", "insight", "why")
|
||||
"links", "homes", "needs-operator", "brief", "status", "insight", "why")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -977,6 +983,12 @@ 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 {
|
||||
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)
|
||||
}},
|
||||
{Name: Seat + ".import",
|
||||
|
||||
@@ -121,6 +121,12 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
|
||||
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
|
||||
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"] {
|
||||
t.Fatal("watch writes another status file than the health tool reads")
|
||||
}
|
||||
|
||||
@@ -84,9 +84,48 @@ func fingerprint(digest string) string {
|
||||
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
|
||||
// again to hold the warrant to it.
|
||||
// again to hold the warrant to it — in the form it was asked in, so an ask made before a form changed is still
|
||||
// held to the words the operator was shown.
|
||||
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()
|
||||
headline := "Accept " + name + "?"
|
||||
var b strings.Builder
|
||||
@@ -111,9 +150,15 @@ func composeAsk(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()))
|
||||
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)}
|
||||
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
|
||||
}
|
||||
|
||||
// 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 }{
|
||||
{OptionApprove, "Approve", "the record is accepted and its clauses are in force"},
|
||||
{OptionDecline, "Decline", "the record stays proposed; nothing of it is in force"},
|
||||
@@ -157,7 +202,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
|
||||
if k.Asks == nil {
|
||||
return unasked("the operator cannot be asked from here")
|
||||
}
|
||||
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting}
|
||||
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting, Form: AskGlance}
|
||||
ask := composeAsk(w, oa)
|
||||
if err := ask.Check(k.now()); err != nil {
|
||||
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
|
||||
|
||||
@@ -248,6 +248,9 @@ type OperatorAsk struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
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 string `json:"said"`
|
||||
Warrant string `json:"warrant,omitempty"`
|
||||
@@ -291,11 +294,14 @@ type Record struct {
|
||||
// written; what is one is a touches link.
|
||||
Touches []string `json:"touches,omitempty"`
|
||||
// Effort is the status a proposed research effort takes when it is accepted.
|
||||
Effort string `json:"effort,omitempty"`
|
||||
NeedsOperator bool `json:"needs-operator,omitempty"`
|
||||
Clauses []Clause `json:"clauses,omitempty"`
|
||||
Links []Link `json:"links,omitempty"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
Effort string `json:"effort,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"`
|
||||
Links []Link `json:"links,omitempty"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
// Homes are the documents of hq that cite the record (a design, a playbook), while those live in hq.
|
||||
Homes []string `json:"homes,omitempty"`
|
||||
// Extra holds the fields of an imported record this slice has no field of its own for, as written.
|
||||
@@ -329,6 +335,25 @@ type Insight struct {
|
||||
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
|
||||
// its review. The accepted version stays the one served until it is accepted.
|
||||
type PendingState struct {
|
||||
@@ -442,6 +467,12 @@ func Render(r *Record) string {
|
||||
if r.NeedsOperator {
|
||||
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, ", "))
|
||||
keys := make([]string, 0, len(r.Extra))
|
||||
for k := range r.Extra {
|
||||
|
||||
@@ -18,6 +18,10 @@ func proposed(t *testing.T, w *world, author string, needsOperator bool) int {
|
||||
n := w.reserved(t, author)
|
||||
p := valid(n)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -245,7 +249,8 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
|
||||
}
|
||||
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 ||
|
||||
!strings.Contains(ask.Explanation, "the knowledge base's own review") {
|
||||
!strings.Contains(ask.Details, "the knowledge base's own review") || ask.Headline != "Merges land after ninety seconds of quiet" ||
|
||||
asks.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") {
|
||||
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.
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
// The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5).
|
||||
//
|
||||
// **29 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every
|
||||
// **30 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
|
||||
// judge one record, moved to the write: 25 on its structure, 4 on its prose. Each has an id, said in the refusal,
|
||||
// judge one record, moved to the write: 25 on its structure, 4 on its prose; and the brief a record that needs
|
||||
// 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).
|
||||
//
|
||||
// **4 rules judge the whole record set** (wholeSetRules) and run after every acceptance, and in the check
|
||||
@@ -15,6 +16,8 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Rule is one rule that judges one record: its id and what it holds.
|
||||
@@ -25,7 +28,7 @@ type Rule struct {
|
||||
From string
|
||||
}
|
||||
|
||||
// PerRecordRules are the 29, in the order they are judged.
|
||||
// PerRecordRules are the 30, in the order they are judged.
|
||||
var PerRecordRules = []Rule{
|
||||
{"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)"},
|
||||
@@ -56,6 +59,7 @@ var PerRecordRules = []Rule{
|
||||
{"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"},
|
||||
{"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.
|
||||
@@ -116,7 +120,7 @@ func allText(r *Record) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// JudgeRecord holds a proposed record to the 29 rules. held is the entry its number holds (a reservation, the
|
||||
// JudgeRecord holds a proposed record to the 30 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.
|
||||
func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal {
|
||||
var out []Refusal
|
||||
@@ -349,6 +353,11 @@ 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)
|
||||
}
|
||||
}
|
||||
if r.NeedsOperator || r.Brief != nil {
|
||||
for _, why := range briefProblems(r.Brief) {
|
||||
refuse("brief", "%s", why)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -597,6 +606,7 @@ var RuleFrom = map[string]string{
|
||||
"consequences": "2026-10-11",
|
||||
"checked": "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
|
||||
@@ -615,3 +625,35 @@ func Holds(rule, kind, date string) bool {
|
||||
}
|
||||
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,6 +90,9 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) {
|
||||
{"insight-form", func(p *ProposeAsk, w *world, t *testing.T) {
|
||||
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{}
|
||||
for _, c := range cases {
|
||||
@@ -100,8 +103,8 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) {
|
||||
t.Errorf("the rule %s has no case", r.ID)
|
||||
}
|
||||
}
|
||||
if len(PerRecordRules) != 29 {
|
||||
t.Errorf("%d per-record rules; ADR 0290 counts 29", len(PerRecordRules))
|
||||
if len(PerRecordRules) != 30 {
|
||||
t.Errorf("%d per-record rules; ADR 0290 counts 29, and issue 639 adds the brief", len(PerRecordRules))
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.rule, func(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-knowledge
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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.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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "knowledge_health",
|
||||
"needs": "postgres-database",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# mesh-mail — the operator's mail accounts, watched and said on the bus
|
||||
|
||||
One module holds the operator's mail accounts, watches them, and says each change of a message on the bus for the
|
||||
modules the operator grants it to (novox/hq ADR 0359, research 049 document 02). One instance, on the control-node.
|
||||
This is the first stage: accounts of the kind `imap`, read-only. Nothing here flags, moves, deletes, drafts or sends.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Watches.** For each account it keeps one connection: IDLE on the inbox, ended and begun again every two minutes
|
||||
(which proves the server still answers), and a look at every other folder every five minutes. Folders are opened
|
||||
read-only and bodies are peeked, so watching never marks a message seen.
|
||||
- **Indexes.** One row per message and folder in its own database (`postgres-database`): metadata, flags, the text
|
||||
for search, and the attachments' references. Each attachment is kept once, by its SHA-256, in its bucket
|
||||
(`s3-bucket`, from `minio`).
|
||||
- **Says each change.** A message added, its flags changed, or removed is said as the event
|
||||
`mail.<account>.message.<added|changed|removed>`: account, folder, uid, message id, date, sender, recipients,
|
||||
subject, size, flags and attachment references; never the body, never an attachment's bytes. The events go to the
|
||||
module's own stream `MAIL`, kept seven days, never to the shared EVENTS stream (novox/hq ADR 0363); a module hears an
|
||||
account's part only when its `consumes` names mesh-mail and the setting `grants` names it for that account. The first look at a
|
||||
folder indexes what is there and says nothing: the bus hears what changed since the watch began. The keeper writes
|
||||
each event in the same step as the change it says; the bundle says it through the runtime and removes it once the
|
||||
bus took it.
|
||||
|
||||
## Its settings, both trusted
|
||||
|
||||
`accounts` — per account name (a lower-case token, which names the account on the bus and its secret):
|
||||
|
||||
```json
|
||||
{"mesh": {"kind": "imap", "address": "<the address>",
|
||||
"incoming": {"host": "<the IMAP server>", "port": 993, "security": "tls"},
|
||||
"outgoing": {"host": "<the SMTP server>", "port": 465, "security": "tls"},
|
||||
"folders": ["optional: the folders watched besides the inbox; every folder when left out"]}}
|
||||
```
|
||||
|
||||
`outgoing` is read and never used in this stage. A second `imap` account on another server is one more entry and one
|
||||
more password; no code changes. `user` signs in under another name than the address.
|
||||
|
||||
`grants` — per account, who may read its mail: a module's name (on every machine), `<machine>.<module>`, or
|
||||
`person.<name>`. Nobody by default. It restricts the stream `MAIL` on the bus (a person's entry grants nothing there)
|
||||
and the tools `mail_search` and `mail_read`.
|
||||
|
||||
The password of an account is the own secret `mail-credential-<account>`, given at the desk; the mesh never makes one.
|
||||
|
||||
## Its tools
|
||||
|
||||
| Tool | Answers |
|
||||
|---|---|
|
||||
| `mail_accounts` | each account's kind, state and why, when its server last answered, its folders; the address only to a caller granted it |
|
||||
| `mail_search` | messages of the accounts the caller is granted, by every word of a query, newest first, no body |
|
||||
| `mail_read` | one message's metadata and text, or one attachment's bytes, to a caller granted its account |
|
||||
| `mail_health` | healthy; unhealthy naming the account and why (connection down, or no answer for five minutes); waits naming `mail-credential-<account>` while its password is not given |
|
||||
|
||||
The caller is the runtime's word, never an argument.
|
||||
|
||||
## Its processes
|
||||
|
||||
`prepare` brings the index's schema to this version's (numbered migrations, and `schema.sql` for a fresh database).
|
||||
`keep` watches the accounts and is judged by its unit. `watch` judges every minute and is judged by `mail_health`.
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
// The operator's mail accounts and who may read them (novox/hq ADR 0359 §1, §4, §5): two trusted settings, each a
|
||||
// file the mesh writes from the module's settings and changes only on the operator's approval. `accounts` says which
|
||||
// server each account is on and how to sign in (never a credential); `grants` says, per account, which callers may
|
||||
// read its mail. The password of an account is the module's own secret mail-credential-<account>, given at the desk
|
||||
// (ADR 0283 §6), at the path the secrets directory and that name make.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The kinds of account an adapter exists for. Only `imap` is built (ADR 0359 §5); `imap-oauth` and `graph` are later
|
||||
// decisions (§7), and an account of a kind not listed here is refused by name.
|
||||
const KindIMAP = "imap"
|
||||
|
||||
// Kinds is every kind this version has an adapter for.
|
||||
var Kinds = []string{KindIMAP}
|
||||
|
||||
// SecretFamily is the prefix of every account's own secret: mail-credential-<account> (ADR 0283 §6).
|
||||
const SecretFamily = "mail-credential-"
|
||||
|
||||
// accountName is what an account may be called: a member name of the secret family (ADR 0283 §6) and one token of a
|
||||
// bus subject, so never a dot, a slash or an upper-case letter.
|
||||
var accountName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}$`)
|
||||
|
||||
// Server is where an account's mail is reached.
|
||||
type Server struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
// Security is tls (TLS from the first byte) or starttls (plain, then upgraded before signing in). Nothing
|
||||
// signs in over a connection that is not encrypted.
|
||||
Security string `json:"security"`
|
||||
}
|
||||
|
||||
// Address is host:port.
|
||||
func (s Server) Address() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) }
|
||||
|
||||
// Account is one of the operator's mail accounts, as the setting `accounts` declares it.
|
||||
type Account struct {
|
||||
// Name is the key the account has in the setting; set when the setting is read.
|
||||
Name string `json:"-"`
|
||||
Kind string `json:"kind"`
|
||||
// Address is the account's mail address, said only to a caller granted the account.
|
||||
Address string `json:"address"`
|
||||
// User is the name it signs in with; the address when empty.
|
||||
User string `json:"user,omitempty"`
|
||||
Incoming Server `json:"incoming"`
|
||||
Outgoing *Server `json:"outgoing,omitempty"`
|
||||
// Folders are the folders watched besides the inbox; every folder the server lists when empty.
|
||||
Folders []string `json:"folders,omitempty"`
|
||||
}
|
||||
|
||||
// SignInAs is the name the account signs in with.
|
||||
func (a Account) SignInAs() string {
|
||||
if a.User != "" {
|
||||
return a.User
|
||||
}
|
||||
return a.Address
|
||||
}
|
||||
|
||||
// Secret is the name of the account's own secret.
|
||||
func (a Account) Secret() string { return SecretFamily + a.Name }
|
||||
|
||||
// AccountsFile is the shape of the trusted setting `accounts`, as the mesh writes it.
|
||||
type AccountsFile struct {
|
||||
Accounts map[string]Account `json:"accounts"`
|
||||
}
|
||||
|
||||
// ReadAccounts reads the accounts setting, refusing it whole when any account in it cannot be watched: an account
|
||||
// half-understood would be watched wrongly, and the operator approved the setting as one.
|
||||
func ReadAccounts(path string) ([]Account, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("the module was started without its accounts setting (MESH_MAIL_ACCOUNTS)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the accounts setting cannot be read: %w", err)
|
||||
}
|
||||
return ParseAccounts(raw)
|
||||
}
|
||||
|
||||
// ParseAccounts reads the accounts setting's content.
|
||||
func ParseAccounts(raw []byte) ([]Account, error) {
|
||||
var f AccountsFile
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("the accounts setting is not the module's: %v", err)
|
||||
}
|
||||
var out []Account
|
||||
var problems []string
|
||||
for name, a := range f.Accounts {
|
||||
a.Name = name
|
||||
if err := a.Check(); err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, errors.New("the accounts setting is refused: " + strings.Join(problems, "; "))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Check says why an account cannot be watched, or nil.
|
||||
func (a Account) Check() error {
|
||||
say := func(format string, args ...any) error {
|
||||
return fmt.Errorf("the account %q: %s", a.Name, fmt.Sprintf(format, args...))
|
||||
}
|
||||
if !accountName.MatchString(a.Name) {
|
||||
return say("is not an account's name: a lower-case letter or digit, then lower-case letters, digits and hyphens, at most 40")
|
||||
}
|
||||
known := false
|
||||
for _, k := range Kinds {
|
||||
known = known || a.Kind == k
|
||||
}
|
||||
if !known {
|
||||
return say("is of the kind %q, and this version watches only %s", a.Kind, strings.Join(Kinds, ", "))
|
||||
}
|
||||
if !strings.Contains(a.Address, "@") {
|
||||
return say("has no mail address")
|
||||
}
|
||||
if err := a.Incoming.check("incoming"); err != nil {
|
||||
return say("%v", err)
|
||||
}
|
||||
if a.Outgoing != nil {
|
||||
if err := a.Outgoing.check("outgoing"); err != nil {
|
||||
return say("%v", err)
|
||||
}
|
||||
}
|
||||
for _, f := range a.Folders {
|
||||
if strings.TrimSpace(f) == "" || strings.EqualFold(f, "INBOX") {
|
||||
return say("lists the folder %q, and the inbox is always watched", f)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Server) check(which string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(s.Host) == "" || strings.ContainsAny(s.Host, " /:"):
|
||||
return fmt.Errorf("its %s server has no host name", which)
|
||||
case s.Port <= 0 || s.Port > 65535:
|
||||
return fmt.Errorf("its %s server's port %d is not a port", which, s.Port)
|
||||
case s.Security != "tls" && s.Security != "starttls":
|
||||
return fmt.Errorf("its %s server's security is %q, and only tls or starttls signs in", which, s.Security)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecretPath is where the account's own secret is placed in the secrets directory, as the manifest declares the
|
||||
// family: <dir>/mail-credential-<account>.secret.
|
||||
func SecretPath(dir string, a Account) string { return filepath.Join(dir, a.Secret()+".secret") }
|
||||
|
||||
// ReadSecret is the account's password, or given false when nobody gave it yet: the mesh never makes a member of the
|
||||
// family, so a member not given is no file (ADR 0283 §6).
|
||||
func ReadSecret(dir string, a Account) (string, bool, error) {
|
||||
raw, err := os.ReadFile(SecretPath(dir, a))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
pw := strings.TrimRight(string(raw), "\r\n")
|
||||
if pw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return pw, true, nil
|
||||
}
|
||||
|
||||
// SecretGiven says whether the account's own secret was given.
|
||||
func SecretGiven(dir string, a Account) bool {
|
||||
_, given, err := ReadSecret(dir, a)
|
||||
if err != nil {
|
||||
// A file there that cannot be read was given: the keeper says what is wrong with it.
|
||||
_, statErr := os.Stat(SecretPath(dir, a))
|
||||
return statErr == nil
|
||||
}
|
||||
return given
|
||||
}
|
||||
|
||||
// ---- grants ------------------------------------------------------------------------------------------------------
|
||||
|
||||
// Grants is the trusted setting `grants`: per account, the callers that may read its mail (ADR 0359 §4). An entry is a
|
||||
// module's name (granted wherever it runs), a module on one machine (`<machine>.<module>`) or a person
|
||||
// (`person.<name>`), as the runtime names callers.
|
||||
type Grants map[string][]string
|
||||
|
||||
// GrantsFile is the shape of the setting as the mesh writes it.
|
||||
type GrantsFile struct {
|
||||
Grants Grants `json:"grants"`
|
||||
}
|
||||
|
||||
// ReadGrants reads the grants setting. A setting that cannot be read grants nothing, and says so.
|
||||
func ReadGrants(path string) (Grants, error) {
|
||||
if path == "" {
|
||||
return Grants{}, errors.New("the module was started without its grants setting (MESH_MAIL_GRANTS)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Grants{}, fmt.Errorf("the grants setting cannot be read: %w", err)
|
||||
}
|
||||
var f GrantsFile
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return Grants{}, fmt.Errorf("the grants setting is not the module's: %v", err)
|
||||
}
|
||||
if f.Grants == nil {
|
||||
f.Grants = Grants{}
|
||||
}
|
||||
return f.Grants, nil
|
||||
}
|
||||
|
||||
// Unproven is the caller the runtime proved nobody to be; it is granted nothing.
|
||||
const Unproven = "unproven"
|
||||
|
||||
// Allows says whether the caller may read the account's mail. Nobody is granted an account by default, an unproven
|
||||
// caller never, and a wildcard is no entry.
|
||||
func (g Grants) Allows(caller, account string) bool {
|
||||
if caller == "" || caller == Unproven {
|
||||
return false
|
||||
}
|
||||
for _, entry := range g[account] {
|
||||
if entry == "" || strings.ContainsAny(entry, "*>") {
|
||||
continue
|
||||
}
|
||||
if entry == caller {
|
||||
return true
|
||||
}
|
||||
// A module's name alone grants it on every machine: the caller is <machine>.<module>.
|
||||
if !strings.Contains(entry, ".") {
|
||||
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Granted is the accounts, of those given, the caller may read.
|
||||
func (g Grants) Granted(caller string, accounts []Account) []string {
|
||||
var out []string
|
||||
for _, a := range accounts {
|
||||
if g.Allows(caller, a.Name) {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The first stage's account, as the operator sets the setting `accounts` (ADR 0359 §5): its address and its server,
|
||||
// never a credential; its password is the own secret mail-credential-mesh.
|
||||
const stageOne = `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
|
||||
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}}}}`
|
||||
|
||||
func TestTheFirstStagesAccountIsReadAndItsSecretNamed(t *testing.T) {
|
||||
got, err := ParseAccounts([]byte(stageOne))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "mesh" || got[0].Kind != KindIMAP || got[0].SignInAs() != "jschoubben@novox.be" ||
|
||||
got[0].Incoming.Address() != "mail.novox.be:993" || got[0].Secret() != "mail-credential-mesh" || got[0].Outgoing != nil {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if p := SecretPath("/state", got[0]); p != "/state/mail-credential-mesh.secret" {
|
||||
t.Fatalf("the secret is placed at %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
// A second account of the kind imap on another server — Gmail with an app password, another member of the family —
|
||||
// is configuration only: no code changes for it. Each account may also say its outgoing server, which this stage
|
||||
// reads and never uses (it sends nothing).
|
||||
func TestASecondIMAPAccountIsConfigurationOnly(t *testing.T) {
|
||||
got, err := ParseAccounts([]byte(`{"accounts": {
|
||||
"mesh": {"kind": "imap", "address": "jschoubben@novox.be", "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}},
|
||||
"gmail": {"kind": "imap", "address": "someone@gmail.com", "incoming": {"host": "imap.gmail.com", "port": 993, "security": "tls"},
|
||||
"outgoing": {"host": "smtp.gmail.com", "port": 465, "security": "tls"}, "folders": ["[Gmail]/Sent Mail"]}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name != "gmail" || got[0].Secret() != "mail-credential-gmail" || got[0].Outgoing.Address() != "smtp.gmail.com:465" ||
|
||||
!reflect.DeepEqual(got[0].Folders, []string{"[Gmail]/Sent Mail"}) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountThatCannotBeWatchedRefusesTheSetting(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"a later kind": `{"accounts": {"work": {"kind": "graph", "address": "a@b.c", "incoming": {"host": "h", "port": 1, "security": "tls"}}}}`,
|
||||
"no encryption": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 143, "security": "none"}}}}`,
|
||||
"a name with a dot": `{"accounts": {"my.box": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
|
||||
"a password in it": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "password": "p", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
|
||||
"no address": `{"accounts": {"x": {"kind": "imap", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
|
||||
"the inbox listed": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "folders": ["INBOX"], "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
|
||||
"a bad outgoing one": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 993, "security": "tls"}, "outgoing": {"host": "", "port": 465, "security": "tls"}}}}`,
|
||||
} {
|
||||
if _, err := ParseAccounts([]byte(raw)); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
if got, err := ParseAccounts([]byte(`{"accounts": {}}`)); err != nil || len(got) != 0 {
|
||||
t.Fatalf("the manifest's default: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Grants (ADR 0359 §4): nobody by default; a module's name grants it wherever it runs; a module on one machine, or a
|
||||
// person, by its full name; never the unproven, never by a wildcard, never one account for another.
|
||||
func TestGrantsAreByAccountAndByProvenCaller(t *testing.T) {
|
||||
g := Grants{"mesh": {"triage", "novox.desk", "person.jochen", "*"}}
|
||||
for caller, want := range map[string]bool{
|
||||
"novox.triage": true, "shanks.triage": true, "novox.desk": true, "g14.desk": false, "person.jochen": true,
|
||||
"person.triage": false, "novox.other": false, Unproven: false, "": false, "novox.*": false,
|
||||
} {
|
||||
if got := g.Allows(caller, "mesh"); got != want {
|
||||
t.Errorf("%s: %v", caller, got)
|
||||
}
|
||||
}
|
||||
if g.Allows("novox.triage", "gmail") || (Grants{}).Allows("novox.triage", "mesh") {
|
||||
t.Fatal("a grant reached an account it does not name")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "grants.json")
|
||||
_ = os.WriteFile(path, []byte(`{"grants": {"mesh": ["triage"]}}`), 0o600)
|
||||
read, err := ReadGrants(path)
|
||||
if err != nil || !read.Allows("novox.triage", "mesh") {
|
||||
t.Fatalf("%v %v", read, err)
|
||||
}
|
||||
_ = os.WriteFile(path, []byte(`{"grants": {"mesh": "triage"}}`), 0o600)
|
||||
if read, err := ReadGrants(path); err == nil || len(read) != 0 {
|
||||
t.Fatal("a grants setting it cannot read granted something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecretGivenEmptyIsNotGiven(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := testAccount()
|
||||
if SecretGiven(dir, a) {
|
||||
t.Fatal("given before anything was written")
|
||||
}
|
||||
_ = os.WriteFile(SecretPath(dir, a), []byte("\n"), 0o600)
|
||||
if SecretGiven(dir, a) {
|
||||
t.Fatal("an empty file is a password")
|
||||
}
|
||||
_ = os.WriteFile(SecretPath(dir, a), []byte("pw\n"), 0o600)
|
||||
if pw, given, _ := ReadSecret(dir, a); !given || pw != "pw" || strings.Contains(pw, "\n") {
|
||||
t.Fatalf("%q %v", pw, given)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
// How mesh-mail is (novox/hq ADR 0359 §6). Its keeper's liveness is its unit's; its watching is judged here, by the
|
||||
// watch process, once a minute, and left in a status file the bundle's tool `mail_health` reads:
|
||||
//
|
||||
// - waits, naming the own secret mail-credential-<account>, for an account whose password nobody gave yet (ADR 0283),
|
||||
// so the condition is needs-operator and the gate passes;
|
||||
// - unhealthy, naming the account and why, when its connection is down or its server has not answered for
|
||||
// SilentAfter (five minutes) — an IDLE that went silent;
|
||||
// - healthy otherwise.
|
||||
//
|
||||
// A fault beside a wait is unhealthy and says both.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// HealthTool is the module's tool the node-engine asks how it is.
|
||||
const HealthTool = "mail_health"
|
||||
|
||||
// WatchEvery is how often the watch judges; StatusStale how old its verdict may be before the tool says it stopped.
|
||||
const (
|
||||
WatchEvery = time.Minute
|
||||
StatusStale = 3 * WatchEvery
|
||||
// SignInGrace is how long a password just given may wait for its keeper to sign in before that is a fault.
|
||||
SignInGrace = 3 * time.Minute
|
||||
)
|
||||
|
||||
// Judge is the module's health from what the watch reads: the accounts setting (or why it cannot be read), whether the
|
||||
// index serves, which passwords were given, and each account's state as its keeper said it.
|
||||
func Judge(accounts []Account, accountsErr, readyErr error, given func(Account) bool, states map[string]AccountState, now time.Time) health.Answer {
|
||||
if accountsErr != nil {
|
||||
return health.Unhealthy(accountsErr.Error())
|
||||
}
|
||||
var problems, fine []string
|
||||
var waits []health.Wait
|
||||
if readyErr != nil {
|
||||
problems = append(problems, "the index cannot serve: "+readyErr.Error())
|
||||
}
|
||||
for _, a := range accounts {
|
||||
if !given(a) {
|
||||
waits = append(waits, health.Wait{
|
||||
Part: "the mail account " + a.Name,
|
||||
Secret: a.Secret(),
|
||||
What: "the password of the mail account " + a.Name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if readyErr != nil {
|
||||
continue
|
||||
}
|
||||
st, said := states[a.Name]
|
||||
switch {
|
||||
case !said:
|
||||
problems = append(problems, fmt.Sprintf("the account %s: its keeper has said nothing of it", a.Name))
|
||||
case st.State == AccountWaits:
|
||||
if now.Sub(st.Said) > SignInGrace {
|
||||
problems = append(problems, fmt.Sprintf("the account %s: its password was given, and its keeper has not signed in for %s",
|
||||
a.Name, now.Sub(st.Said).Round(time.Second)))
|
||||
} else {
|
||||
fine = append(fine, a.Name+" is signing in")
|
||||
}
|
||||
case st.State == AccountFailing:
|
||||
problems = append(problems, fmt.Sprintf("the account %s is not watched: %s", a.Name, st.Why))
|
||||
case st.Heard.IsZero():
|
||||
problems = append(problems, fmt.Sprintf("the account %s: its server has not answered since its keeper started", a.Name))
|
||||
case now.Sub(st.Heard) > SilentAfter:
|
||||
problems = append(problems, fmt.Sprintf("the account %s: its server has not answered for %s (its IDLE on the inbox is silent)",
|
||||
a.Name, now.Sub(st.Heard).Round(time.Second)))
|
||||
default:
|
||||
fine = append(fine, fmt.Sprintf("%s is %s (%s)", a.Name, st.State, st.Why))
|
||||
}
|
||||
}
|
||||
waitSaid := ""
|
||||
if len(waits) > 0 {
|
||||
var names []string
|
||||
for _, w := range waits {
|
||||
names = append(names, w.Secret)
|
||||
}
|
||||
waitSaid = "waits for its password: " + strings.Join(names, ", ")
|
||||
}
|
||||
switch {
|
||||
case len(problems) > 0:
|
||||
why := strings.Join(problems, "; ")
|
||||
if waitSaid != "" {
|
||||
why += "; and it " + waitSaid
|
||||
}
|
||||
return health.Unhealthy(why, waits...)
|
||||
case len(waits) > 0:
|
||||
why := "nothing is wrong, and it " + waitSaid
|
||||
if len(fine) > 0 {
|
||||
why += "; " + strings.Join(fine, "; ")
|
||||
}
|
||||
return health.Waiting(why, waits...)
|
||||
case len(accounts) == 0:
|
||||
return health.Healthy("no mail account is declared, so nothing is watched")
|
||||
}
|
||||
return health.Healthy(fmt.Sprintf("%d account(s) watched: %s", len(accounts), strings.Join(fine, "; ")))
|
||||
}
|
||||
|
||||
// Status is the watch's verdict as it leaves it: the answer, and when it looked.
|
||||
type Status struct {
|
||||
health.Answer
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
func writeStatus(path string, s Status) error {
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".next"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// ReadStatus is the watch's last verdict, as the tool answers it: unhealthy when there is none, when it cannot be read,
|
||||
// or when it is older than StatusStale.
|
||||
func ReadStatus(path string, now time.Time) health.Answer {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return health.Unhealthy("the watch has left no verdict yet: " + err.Error())
|
||||
}
|
||||
var s Status
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return health.Unhealthy("the watch's verdict cannot be read")
|
||||
}
|
||||
if age := now.Sub(s.At); age > StatusStale {
|
||||
return health.Unhealthy(fmt.Sprintf("the watch has not looked for %s (its last verdict: %s)", age.Round(time.Second), s.Why))
|
||||
}
|
||||
// Read through the SDK's reader, so a wait it cannot check is never excused.
|
||||
a, err := health.Read(mustJSON(s.Answer))
|
||||
if err != nil {
|
||||
return health.Unhealthy("the watch's verdict cannot be read: " + err.Error())
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
raw, _ := json.Marshal(v)
|
||||
return raw
|
||||
}
|
||||
|
||||
// watchOnce is one look: what the watch reads, judged.
|
||||
func watchOnce(ctx context.Context, env Env, open func(context.Context) (Store, func(), error), now time.Time) health.Answer {
|
||||
accounts, accountsErr := ReadAccounts(env.Accounts)
|
||||
var readyErr error
|
||||
states := map[string]AccountState{}
|
||||
st, done, err := open(ctx)
|
||||
if err != nil {
|
||||
readyErr = err
|
||||
} else {
|
||||
defer done()
|
||||
if readyErr = st.Ready(ctx); readyErr == nil {
|
||||
if states, err = st.States(ctx); err != nil {
|
||||
readyErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return Judge(accounts, accountsErr, readyErr, func(a Account) bool { return SecretGiven(env.Secrets, a) }, states, now)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
var judgedAt = time.Date(2026, 10, 14, 18, 0, 0, 0, time.UTC)
|
||||
|
||||
func given(names ...string) func(Account) bool {
|
||||
return func(a Account) bool { return contains(names, a.Name) }
|
||||
}
|
||||
|
||||
// Before the password is given, mail_health answers waits, naming the secret, which the node-engine reads as a wait
|
||||
// the controller can excuse (ADR 0283 §1, §3): the family mail-credential-* is declared issued outside the mesh.
|
||||
func TestItWaitsNamingTheSecretUntilThePasswordIsGiven(t *testing.T) {
|
||||
a := Judge([]Account{testAccount()}, nil, nil, given(), nil, judgedAt)
|
||||
if a.State != health.StateWaits || len(a.Waits) != 1 || a.Waits[0].Secret != "mail-credential-mesh" || a.Healthy {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
read, err := health.Read(mustJSON(a))
|
||||
if err != nil || read.State != health.StateWaits {
|
||||
t.Fatalf("the node-engine reads it as %+v (%v)", read, err)
|
||||
}
|
||||
if strings.Contains(a.Waits[0].What, "@") || strings.Contains(a.Why, "@") {
|
||||
t.Fatalf("the wait says an address: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// Unhealthy, naming the account and why, when its connection is down or its server silent for five minutes.
|
||||
func TestItIsUnhealthyWhenAnAccountIsDownOrSilent(t *testing.T) {
|
||||
acc := []Account{testAccount()}
|
||||
down := map[string]AccountState{"mesh": {Account: "mesh", State: AccountFailing, Why: "its server mail.example cannot be reached", Said: judgedAt}}
|
||||
if a := Judge(acc, nil, nil, given("mesh"), down, judgedAt); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "the account mesh is not watched: its server") {
|
||||
t.Fatalf("down: %+v", a)
|
||||
}
|
||||
silent := map[string]AccountState{"mesh": {Account: "mesh", State: AccountCurrent, Heard: judgedAt.Add(-SilentAfter - time.Second), Said: judgedAt}}
|
||||
if a := Judge(acc, nil, nil, given("mesh"), silent, judgedAt); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "IDLE on the inbox is silent") {
|
||||
t.Fatalf("silent: %+v", a)
|
||||
}
|
||||
fine := map[string]AccountState{"mesh": {Account: "mesh", State: AccountCurrent, Why: "watching", Heard: judgedAt.Add(-time.Minute), Said: judgedAt}}
|
||||
if a := Judge(acc, nil, nil, given("mesh"), fine, judgedAt); a.State != health.StateHealthy || !a.Healthy {
|
||||
t.Fatalf("fine: %+v", a)
|
||||
}
|
||||
if a := Judge(acc, nil, errors.New("the schema is behind"), given("mesh"), fine, judgedAt); a.State != health.StateUnhealthy {
|
||||
t.Fatalf("an index that cannot serve: %+v", a)
|
||||
}
|
||||
if a := Judge(nil, errors.New("the accounts setting is refused"), nil, given(), nil, judgedAt); a.State != health.StateUnhealthy {
|
||||
t.Fatalf("a setting it cannot read: %+v", a)
|
||||
}
|
||||
// A fault beside a wait is unhealthy and says both (ADR 0283 §1).
|
||||
two := []Account{testAccount(), {Name: "gmail", Kind: KindIMAP, Address: "x@example.com", Incoming: Server{Host: "h", Port: 993, Security: "tls"}}}
|
||||
a := Judge(two, nil, nil, given("mesh"), down, judgedAt)
|
||||
if a.State != health.StateUnhealthy || len(a.Waits) != 1 || a.Waits[0].Secret != "mail-credential-gmail" || !strings.Contains(a.Why, "mail-credential-gmail") {
|
||||
t.Fatalf("both: %+v", a)
|
||||
}
|
||||
// A password just given is signing in for a while; not for ever.
|
||||
signing := map[string]AccountState{"mesh": {Account: "mesh", State: AccountWaits, Said: judgedAt.Add(-time.Minute)}}
|
||||
if a := Judge(acc, nil, nil, given("mesh"), signing, judgedAt); a.State != health.StateHealthy {
|
||||
t.Fatalf("signing in: %+v", a)
|
||||
}
|
||||
signing["mesh"] = AccountState{Account: "mesh", State: AccountWaits, Said: judgedAt.Add(-SignInGrace - time.Minute)}
|
||||
if a := Judge(acc, nil, nil, given("mesh"), signing, judgedAt); a.State != health.StateUnhealthy {
|
||||
t.Fatalf("never signed in: %+v", a)
|
||||
}
|
||||
if a := Judge(nil, nil, nil, given(), nil, judgedAt); a.State != health.StateHealthy || !strings.Contains(a.Why, "nothing is watched") {
|
||||
t.Fatalf("no account: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// The tool reads the watch's verdict, and says when the watch stopped looking.
|
||||
func TestTheHealthToolReadsTheWatchsVerdictAndSaysWhenItIsStale(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "health.json")
|
||||
if a := ReadStatus(path, judgedAt); a.State != health.StateUnhealthy {
|
||||
t.Fatalf("no verdict: %+v", a)
|
||||
}
|
||||
waits := Judge([]Account{testAccount()}, nil, nil, given(), nil, judgedAt)
|
||||
if err := writeStatus(path, Status{Answer: waits, At: judgedAt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := ReadStatus(path, judgedAt.Add(time.Minute)); a.State != health.StateWaits || a.Waits[0].Secret != "mail-credential-mesh" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
if a := ReadStatus(path, judgedAt.Add(StatusStale+time.Second)); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "has not looked") {
|
||||
t.Fatalf("stale: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// One look of the watch, end to end over the files the mesh writes: the accounts setting, the secrets directory and
|
||||
// the index.
|
||||
func TestTheWatchJudgesFromTheFilesAndTheIndex(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Secrets: dir}
|
||||
writeFile(t, env.Accounts, stageOne)
|
||||
st := newMemStore()
|
||||
open := func(context.Context) (Store, func(), error) { return st, func() {}, nil }
|
||||
if a := watchOnce(context.Background(), env, open, judgedAt); a.State != health.StateWaits {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
giveSecret(t, dir, Account{Name: "mesh"}, "pw")
|
||||
_ = st.SetState(context.Background(), AccountState{Account: "mesh", State: AccountCurrent, Heard: judgedAt, Said: judgedAt})
|
||||
if a := watchOnce(context.Background(), env, open, judgedAt); a.State != health.StateHealthy {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
raw, _ := json.Marshal(Status{Answer: health.Healthy("x"), At: judgedAt})
|
||||
if !strings.Contains(string(raw), `"state":"healthy"`) || !strings.Contains(string(raw), `"at":`) {
|
||||
t.Fatalf("the status file's shape: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package main
|
||||
|
||||
// The adapter for an account of the kind `imap` (novox/hq ADR 0359 §1, §2, §5). For each account it keeps one
|
||||
// connection: IDLE on the inbox, and every PollEvery a look at every other folder. Each look compares what the server
|
||||
// holds with the index and says each difference as an event: a message added, its flags changed, or removed. The first
|
||||
// look at a folder indexes what is there without saying it — what the bus is told is what changed since the mesh
|
||||
// started watching, never the history of a mailbox.
|
||||
//
|
||||
// **It only reads.** Every folder is opened read-only (EXAMINE), and every message is fetched with BODY.PEEK, so
|
||||
// watching never marks a message seen. The client is used through reader alone, which has no command that flags,
|
||||
// moves, copies, deletes, appends or sends (§5); a test holds the code to that.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
)
|
||||
|
||||
// How the keeper paces itself. IdleCycle is how long one IDLE lasts before it is ended and begun again, which proves
|
||||
// the server still answers; SilentAfter is how long without an answer makes the account unhealthy (§6).
|
||||
const (
|
||||
IdleCycle = 2 * time.Minute
|
||||
PollEvery = 5 * time.Minute
|
||||
SilentAfter = 5 * time.Minute
|
||||
RetryFirst = 30 * time.Second
|
||||
RetryMost = 5 * time.Minute
|
||||
FetchBatch = 25
|
||||
)
|
||||
|
||||
// reader is the part of an IMAP client the keeper uses: nothing in it changes a mailbox.
|
||||
type reader interface {
|
||||
Login(username, password string) *imapclient.Command
|
||||
List(ref, pattern string, options *imap.ListOptions) *imapclient.ListCommand
|
||||
Select(mailbox string, options *imap.SelectOptions) *imapclient.SelectCommand
|
||||
Fetch(numSet imap.NumSet, options *imap.FetchOptions) *imapclient.FetchCommand
|
||||
Idle() (*imapclient.IdleCommand, error)
|
||||
Caps() imap.CapSet
|
||||
Closed() <-chan struct{}
|
||||
Logout() *imapclient.Command
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Dialer opens a connection to an account's server, handing what the server says unasked to the handler.
|
||||
type Dialer func(a Account, handler *imapclient.UnilateralDataHandler) (reader, error)
|
||||
|
||||
// DialServer is the dialer for the mesh: TLS from the first byte, or STARTTLS before signing in, as the account says.
|
||||
func DialServer(a Account, handler *imapclient.UnilateralDataHandler) (reader, error) {
|
||||
opts := &imapclient.Options{UnilateralDataHandler: handler, TLSConfig: &tls.Config{ServerName: a.Incoming.Host, MinVersion: tls.VersionTLS12}}
|
||||
var c *imapclient.Client
|
||||
var err error
|
||||
switch a.Incoming.Security {
|
||||
case "tls":
|
||||
c, err = imapclient.DialTLS(a.Incoming.Address(), opts)
|
||||
case "starttls":
|
||||
c, err = imapclient.DialStartTLS(a.Incoming.Address(), opts)
|
||||
default:
|
||||
err = fmt.Errorf("the account %s's security %q signs in over nothing this version speaks", a.Name, a.Incoming.Security)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Keeper watches the accounts and keeps the index.
|
||||
type Keeper struct {
|
||||
Store Store
|
||||
Objects Objects
|
||||
Dial Dialer
|
||||
SecretsDir string
|
||||
Logf func(format string, a ...any)
|
||||
Now func() time.Time
|
||||
|
||||
// Paces, for the tests; the constants above when zero.
|
||||
IdleCycle, PollEvery, RetryFirst time.Duration
|
||||
}
|
||||
|
||||
func (k *Keeper) now() time.Time {
|
||||
if k.Now != nil {
|
||||
return k.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (k *Keeper) logf(format string, a ...any) {
|
||||
if k.Logf != nil {
|
||||
k.Logf(format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(d, def time.Duration) time.Duration {
|
||||
if d > 0 {
|
||||
return d
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Run watches every account until ctx ends.
|
||||
func (k *Keeper) Run(ctx context.Context, accounts []Account) {
|
||||
var wg sync.WaitGroup
|
||||
for _, a := range accounts {
|
||||
wg.Add(1)
|
||||
go func(a Account) {
|
||||
defer wg.Done()
|
||||
k.Watch(ctx, a)
|
||||
}(a)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (k *Keeper) say(ctx context.Context, a Account, state, why string, heard, synced time.Time) {
|
||||
if err := k.Store.SetState(ctx, AccountState{Account: a.Name, State: state, Why: why, Heard: heard, Synced: synced, Said: k.now()}); err != nil {
|
||||
k.logf("[mesh-mail] the account %s's state could not be kept: %v", a.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch keeps one account until ctx ends: waits while its secret is not given, signs in, watches, and on any fault
|
||||
// says it, waits a little longer each time, and begins again.
|
||||
func (k *Keeper) Watch(ctx context.Context, a Account) {
|
||||
retry := orDefault(k.RetryFirst, RetryFirst)
|
||||
var heard, synced time.Time
|
||||
for ctx.Err() == nil {
|
||||
pw, given, err := ReadSecret(k.SecretsDir, a)
|
||||
switch {
|
||||
case err != nil:
|
||||
k.say(ctx, a, AccountFailing, "its password was given and cannot be read: "+err.Error(), heard, synced)
|
||||
case !given:
|
||||
k.say(ctx, a, AccountWaits, "its password ("+a.Secret()+") has not been given", heard, synced)
|
||||
default:
|
||||
s := &session{k: k, a: a, wake: make(chan struct{}, 1), heard: heard, synced: synced}
|
||||
err = s.run(ctx, pw)
|
||||
heard, synced = s.heard, s.synced
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
k.say(ctx, a, AccountFailing, err.Error(), heard, synced)
|
||||
k.logf("[mesh-mail] the account %s: %v; trying again in %s", a.Name, err, retry)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(retry):
|
||||
}
|
||||
if given && err != nil {
|
||||
retry = min(retry*2, RetryMost)
|
||||
} else {
|
||||
retry = orDefault(k.RetryFirst, RetryFirst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// session is one connection to one account's server.
|
||||
type session struct {
|
||||
k *Keeper
|
||||
a Account
|
||||
c reader
|
||||
wake chan struct{}
|
||||
heard time.Time
|
||||
synced time.Time
|
||||
// state and why are what the session last said of the account, and told when it said it.
|
||||
state, why string
|
||||
told time.Time
|
||||
}
|
||||
|
||||
// answered is the server having answered: kept, and told at least once a minute, so a long first look at a large
|
||||
// mailbox still shows the account alive to the health watch.
|
||||
func (s *session) answered() {
|
||||
s.heard = s.k.now()
|
||||
if s.state != "" && s.heard.Sub(s.told) >= time.Minute {
|
||||
s.tell(s.state, s.why)
|
||||
}
|
||||
}
|
||||
|
||||
// tell says the account's state.
|
||||
func (s *session) tell(state, why string) {
|
||||
s.state, s.why, s.told = state, why, s.k.now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
s.k.say(ctx, s.a, state, why, s.heard, s.synced)
|
||||
}
|
||||
|
||||
func (s *session) poke() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) run(ctx context.Context, password string) error {
|
||||
handler := &imapclient.UnilateralDataHandler{
|
||||
Expunge: func(uint32) { s.poke() },
|
||||
Mailbox: func(*imapclient.UnilateralDataMailbox) { s.poke() },
|
||||
Fetch: func(*imapclient.FetchMessageData) { s.poke() },
|
||||
}
|
||||
c, err := s.k.Dial(s.a, handler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("its server %s cannot be reached: %v", s.a.Incoming.Host, err)
|
||||
}
|
||||
s.c = c
|
||||
defer c.Close()
|
||||
if err := c.Login(s.a.SignInAs(), password).Wait(); err != nil {
|
||||
var said *imap.Error
|
||||
if errors.As(err, &said) {
|
||||
return fmt.Errorf("its server refused the sign-in (%s): its password may be wrong, and is given again at the desk", strings.TrimSpace(said.Text))
|
||||
}
|
||||
return fmt.Errorf("signing in failed: %v", err)
|
||||
}
|
||||
s.answered()
|
||||
s.tell(AccountSyncing, "signed in; looking at its folders")
|
||||
folders, err := s.folders()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idles := c.Caps().Has(imap.CapIdle) || c.Caps().Has(imap.CapIMAP4rev2)
|
||||
var polled time.Time
|
||||
for ctx.Err() == nil {
|
||||
if polled.IsZero() || s.k.now().Sub(polled) >= orDefault(s.k.PollEvery, PollEvery) {
|
||||
for _, f := range folders {
|
||||
if err := s.k.syncFolder(ctx, c, s.a, f, s.answered); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
polled = s.k.now()
|
||||
}
|
||||
// The inbox last, so it is the folder selected while the session idles.
|
||||
if err := s.k.syncFolder(ctx, c, s.a, "INBOX", s.answered); err != nil {
|
||||
return err
|
||||
}
|
||||
s.synced = s.k.now()
|
||||
s.tell(AccountCurrent, fmt.Sprintf("watching the inbox and %d other folder(s)", len(folders)))
|
||||
if err := s.wait(ctx, idles); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = c.Logout().Wait()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// wait is one IDLE on the inbox, ended by what the server says unasked, by the cycle's end or by ctx. Ending it waits
|
||||
// for the server's answer, which is what proves the connection alive.
|
||||
func (s *session) wait(ctx context.Context, idles bool) error {
|
||||
cycle := time.NewTimer(orDefault(s.k.IdleCycle, IdleCycle))
|
||||
defer cycle.Stop()
|
||||
if !idles {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-cycle.C:
|
||||
case <-s.c.Closed():
|
||||
return errors.New("its server closed the connection")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
idle, err := s.c.Idle()
|
||||
if err != nil {
|
||||
return fmt.Errorf("its server refused to IDLE on the inbox: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-s.wake:
|
||||
case <-cycle.C:
|
||||
case <-s.c.Closed():
|
||||
}
|
||||
if err := idle.Close(); err != nil {
|
||||
return fmt.Errorf("its IDLE on the inbox ended badly: %v", err)
|
||||
}
|
||||
if err := idle.Wait(); err != nil {
|
||||
return fmt.Errorf("its IDLE on the inbox ended badly: %v", err)
|
||||
}
|
||||
s.answered()
|
||||
return nil
|
||||
}
|
||||
|
||||
// folders is what is watched besides the inbox: the account's list, or every folder the server can open.
|
||||
func (s *session) folders() ([]string, error) {
|
||||
if len(s.a.Folders) > 0 {
|
||||
return append([]string(nil), s.a.Folders...), nil
|
||||
}
|
||||
listed, err := s.c.List("", "*", nil).Collect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("its folders cannot be listed: %v", err)
|
||||
}
|
||||
s.answered()
|
||||
var out []string
|
||||
for _, l := range listed {
|
||||
if strings.EqualFold(l.Mailbox, "INBOX") || hasAttr(l.Attrs, imap.MailboxAttrNoSelect) || hasAttr(l.Attrs, imap.MailboxAttrNonExistent) {
|
||||
continue
|
||||
}
|
||||
out = append(out, l.Mailbox)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasAttr(attrs []imap.MailboxAttr, want imap.MailboxAttr) bool {
|
||||
for _, a := range attrs {
|
||||
if strings.EqualFold(string(a), string(want)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// syncFolder brings the index of one folder to what the server holds, and says each difference, unless this is the
|
||||
// first look at the folder.
|
||||
func (k *Keeper) syncFolder(ctx context.Context, c reader, a Account, folder string, answered func()) error {
|
||||
sel, err := c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the folder %s cannot be opened: %v", folder, err)
|
||||
}
|
||||
answered()
|
||||
known, seen, err := k.Store.Folder(ctx, a.Name, folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seen && known.UIDValidity != sel.UIDValidity {
|
||||
k.logf("[mesh-mail] the account %s's folder %s was renumbered by its server; it is indexed afresh", a.Name, folder)
|
||||
if err := k.Store.DropFolder(ctx, a.Name, folder); err != nil {
|
||||
return err
|
||||
}
|
||||
seen = false
|
||||
}
|
||||
server := map[uint32][]string{}
|
||||
if sel.NumMessages > 0 {
|
||||
all, err := c.Fetch(imap.UIDSet{{Start: 1, Stop: 0}}, &imap.FetchOptions{UID: true, Flags: true}).Collect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the folder %s's messages cannot be listed: %v", folder, err)
|
||||
}
|
||||
answered()
|
||||
for _, m := range all {
|
||||
server[uint32(m.UID)] = flagStrings(m.Flags)
|
||||
}
|
||||
}
|
||||
index, err := k.Store.Flags(ctx, a.Name, folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
say := func(e *Event) *Event {
|
||||
if seen {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Removed: in the index, no longer on the server.
|
||||
for uid := range index {
|
||||
if _, still := server[uid]; still {
|
||||
continue
|
||||
}
|
||||
m, _, err := k.Store.Get(ctx, a.Name, folder, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.UID == 0 {
|
||||
m = Message{Account: a.Name, Folder: folder, UID: uid, UIDValidity: sel.UIDValidity}
|
||||
}
|
||||
if err := k.Store.Remove(ctx, a.Name, folder, uid, say(removedEvent(m))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Changed: on both, with other flags.
|
||||
var added []uint32
|
||||
for uid, flags := range server {
|
||||
before, indexed := index[uid]
|
||||
if !indexed {
|
||||
added = append(added, uid)
|
||||
continue
|
||||
}
|
||||
if sameStrings(before, flags) {
|
||||
continue
|
||||
}
|
||||
m, _, err := k.Store.Get(ctx, a.Name, folder, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Flags = flags
|
||||
if err := k.Store.SetFlags(ctx, a.Name, folder, uid, flags, say(changedEvent(m, before, flags))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Added: on the server, not yet in the index; fetched whole, in batches, without marking them seen.
|
||||
sort.Slice(added, func(i, j int) bool { return added[i] < added[j] })
|
||||
for start := 0; start < len(added); start += FetchBatch {
|
||||
batch := added[start:min(start+FetchBatch, len(added))]
|
||||
uids := make([]imap.UID, len(batch))
|
||||
for i, u := range batch {
|
||||
uids[i] = imap.UID(u)
|
||||
}
|
||||
msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{
|
||||
UID: true, Flags: true, Envelope: true, RFC822Size: true, InternalDate: true,
|
||||
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the folder %s's new messages cannot be fetched: %v", folder, err)
|
||||
}
|
||||
answered()
|
||||
for _, fm := range msgs {
|
||||
m, err := k.index(ctx, a, folder, sel.UIDValidity, fm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.Store.Add(ctx, m, say(addedEvent(m))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return k.Store.SetFolder(ctx, Folder{Account: a.Name, Name: folder, UIDValidity: sel.UIDValidity, Messages: len(server), Synced: k.now()})
|
||||
}
|
||||
|
||||
// index is a fetched message as the index keeps it: its metadata, its text, and its attachments kept in the store.
|
||||
func (k *Keeper) index(ctx context.Context, a Account, folder string, validity uint32, fm *imapclient.FetchMessageBuffer) (Message, error) {
|
||||
m := Message{Account: a.Name, Folder: folder, UIDValidity: validity, UID: uint32(fm.UID), Size: fm.RFC822Size,
|
||||
Flags: flagStrings(fm.Flags), Date: fm.InternalDate.UTC(), From: []Person{}, To: []Person{}, Attachments: []AttachmentRef{}}
|
||||
if env := fm.Envelope; env != nil {
|
||||
m.MessageID, m.Subject = env.MessageID, env.Subject
|
||||
if len(env.InReplyTo) > 0 {
|
||||
m.InReplyTo = env.InReplyTo[0]
|
||||
}
|
||||
if !env.Date.IsZero() {
|
||||
m.Date = env.Date.UTC()
|
||||
}
|
||||
m.From, m.To, m.Cc = people(env.From), people(env.To), people(env.Cc)
|
||||
}
|
||||
var raw []byte
|
||||
if len(fm.BodySection) > 0 {
|
||||
raw = fm.BodySection[0].Bytes
|
||||
}
|
||||
parsed, err := ParseMessage(raw)
|
||||
if err != nil {
|
||||
k.logf("[mesh-mail] the account %s's message %d in %s is read in part: %v", a.Name, m.UID, folder, err)
|
||||
}
|
||||
m.Body = parsed.Text
|
||||
for _, p := range parsed.Attachments {
|
||||
ref, err := Keep(ctx, k.Objects, p.Data, p.Type, p.Name)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
m.Attachments = append(m.Attachments, ref)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func people(as []imap.Address) []Person {
|
||||
out := []Person{}
|
||||
for _, a := range as {
|
||||
if addr := a.Addr(); addr != "" {
|
||||
out = append(out, Person{Name: a.Name, Address: addr})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flagStrings(fs []imap.Flag) []string {
|
||||
out := make([]string, 0, len(fs))
|
||||
for _, f := range fs {
|
||||
if strings.EqualFold(string(f), `\Recent`) {
|
||||
continue
|
||||
}
|
||||
out = append(out, string(f))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sameStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- the events ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// EventKey is where an account's change is said: mail.<account>.message.<added|changed|removed> (research 049
|
||||
// document 02 §5), the account by its name in the setting, never its address.
|
||||
func EventKey(account, change string) string { return "mail." + account + ".message." + change }
|
||||
|
||||
// Changes are the three things said of a message.
|
||||
var Changes = []string{"added", "changed", "removed"}
|
||||
|
||||
// metadata is what an event says of a message: never its body, never an attachment's bytes (ADR 0359 §2).
|
||||
func metadata(m Message) map[string]any {
|
||||
atts := make([]map[string]any, 0, len(m.Attachments))
|
||||
for _, r := range m.Attachments {
|
||||
atts = append(atts, map[string]any{"bucket": r.Bucket, "key": r.Key, "size": r.Size, "type": r.Type, "name": r.Name})
|
||||
}
|
||||
return map[string]any{
|
||||
"account": m.Account, "folder": m.Folder, "uid": m.UID, "uidvalidity": m.UIDValidity,
|
||||
"message-id": m.MessageID, "in-reply-to": m.InReplyTo, "date": m.Date.UTC().Format(time.RFC3339),
|
||||
"from": m.From, "to": m.To, "cc": m.Cc, "subject": m.Subject, "size": m.Size, "flags": m.Flags,
|
||||
"attachments": atts,
|
||||
}
|
||||
}
|
||||
|
||||
func addedEvent(m Message) *Event {
|
||||
return &Event{Key: EventKey(m.Account, "added"), Body: metadata(m)}
|
||||
}
|
||||
|
||||
func changedEvent(m Message, before, after []string) *Event {
|
||||
body := metadata(m)
|
||||
body["changes"] = map[string]any{"flags": map[string]any{"before": before, "after": after}}
|
||||
return &Event{Key: EventKey(m.Account, "changed"), Body: body}
|
||||
}
|
||||
|
||||
func removedEvent(m Message) *Event {
|
||||
return &Event{Key: EventKey(m.Account, "removed"), Body: map[string]any{
|
||||
"account": m.Account, "folder": m.Folder, "uid": m.UID, "uidvalidity": m.UIDValidity, "message-id": m.MessageID,
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
)
|
||||
|
||||
// keeping starts a keeper on one account of the test server, its paces short, and stops it when the test ends.
|
||||
func keeping(t *testing.T, s *mailServer, st Store, objects Objects, secrets string) {
|
||||
t.Helper()
|
||||
k := &Keeper{Store: st, Objects: objects, Dial: s.dialer(), SecretsDir: secrets, Logf: t.Logf,
|
||||
IdleCycle: 300 * time.Millisecond, PollEvery: 400 * time.Millisecond, RetryFirst: 100 * time.Millisecond}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { k.Watch(ctx, testAccount()); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
}
|
||||
|
||||
func stateOf(st Store, account string) AccountState {
|
||||
states, _ := st.States(context.Background())
|
||||
return states[account]
|
||||
}
|
||||
|
||||
// float64OrUint reads a uid from an event body whether it came from memory (uint32) or from postgres (float64).
|
||||
type float64OrUint struct{ v any }
|
||||
|
||||
func (f float64OrUint) value() uint32 {
|
||||
switch n := f.v.(type) {
|
||||
case uint32:
|
||||
return n
|
||||
case float64:
|
||||
return uint32(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func uidOf(e Event) uint32 { return float64OrUint{e.Body["uid"]}.value() }
|
||||
|
||||
func eventsNamed(st Store, key string) []Event {
|
||||
evs, _ := st.Pending(context.Background(), 1000)
|
||||
var out []Event
|
||||
for _, e := range evs {
|
||||
if e.Key == key {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func describe(st Store) string {
|
||||
evs, _ := st.Pending(context.Background(), 1000)
|
||||
var keys []string
|
||||
for _, e := range evs {
|
||||
keys = append(keys, e.Key)
|
||||
}
|
||||
s := stateOf(st, "mesh")
|
||||
return "state " + s.State + " (" + s.Why + "); events " + strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// The whole watch, over a real IMAP conversation (ADR 0359 §2, §3, §5): the first look indexes what is there and says
|
||||
// nothing; then a new mail in the inbox is said within moments of its delivery, through IDLE, with its metadata and
|
||||
// its attachment by reference, never its body; its flags changed are said; its removal is said; a mail in another
|
||||
// folder is said at the next poll; and watching never marks a message seen.
|
||||
func TestTheKeeperSaysEachChangeOnTheBusAndOnlyReads(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
s := newMailServer(t)
|
||||
op := s.operator(t)
|
||||
deliver(t, op, "INBOX", plainMail("before the watch", "an old letter"))
|
||||
deliver(t, op, "Archive", plainMail("archived long ago", "an older letter"))
|
||||
secrets := t.TempDir()
|
||||
giveSecret(t, secrets, testAccount(), testPassword)
|
||||
objects := newMemObjects()
|
||||
keeping(t, s, st, objects, secrets)
|
||||
|
||||
eventually(t, 10*time.Second, "the first look", func() (bool, string) {
|
||||
return stateOf(st, "mesh").State == AccountCurrent, describe(st)
|
||||
})
|
||||
ctx := context.Background()
|
||||
if hits, _ := st.Search(ctx, Query{Text: "letter", Accounts: []string{"mesh"}}); len(hits) != 2 {
|
||||
t.Fatalf("the first look indexed %d messages, not 2", len(hits))
|
||||
}
|
||||
if evs, _ := st.Pending(ctx, 100); len(evs) != 0 {
|
||||
t.Fatalf("the first look said %d event(s): the history of a mailbox is not news", len(evs))
|
||||
}
|
||||
|
||||
// A new mail with an attachment, delivered while the keeper idles on the inbox.
|
||||
deliver(t, op, "INBOX", attachedMail("the invoice"))
|
||||
var added Event
|
||||
eventually(t, 5*time.Second, "the new mail said", func() (bool, string) {
|
||||
evs := eventsNamed(st, "mail.mesh.message.added")
|
||||
if len(evs) == 1 {
|
||||
added = evs[0]
|
||||
}
|
||||
return len(evs) == 1, describe(st)
|
||||
})
|
||||
body := added.Body
|
||||
if body["subject"] != "the invoice" || body["folder"] != "INBOX" || body["account"] != "mesh" || body["message-id"] != "att@example.org" {
|
||||
t.Fatalf("the event says %v", body)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
for _, never := range []string{"Please find", "dGhlIGludm9pY2Ug", attachmentData} {
|
||||
if strings.Contains(string(raw), never) {
|
||||
t.Fatalf("the event carries the body or an attachment's bytes (%q): %s", never, raw)
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(attachmentData))
|
||||
key := hex.EncodeToString(sum[:])
|
||||
if !strings.Contains(string(raw), `"key":"`+key+`"`) || !strings.Contains(string(raw), `"name":"invoice.pdf"`) {
|
||||
t.Fatalf("the event does not name the attachment by its SHA-256 %s: %s", key, raw)
|
||||
}
|
||||
if kept, _ := objects.Get(ctx, key); string(kept) != attachmentData {
|
||||
t.Fatalf("the attachment store keeps %q under %s", kept, key)
|
||||
}
|
||||
uid := uidOf(added)
|
||||
m, ok, _ := st.Get(ctx, "mesh", "INBOX", uid)
|
||||
if !ok || !strings.Contains(m.Body, "Please find the invoice attached.") {
|
||||
t.Fatalf("the index keeps the text for mail_read: %+v", m)
|
||||
}
|
||||
|
||||
// Flagged by the operator elsewhere: said as changed, with the flags before and after.
|
||||
if _, err := op.Select("INBOX", nil).Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagFlagged}, Silent: true}, nil).Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, 5*time.Second, "the flag said", func() (bool, string) {
|
||||
evs := eventsNamed(st, "mail.mesh.message.changed")
|
||||
if len(evs) != 1 {
|
||||
return false, describe(st)
|
||||
}
|
||||
raw, _ := json.Marshal(evs[0].Body["changes"])
|
||||
return strings.Contains(string(raw), `"after":["\\Flagged"]`), string(raw)
|
||||
})
|
||||
|
||||
// Never marked seen by the watching: the message is still unseen on the server.
|
||||
fetched, err := op.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{Flags: true, UID: true}).Collect()
|
||||
if err != nil || len(fetched) != 1 {
|
||||
t.Fatalf("%v %v", fetched, err)
|
||||
}
|
||||
for _, f := range fetched[0].Flags {
|
||||
if f == imap.FlagSeen {
|
||||
t.Fatal("watching marked the message seen")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleted by the operator elsewhere: said as removed.
|
||||
if err := op.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true}, nil).Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Expunge().Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, 5*time.Second, "the removal said", func() (bool, string) {
|
||||
evs := eventsNamed(st, "mail.mesh.message.removed")
|
||||
return len(evs) == 1 && uidOf(evs[0]) == uid && evs[0].Body["message-id"] == "att@example.org", describe(st)
|
||||
})
|
||||
if _, ok, _ := st.Get(ctx, "mesh", "INBOX", uid); ok {
|
||||
t.Fatal("the removed message is still indexed")
|
||||
}
|
||||
|
||||
// A mail in another folder is said at the next poll.
|
||||
deliver(t, op, "Archive", plainMail("filed away", "a letter filed"))
|
||||
eventually(t, 5*time.Second, "the archived mail said", func() (bool, string) {
|
||||
for _, e := range eventsNamed(st, "mail.mesh.message.added") {
|
||||
if e.Body["folder"] == "Archive" && e.Body["subject"] == "filed away" {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, describe(st)
|
||||
})
|
||||
if s := stateOf(st, "mesh"); s.State != AccountCurrent || time.Since(s.Heard) > time.Minute {
|
||||
t.Fatalf("the account is %+v", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// No password given: the account waits, and nothing is dialled. A wrong one: the account fails, saying the server
|
||||
// refused the sign-in. Given later: it is picked up without a restart.
|
||||
func TestTheKeeperWaitsForThePasswordAndSaysARefusal(t *testing.T) {
|
||||
s := newMailServer(t)
|
||||
st := newMemStore()
|
||||
secrets := t.TempDir()
|
||||
keeping(t, s, st, newMemObjects(), secrets)
|
||||
eventually(t, 5*time.Second, "the wait", func() (bool, string) {
|
||||
got := stateOf(st, "mesh")
|
||||
return got.State == AccountWaits && strings.Contains(got.Why, "mail-credential-mesh"), describe(st)
|
||||
})
|
||||
giveSecret(t, secrets, testAccount(), "not-the-password")
|
||||
eventually(t, 5*time.Second, "the refusal", func() (bool, string) {
|
||||
got := stateOf(st, "mesh")
|
||||
return got.State == AccountFailing && strings.Contains(got.Why, "refused the sign-in"), describe(st)
|
||||
})
|
||||
giveSecret(t, secrets, testAccount(), testPassword)
|
||||
eventually(t, 10*time.Second, "the sign-in", func() (bool, string) {
|
||||
return stateOf(st, "mesh").State == AccountCurrent, describe(st)
|
||||
})
|
||||
}
|
||||
|
||||
// It only reads (ADR 0359 §5): no code of the module, but its tests, issues a command that flags, moves, copies,
|
||||
// deletes, appends, creates, renames or sends, and every folder is opened read-only and every body peeked.
|
||||
func TestNothingButTheTestsWritesToAMailbox(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writes := regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(Store|Move|Copy|Expunge|UIDExpunge|Append|Create|Delete|Rename|Subscribe|Unsubscribe|SetMetadata|SetQuota)\(`)
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range writes.FindAllSubmatch(raw, -1) {
|
||||
if string(m[1]) != "os" { // a file of the module's own, never a mailbox
|
||||
t.Errorf("%s calls %s, which writes to a mailbox", f, m[0])
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(raw), "net/smtp") {
|
||||
t.Errorf("%s speaks SMTP: this stage sends nothing", f)
|
||||
}
|
||||
}
|
||||
src, _ := os.ReadFile("imap.go")
|
||||
if !strings.Contains(string(src), "&imap.SelectOptions{ReadOnly: true}") || !strings.Contains(string(src), "{{Peek: true}}") {
|
||||
t.Fatal("folders are not opened read-only, or bodies not peeked")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
// What the tests stand on: an IMAP server in this process (go-imap's own in-memory server, speaking the protocol over
|
||||
// a real socket), the index in memory always and on a real postgres when one can be had (MESH_TEST_POSTGRES, or a
|
||||
// private server started here from the machine's initdb on a socket of its own, listening on no port), and the
|
||||
// attachment store in memory.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
stopPostgres()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// ---- the IMAP server --------------------------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
testUser = "operator@example.org"
|
||||
testPassword = "the-password"
|
||||
)
|
||||
|
||||
type mailServer struct {
|
||||
addr string
|
||||
srv *imapserver.Server
|
||||
}
|
||||
|
||||
// newMailServer is a server holding one user with an inbox and an Archive folder.
|
||||
func newMailServer(t *testing.T) *mailServer {
|
||||
t.Helper()
|
||||
mem := imapmemserver.New()
|
||||
user := imapmemserver.NewUser(testUser, testPassword)
|
||||
for _, f := range []string{"INBOX", "Archive"} {
|
||||
if err := user.Create(f, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mem.AddUser(user)
|
||||
srv := imapserver.New(&imapserver.Options{
|
||||
NewSession: func(*imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return mem.NewSession(), nil, nil
|
||||
},
|
||||
InsecureAuth: true,
|
||||
Caps: imap.CapSet{imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}},
|
||||
})
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
return &mailServer{addr: ln.Addr().String(), srv: srv}
|
||||
}
|
||||
|
||||
// dialer reaches the test server without TLS, which only a test does: the mesh's dialer signs in over TLS alone.
|
||||
func (s *mailServer) dialer() Dialer {
|
||||
return func(_ Account, h *imapclient.UnilateralDataHandler) (reader, error) {
|
||||
c, err := imapclient.DialInsecure(s.addr, &imapclient.Options{UnilateralDataHandler: h})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
// operator is a second client, as the operator's mail program or another sender is: it appends, flags and deletes.
|
||||
func (s *mailServer) operator(t *testing.T) *imapclient.Client {
|
||||
t.Helper()
|
||||
c, err := imapclient.DialInsecure(s.addr, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
if err := c.Login(testUser, testPassword).Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func deliver(t *testing.T, c *imapclient.Client, folder, raw string) {
|
||||
t.Helper()
|
||||
raw = strings.ReplaceAll(raw, "\n", "\r\n")
|
||||
cmd := c.Append(folder, int64(len(raw)), nil)
|
||||
if _, err := cmd.Write([]byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cmd.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func plainMail(subject, body string) string {
|
||||
return "From: Alice <alice@example.org>\nTo: Operator <operator@example.org>\nSubject: " + subject +
|
||||
"\nMessage-Id: <" + strings.ReplaceAll(subject, " ", "-") + "@example.org>\nDate: Wed, 14 Oct 2026 10:00:00 +0000\n" +
|
||||
"MIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n" + body + "\n"
|
||||
}
|
||||
|
||||
// attachmentData is an attachment's content, and attachedMail a message carrying it.
|
||||
const attachmentData = "the invoice, in full"
|
||||
|
||||
func attachedMail(subject string) string {
|
||||
return "From: Bob <bob@example.org>\nTo: operator@example.org\nSubject: " + subject + "\nMessage-Id: <att@example.org>\n" +
|
||||
"Date: Wed, 14 Oct 2026 11:00:00 +0000\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=XYZ\n\n" +
|
||||
"--XYZ\nContent-Type: text/plain; charset=utf-8\n\nPlease find the invoice attached.\n" +
|
||||
"--XYZ\nContent-Type: application/pdf; name=invoice.pdf\nContent-Disposition: attachment; filename=invoice.pdf\n" +
|
||||
"Content-Transfer-Encoding: base64\n\ndGhlIGludm9pY2UsIGluIGZ1bGw=\n--XYZ--\n"
|
||||
}
|
||||
|
||||
// ---- the account ------------------------------------------------------------------------------------------------
|
||||
|
||||
func testAccount() Account {
|
||||
return Account{Name: "mesh", Kind: KindIMAP, Address: testUser, Incoming: Server{Host: "localhost", Port: 993, Security: "tls"}}
|
||||
}
|
||||
|
||||
func giveSecret(t *testing.T, dir string, a Account, pw string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(SecretPath(dir, a), []byte(pw+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// eventually waits for a condition, failing with what it last saw.
|
||||
func eventually(t *testing.T, within time.Duration, what string, ok func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
last := ""
|
||||
for time.Now().Before(deadline) {
|
||||
var done bool
|
||||
if done, last = ok(); done {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("%s did not happen within %s; last seen: %s", what, within, last)
|
||||
}
|
||||
|
||||
// ---- postgres ---------------------------------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
pgOnce sync.Once
|
||||
pgBase string
|
||||
pgDir string
|
||||
pgWhy string
|
||||
pgCount int
|
||||
pgMu sync.Mutex
|
||||
)
|
||||
|
||||
func startPostgres() {
|
||||
if u := os.Getenv("MESH_TEST_POSTGRES"); u != "" {
|
||||
pgBase = u
|
||||
return
|
||||
}
|
||||
initdb, err := exec.LookPath("initdb")
|
||||
if err != nil {
|
||||
pgWhy = "no postgres: MESH_TEST_POSTGRES is unset and the machine has no initdb"
|
||||
return
|
||||
}
|
||||
pgctl := filepath.Join(filepath.Dir(initdb), "pg_ctl")
|
||||
dir, err := os.MkdirTemp("", "mmpg")
|
||||
if err != nil {
|
||||
pgWhy = err.Error()
|
||||
return
|
||||
}
|
||||
pgDir = dir
|
||||
if out, err := exec.Command(initdb, "-D", dir+"/data", "-U", "postgres", "-A", "trust", "-E", "UTF8", "--no-locale").CombinedOutput(); err != nil {
|
||||
pgWhy = fmt.Sprintf("initdb: %v: %s", err, out)
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command(pgctl, "-D", dir+"/data", "-l", dir+"/log", "-w", "-o",
|
||||
"-k "+dir+" -c listen_addresses='' -F", "start").CombinedOutput(); err != nil {
|
||||
pgWhy = fmt.Sprintf("pg_ctl start: %v: %s", err, out)
|
||||
return
|
||||
}
|
||||
pgBase = "postgres://postgres@/postgres?host=" + dir
|
||||
}
|
||||
|
||||
func stopPostgres() {
|
||||
if pgDir == "" {
|
||||
return
|
||||
}
|
||||
if initdb, err := exec.LookPath("initdb"); err == nil {
|
||||
_ = exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", pgDir+"/data", "-m", "immediate", "stop").Run()
|
||||
}
|
||||
_ = os.RemoveAll(pgDir)
|
||||
}
|
||||
|
||||
// freshDatabase is a database of its own for one test, or a skip saying why there is none.
|
||||
func freshDatabase(t *testing.T) string {
|
||||
t.Helper()
|
||||
pgOnce.Do(startPostgres)
|
||||
if pgBase == "" {
|
||||
t.Skipf("NOT TESTED ON POSTGRES: %s", pgWhy)
|
||||
}
|
||||
pgMu.Lock()
|
||||
pgCount++
|
||||
name := fmt.Sprintf("mail_%d_%d", os.Getpid(), pgCount)
|
||||
pgMu.Unlock()
|
||||
ctx := context.Background()
|
||||
admin, err := OpenPgStore(ctx, pgBase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.pool.Exec(ctx, "create database "+name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := url.Parse(pgBase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.Path = "/" + name
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func pgStore(t *testing.T) *PgStore {
|
||||
t.Helper()
|
||||
s, err := OpenPgStore(context.Background(), freshDatabase(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
if _, err := s.Prepare(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// eachStore runs a test over the index in memory and on postgres.
|
||||
func eachStore(t *testing.T, run func(t *testing.T, s Store)) {
|
||||
t.Run("memory", func(t *testing.T) { run(t, newMemStore()) })
|
||||
t.Run("postgres", func(t *testing.T) { run(t, pgStore(t)) })
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// mesh-mail: the operator's mail accounts, watched, and each mail said on the bus (novox/hq ADR 0359). One instance,
|
||||
// on the control-node. The first stage holds the mesh's own server, read-only: no flag, move, delete, draft or send.
|
||||
//
|
||||
// mesh-mail the bundle the node's runtime launches: the tools mail_accounts, mail_search, mail_read and
|
||||
// mail_health, and the events the keeper wrote, said on the bus as the module
|
||||
// mesh-mail prepare the preparation step: the index's schema brought to this version's
|
||||
// mesh-mail keep acts: watches every account (IDLE on the inbox, a look at the other folders every five
|
||||
// minutes), keeps the index and the attachment store, and writes each change's event beside it
|
||||
// mesh-mail watch judges: reads every minute how each account is, and leaves its verdict for mail_health
|
||||
//
|
||||
// The two long-running processes are the act and the judge, as mesh-knowledge's are: keep is judged by its unit, watch
|
||||
// by the tool mail_health (ADR 0359 §6, ADR 0240 §3 and §7). Only the bundle reaches the bus, through the runtime that
|
||||
// launched it, so the keeper hands its events over in the index, written in the same step as the change they say,
|
||||
// and the bundle says them and removes each once the bus took it. stdout is the bundle's MCP channel; what this module
|
||||
// says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Module is this module's name.
|
||||
const Module = "mesh-mail"
|
||||
|
||||
// Env is where the mesh puts what the module reads, as the manifest's environment names it.
|
||||
type Env struct {
|
||||
Accounts string // the trusted setting accounts
|
||||
Grants string // the trusted setting grants
|
||||
Secrets string // the directory the own secrets mail-credential-<account> are placed in
|
||||
Store string // the attachment store's facts
|
||||
Status string // the watch's verdict
|
||||
}
|
||||
|
||||
func envFromOS() Env {
|
||||
return Env{
|
||||
Accounts: os.Getenv("MESH_MAIL_ACCOUNTS"),
|
||||
Grants: os.Getenv("MESH_MAIL_GRANTS"),
|
||||
Secrets: os.Getenv("MESH_MAIL_SECRETS"),
|
||||
Store: os.Getenv("MESH_MAIL_STORE"),
|
||||
Status: os.Getenv("MESH_MAIL_STATUS"),
|
||||
}
|
||||
}
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "prepare":
|
||||
os.Exit(prepare())
|
||||
case "keep":
|
||||
os.Exit(keep(envFromOS()))
|
||||
case "watch":
|
||||
os.Exit(watch(envFromOS()))
|
||||
}
|
||||
}
|
||||
serve(envFromOS())
|
||||
}
|
||||
|
||||
// ---- prepare ------------------------------------------------------------------------------------------------------
|
||||
|
||||
func prepare() int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
s, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
logf("[mesh-mail] preparing the index: %v", err)
|
||||
return 1
|
||||
}
|
||||
defer s.Close()
|
||||
said, err := s.Prepare(ctx)
|
||||
for _, line := range said {
|
||||
logf("[mesh-mail] %s", line)
|
||||
}
|
||||
if err != nil {
|
||||
logf("[mesh-mail] preparing the index failed: %v", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- keep ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
// keep watches every account until it is stopped. A setting it cannot read, an index that is behind or a store it
|
||||
// cannot reach ends it with a fault, which its unit says; the mesh starts it again.
|
||||
func keep(env Env) int {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
accounts, err := ReadAccounts(env.Accounts)
|
||||
if err != nil {
|
||||
logf("[mesh-mail] %v", err)
|
||||
return 1
|
||||
}
|
||||
st, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
logf("[mesh-mail] the index cannot be opened: %v", err)
|
||||
return 1
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.Ready(ctx); err != nil {
|
||||
logf("[mesh-mail] %v", err)
|
||||
return 1
|
||||
}
|
||||
facts, err := ReadStoreFacts(env.Store)
|
||||
if err != nil {
|
||||
logf("[mesh-mail] %v", err)
|
||||
return 1
|
||||
}
|
||||
k := &Keeper{Store: st, Objects: NewS3(facts), Dial: DialServer, SecretsDir: env.Secrets, Logf: logf}
|
||||
logf("[mesh-mail] keeping %d account(s)", len(accounts))
|
||||
k.Run(ctx, accounts)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- watch --------------------------------------------------------------------------------------------------------
|
||||
|
||||
func watch(env Env) int {
|
||||
if env.Status == "" {
|
||||
logf("[mesh-mail] watch was started without MESH_MAIL_STATUS")
|
||||
return 2
|
||||
}
|
||||
open := func(ctx context.Context) (Store, func(), error) {
|
||||
s, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return s, s.Close, nil
|
||||
}
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
now := time.Now().UTC()
|
||||
answer := watchOnce(ctx, env, open, now)
|
||||
cancel()
|
||||
if err := writeStatus(env.Status, Status{Answer: answer, At: now}); err != nil {
|
||||
logf("[mesh-mail] the verdict could not be written: %v", err)
|
||||
}
|
||||
time.Sleep(WatchEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the bundle ---------------------------------------------------------------------------------------------------
|
||||
|
||||
func serve(env Env) {
|
||||
var mu sync.Mutex
|
||||
var st *PgStore
|
||||
b := &Bundle{
|
||||
Env: env,
|
||||
Index: func(ctx context.Context) (Store, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if st == nil {
|
||||
s, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st = s
|
||||
}
|
||||
if err := st.Ready(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
},
|
||||
Objects: func() (Objects, error) {
|
||||
facts, err := ReadStoreFacts(env.Store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewS3(facts), nil
|
||||
},
|
||||
}
|
||||
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf}
|
||||
go p.Run(context.Background())
|
||||
if err := stdio.Serve("", b.Tools()); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Publisher says the events the keeper wrote, oldest first, and removes each once the bus took it. One the bus did not
|
||||
// take stays, and is said again on the next round: delivery is at least once, and a consumer reads the uid and the
|
||||
// folder to know a repeat.
|
||||
type Publisher struct {
|
||||
Index func(ctx context.Context) (Store, error)
|
||||
Emit func(key string, body any) error
|
||||
Logf func(format string, a ...any)
|
||||
Every time.Duration
|
||||
}
|
||||
|
||||
// PublishBatch is how many events one round says at most.
|
||||
const PublishBatch = 100
|
||||
|
||||
// Once says what is pending, and answers how many it said.
|
||||
func (p *Publisher) Once(ctx context.Context) (int, error) {
|
||||
st, err := p.Index(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pending, err := st.Pending(ctx, PublishBatch)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var said []int64
|
||||
var failed error
|
||||
for _, e := range pending {
|
||||
if err := p.Emit(e.Key, e.Body); err != nil {
|
||||
failed = fmt.Errorf("the bus did not take %s: %v", e.Key, err)
|
||||
break
|
||||
}
|
||||
said = append(said, e.ID)
|
||||
}
|
||||
if len(said) > 0 {
|
||||
if err := st.Said(ctx, said); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(said), failed
|
||||
}
|
||||
|
||||
// Run says pending events until ctx ends: every few seconds, at once again while a round was full.
|
||||
func (p *Publisher) Run(ctx context.Context) {
|
||||
every := p.Every
|
||||
if every <= 0 {
|
||||
every = 3 * time.Second
|
||||
}
|
||||
// The runtime takes the bundle's first calls only once Serve runs.
|
||||
time.Sleep(every)
|
||||
for ctx.Err() == nil {
|
||||
c, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
n, err := p.Once(c)
|
||||
cancel()
|
||||
if err != nil && p.Logf != nil {
|
||||
p.Logf("[mesh-mail] events wait to be said: %v", err)
|
||||
}
|
||||
if n == PublishBatch {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(every):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The manifest says what the code does (novox/hq ADR 0359): the database and the bucket it requires (§3), the own
|
||||
// secret family issued outside the mesh (§5, ADR 0283 §6), the two trusted settings (§4), the tools (§5), the events,
|
||||
// and its two long-running processes with their health (§6) — and it is assigned to no machine by itself.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Slug string `json:"slug"`
|
||||
Requires []string `json:"requires"`
|
||||
Contributes map[string]map[string]any `json:"contributes"`
|
||||
OwnSecrets map[string]map[string]string `json:"own-secrets"`
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Streams []struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
MaxAgeDays int `json:"max-age-days"`
|
||||
Grant struct {
|
||||
Config string `json:"config"`
|
||||
Events string `json:"events"`
|
||||
} `json:"grant"`
|
||||
} `json:"streams"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Claims []json.RawMessage `json:"claims"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, map[string]map[string]any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byID := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
byID[r["id"].(string)] = r
|
||||
}
|
||||
return m, byID
|
||||
}
|
||||
|
||||
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
m, byID := readManifest(t)
|
||||
if m.Module != Module || !reflect.DeepEqual(m.Requires, []string{"postgres-database", "s3-bucket"}) || m.Contributes["postgres-database"]["name"] != "mail" {
|
||||
t.Fatalf("module %s, requires %v, contributes %v", m.Module, m.Requires, m.Contributes)
|
||||
}
|
||||
// mesh_<node>_<slug> fits the S3 access key's 20 characters on every node.
|
||||
if len("mesh_shanks_"+m.Slug) > 20 {
|
||||
t.Fatalf("the slug %q overflows an S3 access key", m.Slug)
|
||||
}
|
||||
fam := m.OwnSecrets[SecretFamily+"*"]
|
||||
if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 1 {
|
||||
t.Fatalf("own secrets %v", m.OwnSecrets)
|
||||
}
|
||||
var names []string
|
||||
for _, tool := range (&Bundle{}).Tools() {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, []string{"mail_accounts", "mail_search", "mail_read", "mail_health"}) {
|
||||
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
|
||||
}
|
||||
// Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted
|
||||
// config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream.
|
||||
var events []string
|
||||
for _, c := range Changes {
|
||||
events = append(events, EventKey("*", c))
|
||||
}
|
||||
if len(m.Emits) != 0 || len(m.Streams) != 1 || m.Streams[0].Name != "mail" || !reflect.DeepEqual(m.Streams[0].Events, events) ||
|
||||
m.Streams[0].MaxAgeDays != 7 || m.Streams[0].Grant.Config != "grants" || m.Streams[0].Grant.Events != "mail.{key}.**" {
|
||||
t.Fatalf("emits %v, streams %+v; the code says %v", m.Emits, m.Streams, events)
|
||||
}
|
||||
// It reads its own mail and calls nothing, consumes nothing and claims no seat.
|
||||
if len(m.Consumes)+len(m.Invokes)+len(m.Claims) != 0 {
|
||||
t.Fatalf("consumes %v, invokes %v, claims %v", m.Consumes, m.Invokes, m.Claims)
|
||||
}
|
||||
// The two settings are trusted, and their defaults are an empty account list and no grant.
|
||||
for id, want := range map[string]string{"accounts": `{"accounts":{}}`, "grants": `{"grants":{}}`} {
|
||||
r := byID[id]
|
||||
if r == nil || r["trusted"] != true || r["merge"] != "json" || !reflect.DeepEqual(r["takes-settings"], []any{id}) {
|
||||
t.Fatalf("%s: %v", id, r)
|
||||
}
|
||||
if strings.Join(strings.Fields(r["content"].(string)), "") != want {
|
||||
t.Fatalf("%s's default is %q", id, r["content"])
|
||||
}
|
||||
if _, err := ParseAccounts([]byte(byID["accounts"]["content"].(string))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := m.Build.Artifacts
|
||||
if len(a) != 1 || a[0].From != "cmd/mesh-mail" || a[0].Binary != Module || !reflect.DeepEqual(a[0].Loads, []string{Module}) {
|
||||
t.Fatalf("build %+v", m.Build)
|
||||
}
|
||||
for k, v := range map[string]string{"MESH_MAIL_ACCOUNTS": "accounts", "MESH_MAIL_GRANTS": "grants", "MESH_MAIL_STORE": "store", "DATABASE_URL_FILE": "database-url"} {
|
||||
if a[0].Env[k] != byID[v]["path"] {
|
||||
t.Errorf("the bundle reads %s at %s, and the manifest writes %s at %v", k, a[0].Env[k], v, byID[v]["path"])
|
||||
}
|
||||
}
|
||||
if a[0].Env["MESH_MAIL_SECRETS"] != "${dir:state}" {
|
||||
t.Errorf("the secrets directory is %s", a[0].Env["MESH_MAIL_SECRETS"])
|
||||
}
|
||||
}
|
||||
|
||||
// Its processes: the preparation once, keep judged by its unit, watch by mail_health (ADR 0359 §6), the watch writing
|
||||
// the verdict the bundle's tool reads, as the account the bundle runs as.
|
||||
func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
|
||||
m, byID := readManifest(t)
|
||||
prep, keep, watch := byID["prepare"], byID["keep"], byID["watch"]
|
||||
if prep == nil || prep["run-once"] != true || prep["run"].([]any)[1] != "prepare" {
|
||||
t.Fatalf("prepare %v", prep)
|
||||
}
|
||||
if keep == nil || keep["run"].([]any)[1] != "keep" || keep["health"].(map[string]any)["kind"] != "unit" {
|
||||
t.Fatalf("keep %v", keep)
|
||||
}
|
||||
h, _ := watch["health"].(map[string]any)
|
||||
if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool || h["needs"] != "postgres-database" {
|
||||
t.Fatalf("watch %v", watch)
|
||||
}
|
||||
if watch["user"] != "${machine:account}" || watch["env"].(map[string]any)["MESH_MAIL_STATUS"] != m.Build.Artifacts[0].Env["MESH_MAIL_STATUS"] {
|
||||
t.Fatalf("watch writes another status than the tool reads, or as another account: %v", watch)
|
||||
}
|
||||
// keep runs as the machine's root, which the own secrets' files belong to; it starts again when its settings or
|
||||
// its store change.
|
||||
if _, as := keep["user"]; as {
|
||||
t.Fatalf("keep runs as %v", keep["user"])
|
||||
}
|
||||
if !reflect.DeepEqual(keep["restart-on"], []any{"database-url", "store", "accounts"}) {
|
||||
t.Fatalf("keep restarts on %v", keep["restart-on"])
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
-- mesh-mail's index (novox/hq ADR 0359 §3): one row per message and folder, what is known of each folder, each
|
||||
-- account's state as its keeper said it, and the events written with the change they say, until they are said.
|
||||
create table folder (
|
||||
account text not null,
|
||||
name text not null,
|
||||
uidvalidity bigint not null,
|
||||
messages integer not null default 0,
|
||||
synced timestamptz not null default now(),
|
||||
primary key (account, name));
|
||||
|
||||
create table message (
|
||||
account text not null,
|
||||
folder text not null,
|
||||
uid bigint not null,
|
||||
uidvalidity bigint not null,
|
||||
message_id text not null default '',
|
||||
in_reply_to text not null default '',
|
||||
date timestamptz not null,
|
||||
sender jsonb not null default '[]',
|
||||
recipients jsonb not null default '[]',
|
||||
copied jsonb not null default '[]',
|
||||
people text not null default '',
|
||||
subject text not null default '',
|
||||
size bigint not null default 0,
|
||||
flags text[] not null default '{}',
|
||||
attachments jsonb not null default '[]',
|
||||
body text not null default '',
|
||||
search tsvector generated always as (
|
||||
to_tsvector('simple'::regconfig, subject || ' ' || people || ' ' || body)) stored,
|
||||
primary key (account, folder, uid));
|
||||
create index message_search on message using gin (search);
|
||||
create index message_date on message (account, date desc);
|
||||
|
||||
create table account_state (
|
||||
account text primary key,
|
||||
state text not null,
|
||||
why text not null default '',
|
||||
heard timestamptz,
|
||||
synced timestamptz,
|
||||
said timestamptz not null default now());
|
||||
|
||||
create table outbox (
|
||||
id bigserial primary key,
|
||||
key text not null,
|
||||
body jsonb not null,
|
||||
made timestamptz not null default now());
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
// A message's text and its attachments, read from its MIME source. The text is kept in the index for search and served
|
||||
// through mail_read; each attachment goes to the attachment store once, by its SHA-256 (novox/hq ADR 0359 §3).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
_ "github.com/emersion/go-message/charset" // the charsets mail is written in, beyond UTF-8 and ASCII
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
// Part is one attachment as read from a message.
|
||||
type Part struct {
|
||||
Name string
|
||||
Type string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// MostBodyText bounds the text kept per message: a longer body is cut, and says so.
|
||||
const MostBodyText = 256 << 10
|
||||
|
||||
// Parsed is a message's text and its attachments.
|
||||
type Parsed struct {
|
||||
Text string
|
||||
Attachments []Part
|
||||
}
|
||||
|
||||
// ParseMessage reads a message's text (its plain part; else its HTML part as text) and its attachments. A message the
|
||||
// reader cannot follow keeps what was read before the fault, and the error says what.
|
||||
func ParseMessage(raw []byte) (Parsed, error) {
|
||||
var p Parsed
|
||||
r, err := mail.CreateReader(bytes.NewReader(raw))
|
||||
if r == nil {
|
||||
return p, err
|
||||
}
|
||||
var plain, htmlText string
|
||||
for {
|
||||
part, err := r.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return finish(p, plain, htmlText), err
|
||||
}
|
||||
switch h := part.Header.(type) {
|
||||
case *mail.InlineHeader:
|
||||
ct, params, _ := h.ContentType()
|
||||
_, disp, _ := h.ContentDisposition()
|
||||
body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20))
|
||||
if readErr != nil {
|
||||
return finish(p, plain, htmlText), readErr
|
||||
}
|
||||
name := params["name"]
|
||||
if name == "" {
|
||||
name = disp["filename"]
|
||||
}
|
||||
switch {
|
||||
case ct == "text/plain" && plain == "" && name == "":
|
||||
plain = string(body)
|
||||
case ct == "text/html" && htmlText == "" && name == "":
|
||||
htmlText = HTMLText(string(body))
|
||||
case !strings.HasPrefix(ct, "text/") || name != "":
|
||||
// An inline image or a named inline file is an attachment too.
|
||||
p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body})
|
||||
}
|
||||
case *mail.AttachmentHeader:
|
||||
ct, _, _ := h.ContentType()
|
||||
name, _ := h.Filename()
|
||||
body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20))
|
||||
if readErr != nil {
|
||||
return finish(p, plain, htmlText), readErr
|
||||
}
|
||||
p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body})
|
||||
}
|
||||
}
|
||||
return finish(p, plain, htmlText), nil
|
||||
}
|
||||
|
||||
func finish(p Parsed, plain, htmlText string) Parsed {
|
||||
p.Text = plain
|
||||
if strings.TrimSpace(p.Text) == "" {
|
||||
p.Text = htmlText
|
||||
}
|
||||
p.Text = strings.ToValidUTF8(p.Text, "�")
|
||||
if len(p.Text) > MostBodyText {
|
||||
p.Text = strings.ToValidUTF8(p.Text[:MostBodyText], "") + "\n[the text goes on; it was cut at 256 KiB]"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func orOctets(ct string) string {
|
||||
if ct == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if t, _, err := mime.ParseMediaType(ct); err == nil {
|
||||
return t
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
var (
|
||||
scriptsAndStyles = regexp.MustCompile(`(?is)<(script|style|head)\b.*?</(script|style|head)>`)
|
||||
breaks = regexp.MustCompile(`(?i)<(br|/p|/div|/tr|/li|/h[1-6])\b[^>]*>`)
|
||||
tags = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
blankLines = regexp.MustCompile(`\n[ \t]*\n([ \t]*\n)+`)
|
||||
)
|
||||
|
||||
// HTMLText is an HTML body as plain text: no script, style or markup, the entities read, paragraphs kept as lines.
|
||||
// Text only, so nothing of the sender's markup ever reaches whoever reads it.
|
||||
func HTMLText(s string) string {
|
||||
s = scriptsAndStyles.ReplaceAllString(s, "")
|
||||
s = breaks.ReplaceAllString(s, "\n")
|
||||
s = tags.ReplaceAllString(s, "")
|
||||
s = html.UnescapeString(s)
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
s = blankLines.ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
// The attachment store (novox/hq ADR 0359 §3): each attachment kept once, by its SHA-256, in the bucket the `minio`
|
||||
// module provides this module through the `s3-bucket` provision. The mesh writes the bucket's facts and its key into
|
||||
// one file (MESH_MAIL_STORE); this speaks S3 to it with the standard library alone: a HEAD, a PUT and a GET, each
|
||||
// signed with AWS Signature Version 4, path-style, as an S3-compatible object store answers them.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Objects is the attachment store.
|
||||
type Objects interface {
|
||||
// Bucket is the bucket's name, as an event names it.
|
||||
Bucket() string
|
||||
// Has says whether the key is kept.
|
||||
Has(ctx context.Context, key string) (bool, error)
|
||||
// Put keeps the bytes under the key.
|
||||
Put(ctx context.Context, key, mediaType string, data []byte) error
|
||||
// Get is the bytes kept under the key.
|
||||
Get(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Keep stores an attachment once by its SHA-256 and answers its reference: the same file mailed ten times is one
|
||||
// object.
|
||||
func Keep(ctx context.Context, o Objects, data []byte, mediaType, name string) (AttachmentRef, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
has, err := o.Has(ctx, key)
|
||||
if err != nil {
|
||||
return AttachmentRef{}, err
|
||||
}
|
||||
if !has {
|
||||
if err := o.Put(ctx, key, mediaType, data); err != nil {
|
||||
return AttachmentRef{}, err
|
||||
}
|
||||
}
|
||||
return AttachmentRef{Bucket: o.Bucket(), Key: key, Size: int64(len(data)), Type: mediaType, Name: name}, nil
|
||||
}
|
||||
|
||||
// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret.
|
||||
type StoreFacts struct {
|
||||
Scheme string `json:"scheme"`
|
||||
At string `json:"at"`
|
||||
Port string `json:"port"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Key string `json:"key"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// ReadStoreFacts reads the store's facts.
|
||||
func ReadStoreFacts(path string) (StoreFacts, error) {
|
||||
var f StoreFacts
|
||||
if path == "" {
|
||||
return f, errors.New("the module was started without its store's facts (MESH_MAIL_STORE)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("the store's facts cannot be read: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return f, fmt.Errorf("the store's facts are not the module's: %v", err)
|
||||
}
|
||||
if f.At == "" || f.Bucket == "" || f.Key == "" || f.Secret == "" {
|
||||
return f, errors.New("the store's facts name no address, bucket or key")
|
||||
}
|
||||
if f.Scheme == "" {
|
||||
f.Scheme = "http"
|
||||
}
|
||||
if f.Region == "" {
|
||||
f.Region = "us-east-1"
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// S3 is the attachment store over S3.
|
||||
type S3 struct {
|
||||
Endpoint string // scheme://host[:port]
|
||||
Name string
|
||||
Region string
|
||||
Access string
|
||||
Secret string
|
||||
Client *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewS3 is the store the facts name.
|
||||
func NewS3(f StoreFacts) *S3 {
|
||||
host := f.At
|
||||
if f.Port != "" {
|
||||
host += ":" + f.Port
|
||||
}
|
||||
return &S3{Endpoint: f.Scheme + "://" + host, Name: f.Bucket, Region: f.Region, Access: f.Key, Secret: f.Secret,
|
||||
Client: &http.Client{Timeout: 2 * time.Minute}, Now: time.Now}
|
||||
}
|
||||
|
||||
func (s *S3) Bucket() string { return s.Name }
|
||||
|
||||
func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) {
|
||||
u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mediaType != "" {
|
||||
req.Header.Set("Content-Type", mediaType)
|
||||
}
|
||||
req.ContentLength = int64(len(body))
|
||||
Sign(req, body, s.Access, s.Secret, s.Region, s.Now().UTC())
|
||||
return s.Client.Do(req)
|
||||
}
|
||||
|
||||
func (s *S3) Has(ctx context.Context, key string) (bool, error) {
|
||||
resp, err := s.do(ctx, http.MethodHead, key, "", nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the attachment store cannot be reached: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("the attachment store answered %s to asking for %s", resp.Status, key)
|
||||
}
|
||||
|
||||
func (s *S3) Put(ctx context.Context, key, mediaType string, data []byte) error {
|
||||
resp, err := s.do(ctx, http.MethodPut, key, mediaType, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the attachment store cannot be reached: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
said, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("the attachment store refused %s: %s %s", key, resp.Status, strings.TrimSpace(string(said)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
resp, err := s.do(ctx, http.MethodGet, key, "", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the attachment store cannot be reached: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("the attachment store answered %s for %s", resp.Status, key)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// Sign signs a request with AWS Signature Version 4 for S3: every header it carries but the agent and the length is
|
||||
// signed, with the payload's hash.
|
||||
func Sign(req *http.Request, body []byte, access, secret, region string, now time.Time) {
|
||||
payload := sha256.Sum256(body)
|
||||
payloadHex := hex.EncodeToString(payload[:])
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
day := now.Format("20060102")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", payloadHex)
|
||||
|
||||
headers := map[string]string{"host": req.URL.Host}
|
||||
for k, v := range req.Header {
|
||||
lk := strings.ToLower(k)
|
||||
if lk == "user-agent" || lk == "content-length" || lk == "authorization" {
|
||||
continue
|
||||
}
|
||||
headers[lk] = strings.TrimSpace(strings.Join(v, ","))
|
||||
}
|
||||
names := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var canonHeaders strings.Builder
|
||||
for _, k := range names {
|
||||
canonHeaders.WriteString(k + ":" + headers[k] + "\n")
|
||||
}
|
||||
signed := strings.Join(names, ";")
|
||||
path := req.URL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
canonical := strings.Join([]string{req.Method, path, canonicalQuery(req.URL.Query()), canonHeaders.String(), signed, payloadHex}, "\n")
|
||||
scope := day + "/" + region + "/s3/aws4_request"
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
toSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sum[:])
|
||||
key := hmacOf([]byte("AWS4"+secret), day)
|
||||
key = hmacOf(key, region)
|
||||
key = hmacOf(key, "s3")
|
||||
key = hmacOf(key, "aws4_request")
|
||||
sig := hex.EncodeToString(hmacOf(key, toSign))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+access+"/"+scope+", SignedHeaders="+signed+", Signature="+sig)
|
||||
}
|
||||
|
||||
func canonicalQuery(q url.Values) string {
|
||||
keys := make([]string, 0, len(q))
|
||||
for k := range q {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var parts []string
|
||||
for _, k := range keys {
|
||||
vs := append([]string(nil), q[k]...)
|
||||
sort.Strings(vs)
|
||||
for _, v := range vs {
|
||||
parts = append(parts, url.QueryEscape(k)+"="+strings.ReplaceAll(url.QueryEscape(v), "+", "%20"))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func hmacOf(key []byte, data string) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(data))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// ---- in memory ----------------------------------------------------------------------------------------------------
|
||||
|
||||
type memObjects struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
kept map[string][]byte
|
||||
puts int
|
||||
}
|
||||
|
||||
func newMemObjects() *memObjects { return &memObjects{name: "mail-test", kept: map[string][]byte{}} }
|
||||
|
||||
func (m *memObjects) Bucket() string { return m.name }
|
||||
|
||||
func (m *memObjects) Has(_ context.Context, key string) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.kept[key]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (m *memObjects) Put(_ context.Context, key, _ string, data []byte) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.kept[key] = append([]byte(nil), data...)
|
||||
m.puts++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
b, ok := m.kept[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not kept", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The signature is AWS Signature Version 4 as S3 documents it: its own worked example, "GET Object", signs to the
|
||||
// signature AWS publishes.
|
||||
func TestTheSignatureIsTheOneS3Documents(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/test.txt", nil)
|
||||
req.Header.Set("Range", "bytes=0-9")
|
||||
Sign(req, nil, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "us-east-1", time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
|
||||
want := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " +
|
||||
"SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
|
||||
if got := req.Header.Get("Authorization"); got != want {
|
||||
t.Fatalf("signed\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An attachment is kept once by its SHA-256: the same file twice is one PUT, and it reads back.
|
||||
func TestAnAttachmentIsKeptOnceByItsHash(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
kept := map[string][]byte{}
|
||||
puts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 Credential=mesh_novox_mail/") {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
if _, ok := kept[r.URL.Path]; !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
case http.MethodPut:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
kept[r.URL.Path] = body
|
||||
puts++
|
||||
case http.MethodGet:
|
||||
b, ok := kept[r.URL.Path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "store.json")
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
writeFile(t, path, `{"scheme": "http", "at": "`+host+`", "port": "`+port+`", "bucket": "mesh-novox-mail", "region": "eu-west",
|
||||
"key": "mesh_novox_mail", "secret": "s3cr3t"}`)
|
||||
facts, err := ReadStoreFacts(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s3 := NewS3(facts)
|
||||
ctx := context.Background()
|
||||
a, err := Keep(ctx, s3, []byte("same file"), "text/plain", "a.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Keep(ctx, s3, []byte("same file"), "text/plain", "b.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Key != b.Key || a.Bucket != "mesh-novox-mail" || puts != 1 || len(a.Key) != 64 {
|
||||
t.Fatalf("%+v %+v, %d put(s)", a, b, puts)
|
||||
}
|
||||
if _, ok := kept["/mesh-novox-mail/"+a.Key]; !ok {
|
||||
t.Fatalf("kept %v", kept)
|
||||
}
|
||||
got, err := s3.Get(ctx, a.Key)
|
||||
if err != nil || string(got) != "same file" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMessagesTextAndAttachmentsAreRead(t *testing.T) {
|
||||
p, err := ParseMessage([]byte(strings.ReplaceAll(attachedMail("x"), "\n", "\r\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(p.Text) != "Please find the invoice attached." || len(p.Attachments) != 1 ||
|
||||
p.Attachments[0].Name != "invoice.pdf" || p.Attachments[0].Type != "application/pdf" || string(p.Attachments[0].Data) != attachmentData {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
html := "From: a@b.c\r\nSubject: s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<html><head><style>p{}</style></head><body><p>Hello & welcome</p><script>alert(1)</script><p>Bye</p></body></html>"
|
||||
p, err = ParseMessage([]byte(html))
|
||||
if err != nil || p.Text != "Hello & welcome\nBye" {
|
||||
t.Fatalf("%q %v", p.Text, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package main
|
||||
|
||||
// The index on the mesh's postgres (novox/hq ADR 0359 §3): the database the mesh provisions for this module through
|
||||
// the postgres-database provision, its address in the file DATABASE_URL_FILE names. Its schema is numbered migrations
|
||||
// in migrations/, applied by the preparation step (`mesh-mail prepare`, ADR 0135) and recorded in the database itself;
|
||||
// a fresh database is given the consolidated schema, schema.sql, at once, with every migration recorded as applied.
|
||||
// Nothing else changes the schema: the keeper, the watch and the tools refuse a schema that is behind, and say so.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
//go:embed schema.sql
|
||||
var consolidated string
|
||||
|
||||
// Migration is one numbered change of the schema.
|
||||
type Migration struct {
|
||||
Number int
|
||||
Name string
|
||||
SQL string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
var migrationFile = regexp.MustCompile(`^(\d{4})-([a-z0-9-]+)\.sql$`)
|
||||
|
||||
// Migrations are the migrations this binary carries, in order, numbered from 0001 without a gap.
|
||||
func Migrations() ([]Migration, error) {
|
||||
entries, err := fs.ReadDir(migrationFiles, "migrations")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Migration
|
||||
for _, e := range entries {
|
||||
m := migrationFile.FindStringSubmatch(e.Name())
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("%s is not a migration's name (NNNN-what-it-does.sql)", e.Name())
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
body, err := fs.ReadFile(migrationFiles, path.Join("migrations", e.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
return nil, fmt.Errorf("%s is empty", e.Name())
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
out = append(out, Migration{Number: n, Name: m[2], SQL: string(body), Checksum: hex.EncodeToString(sum[:])})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Number < out[j].Number })
|
||||
for i, m := range out {
|
||||
if m.Number != i+1 {
|
||||
return nil, fmt.Errorf("the migrations are not numbered 0001 on without a gap: %04d is the %d. one", m.Number, i+1)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const ledger = `create table if not exists migration (
|
||||
number integer primary key,
|
||||
name text not null,
|
||||
checksum text not null,
|
||||
applied timestamptz not null default now())`
|
||||
|
||||
// lockKey is the advisory lock every migration run takes, so two preparations never interleave.
|
||||
const lockKey int64 = 4_250_359_001
|
||||
|
||||
// PgStore is the Store on postgres.
|
||||
type PgStore struct{ pool *pgxpool.Pool }
|
||||
|
||||
// PgStoreFromEnv opens the index the mesh provisioned.
|
||||
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
||||
file := os.Getenv("DATABASE_URL_FILE")
|
||||
if file == "" {
|
||||
return nil, errors.New("DATABASE_URL_FILE is not set: the index's database is a requirement the mesh resolves")
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the database's address cannot be read: %w", err)
|
||||
}
|
||||
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
// OpenPgStore opens the index at an address.
|
||||
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PgStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close closes the store.
|
||||
func (s *PgStore) Close() { s.pool.Close() }
|
||||
|
||||
// Prepare brings the schema to what this version needs, and says what it did.
|
||||
func (s *PgStore) Prepare(ctx context.Context) ([]string, error) {
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Release()
|
||||
if _, err := conn.Exec(ctx, `select pg_advisory_lock($1)`, lockKey); err != nil {
|
||||
return nil, fmt.Errorf("the migration lock cannot be taken: %w", err)
|
||||
}
|
||||
defer func() { _, _ = conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock($1)`, lockKey) }()
|
||||
if _, err := conn.Exec(ctx, ledger); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applied := map[int]string{}
|
||||
rows, err := conn.Query(ctx, `select number, checksum from migration`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
highest := 0
|
||||
for rows.Next() {
|
||||
var n int
|
||||
var sum string
|
||||
if err := rows.Scan(&n, &sum); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
applied[n] = sum
|
||||
highest = max(highest, n)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := func(tx pgx.Tx, m Migration) error {
|
||||
_, err := tx.Exec(ctx, `insert into migration (number, name, checksum) values ($1, $2, $3)`, m.Number, m.Name, m.Checksum)
|
||||
return err
|
||||
}
|
||||
if len(applied) == 0 {
|
||||
var exists bool
|
||||
if err := conn.QueryRow(ctx, `select to_regclass('message') is not null`).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, errors.New("the database holds the index's tables and no record of a migration: its contents are not known, so nothing is changed")
|
||||
}
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
if _, err := tx.Exec(ctx, consolidated); err != nil {
|
||||
return nil, fmt.Errorf("the consolidated schema failed, and nothing of it was kept: %w", err)
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if err := record(tx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{fmt.Sprintf("a fresh database: the consolidated schema, through migration %04d", len(migrations))}, nil
|
||||
}
|
||||
var problems []string
|
||||
var pending []Migration
|
||||
for _, m := range migrations {
|
||||
sum, ran := applied[m.Number]
|
||||
switch {
|
||||
case ran && sum != m.Checksum:
|
||||
problems = append(problems, fmt.Sprintf("migration %04d-%s ran, and its file has changed since: write a new one", m.Number, m.Name))
|
||||
case !ran && m.Number < highest:
|
||||
problems = append(problems, fmt.Sprintf("migration %04d-%s never ran, and %04d did: renumber it above %04d", m.Number, m.Name, highest, highest))
|
||||
case !ran:
|
||||
pending = append(pending, m)
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, errors.New("this database and these migrations disagree: " + strings.Join(problems, "; "))
|
||||
}
|
||||
var said []string
|
||||
for _, m := range pending {
|
||||
err := func() error {
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
if _, err := tx.Exec(ctx, m.SQL); err != nil {
|
||||
return fmt.Errorf("migration %04d-%s failed, and nothing it did was kept: %w", m.Number, m.Name, err)
|
||||
}
|
||||
if err := record(tx, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}()
|
||||
if err != nil {
|
||||
return said, err
|
||||
}
|
||||
said = append(said, fmt.Sprintf("migration %04d-%s applied", m.Number, m.Name))
|
||||
}
|
||||
if len(said) == 0 {
|
||||
said = []string{fmt.Sprintf("the schema is at migration %04d, as this version needs", len(migrations))}
|
||||
}
|
||||
return said, nil
|
||||
}
|
||||
|
||||
// Ready says why the index cannot serve this version, or nil.
|
||||
func (s *PgStore) Ready(ctx context.Context) error {
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var at int
|
||||
err = s.pool.QueryRow(ctx, `select coalesce(max(number), 0) from migration`).Scan(&at)
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "42P01" {
|
||||
at = 0
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("the database cannot be reached: %w", err)
|
||||
}
|
||||
if at != len(migrations) {
|
||||
return fmt.Errorf("the database's schema is at migration %04d and this version needs %04d: the preparation step "+
|
||||
"(mesh-mail prepare) has not run", at, len(migrations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Folder(ctx context.Context, account, folder string) (Folder, bool, error) {
|
||||
f := Folder{Account: account, Name: folder}
|
||||
var validity int64
|
||||
err := s.pool.QueryRow(ctx, `select uidvalidity, messages, synced from folder where account = $1 and name = $2`, account, folder).
|
||||
Scan(&validity, &f.Messages, &f.Synced)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return f, false, nil
|
||||
}
|
||||
f.UIDValidity = uint32(validity)
|
||||
return f, err == nil, err
|
||||
}
|
||||
|
||||
func (s *PgStore) SetFolder(ctx context.Context, f Folder) error {
|
||||
_, err := s.pool.Exec(ctx, `insert into folder (account, name, uidvalidity, messages, synced) values ($1, $2, $3, $4, $5)
|
||||
on conflict (account, name) do update set uidvalidity = excluded.uidvalidity, messages = excluded.messages, synced = excluded.synced`,
|
||||
f.Account, f.Name, int64(f.UIDValidity), f.Messages, f.Synced)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) DropFolder(ctx context.Context, account, folder string) error {
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2`, account, folder); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `delete from folder where account = $1 and name = $2`, account, folder)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PgStore) Flags(ctx context.Context, account, folder string) (map[uint32][]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `select uid, flags from message where account = $1 and folder = $2`, account, folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uint32][]string{}
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
var flags []string
|
||||
if err := rows.Scan(&uid, &flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[uint32(uid)] = flags
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func keepEvent(ctx context.Context, tx pgx.Tx, e *Event) error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(e.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `insert into outbox (key, body) values ($1, $2)`, e.Key, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) Add(ctx context.Context, m Message, e *Event) error {
|
||||
from, _ := json.Marshal(m.From)
|
||||
to, _ := json.Marshal(m.To)
|
||||
cc, _ := json.Marshal(orNone(m.Cc))
|
||||
atts, _ := json.Marshal(m.Attachments)
|
||||
flags := m.Flags
|
||||
if flags == nil {
|
||||
flags = []string{}
|
||||
}
|
||||
people := strings.Join([]string{peopleText(m.From), peopleText(m.To), peopleText(m.Cc)}, " ")
|
||||
// An address is one word to the search, so its parts are kept beside it: "bob" finds bob@example.org.
|
||||
people += " " + strings.NewReplacer("@", " ", ".", " ", ",", " ").Replace(people)
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `insert into message (account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender,
|
||||
recipients, copied, people, subject, size, flags, attachments, body)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
on conflict (account, folder, uid) do update set uidvalidity = excluded.uidvalidity, message_id = excluded.message_id,
|
||||
in_reply_to = excluded.in_reply_to, date = excluded.date, sender = excluded.sender, recipients = excluded.recipients,
|
||||
copied = excluded.copied, people = excluded.people, subject = excluded.subject, size = excluded.size,
|
||||
flags = excluded.flags, attachments = excluded.attachments, body = excluded.body`,
|
||||
m.Account, m.Folder, int64(m.UID), int64(m.UIDValidity), m.MessageID, m.InReplyTo, m.Date, from, to, cc, people,
|
||||
strings.ToValidUTF8(strings.ReplaceAll(m.Subject, "\x00", ""), ""), m.Size, flags, atts,
|
||||
strings.ReplaceAll(m.Body, "\x00", "")); err != nil {
|
||||
return err
|
||||
}
|
||||
return keepEvent(ctx, tx, e)
|
||||
})
|
||||
}
|
||||
|
||||
func orNone(ps []Person) []Person {
|
||||
if ps == nil {
|
||||
return []Person{}
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
func (s *PgStore) SetFlags(ctx context.Context, account, folder string, uid uint32, flags []string, e *Event) error {
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `update message set flags = $4 where account = $1 and folder = $2 and uid = $3`,
|
||||
account, folder, int64(uid), flags); err != nil {
|
||||
return err
|
||||
}
|
||||
return keepEvent(ctx, tx, e)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PgStore) Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error {
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2 and uid = $3`, account, folder, int64(uid)); err != nil {
|
||||
return err
|
||||
}
|
||||
return keepEvent(ctx, tx, e)
|
||||
})
|
||||
}
|
||||
|
||||
const messageColumns = `account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender, recipients, copied, subject,
|
||||
size, flags, attachments`
|
||||
|
||||
func scanMessage(row pgx.Row, withBody bool) (Message, error) {
|
||||
var m Message
|
||||
var uid, validity int64
|
||||
var from, to, cc, atts []byte
|
||||
dest := []any{&m.Account, &m.Folder, &uid, &validity, &m.MessageID, &m.InReplyTo, &m.Date, &from, &to, &cc, &m.Subject,
|
||||
&m.Size, &m.Flags, &atts}
|
||||
if withBody {
|
||||
dest = append(dest, &m.Body)
|
||||
}
|
||||
if err := row.Scan(dest...); err != nil {
|
||||
return m, err
|
||||
}
|
||||
m.UID, m.UIDValidity, m.Date = uint32(uid), uint32(validity), m.Date.UTC()
|
||||
for _, f := range []struct {
|
||||
raw []byte
|
||||
into any
|
||||
}{{from, &m.From}, {to, &m.To}, {cc, &m.Cc}, {atts, &m.Attachments}} {
|
||||
if err := json.Unmarshal(f.raw, f.into); err != nil {
|
||||
return m, err
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error) {
|
||||
m, err := scanMessage(s.pool.QueryRow(ctx, `select `+messageColumns+`, body from message where account = $1 and folder = $2 and uid = $3`,
|
||||
account, folder, int64(uid)), true)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Message{}, false, nil
|
||||
}
|
||||
return m, err == nil, err
|
||||
}
|
||||
|
||||
func (s *PgStore) Search(ctx context.Context, q Query) ([]Message, error) {
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `select `+messageColumns+` from message
|
||||
where account = any($1) and ($2 = '' or folder = $2) and search @@ plainto_tsquery('simple'::regconfig, $3)
|
||||
order by date desc limit $4`, q.Accounts, q.Folder, q.Text, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
m, err := scanMessage(rows, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Folders(ctx context.Context, account string) ([]Folder, error) {
|
||||
rows, err := s.pool.Query(ctx, `select name, uidvalidity, messages, synced from folder where account = $1 order by name`, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Folder
|
||||
for rows.Next() {
|
||||
f := Folder{Account: account}
|
||||
var validity int64
|
||||
if err := rows.Scan(&f.Name, &validity, &f.Messages, &f.Synced); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.UIDValidity, f.Synced = uint32(validity), f.Synced.UTC()
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Pending(ctx context.Context, limit int) ([]Event, error) {
|
||||
rows, err := s.pool.Query(ctx, `select id, key, body from outbox order by id limit $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
var body []byte
|
||||
if err := rows.Scan(&e.ID, &e.Key, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &e.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Said(ctx context.Context, ids []int64) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from outbox where id = any($1)`, ids)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) SetState(ctx context.Context, st AccountState) error {
|
||||
_, err := s.pool.Exec(ctx, `insert into account_state (account, state, why, heard, synced, said) values ($1, $2, $3, $4, $5, $6)
|
||||
on conflict (account) do update set state = excluded.state, why = excluded.why, heard = excluded.heard,
|
||||
synced = excluded.synced, said = excluded.said`,
|
||||
st.Account, st.State, st.Why, nullTime(st.Heard), nullTime(st.Synced), st.Said)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullTime(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func (s *PgStore) States(ctx context.Context) (map[string]AccountState, error) {
|
||||
rows, err := s.pool.Query(ctx, `select account, state, why, heard, synced, said from account_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]AccountState{}
|
||||
for rows.Next() {
|
||||
var st AccountState
|
||||
var heard, synced *time.Time
|
||||
if err := rows.Scan(&st.Account, &st.State, &st.Why, &heard, &synced, &st.Said); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if heard != nil {
|
||||
st.Heard = heard.UTC()
|
||||
}
|
||||
if synced != nil {
|
||||
st.Synced = synced.UTC()
|
||||
}
|
||||
st.Said = st.Said.UTC()
|
||||
out[st.Account] = st
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The schema is numbered migrations and a consolidated schema for a fresh database (the mesh's rule: both). A
|
||||
// database given the consolidated schema and one given every migration in order hold the same tables and columns.
|
||||
func TestTheConsolidatedSchemaIsTheMigrationsApplied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fresh := pgStore(t)
|
||||
stepped, err := OpenPgStore(ctx, freshDatabase(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stepped.Close()
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if _, err := stepped.pool.Exec(ctx, m.SQL); err != nil {
|
||||
t.Fatalf("migration %04d: %v", m.Number, err)
|
||||
}
|
||||
}
|
||||
a, b := shape(t, fresh), shape(t, stepped)
|
||||
if !reflect.DeepEqual(a, b) || len(a) < 25 {
|
||||
t.Fatalf("the consolidated schema and the migrations differ:\nconsolidated %v\nmigrations %v", a, b)
|
||||
}
|
||||
if !strings.Contains(consolidated, "through migration 0001") || len(migrations) != 1 {
|
||||
t.Fatal("the consolidated schema does not say how far it is consolidated")
|
||||
}
|
||||
// Prepared twice, it changes nothing; and it is ready.
|
||||
if said, err := fresh.Prepare(ctx); err != nil || !strings.Contains(said[0], "as this version needs") {
|
||||
t.Fatalf("%v %v", said, err)
|
||||
}
|
||||
if err := fresh.Ready(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stepped.Ready(ctx); err == nil {
|
||||
t.Fatal("a database whose migrations are not recorded is ready")
|
||||
}
|
||||
}
|
||||
|
||||
func shape(t *testing.T, s *PgStore) []string {
|
||||
t.Helper()
|
||||
rows, err := s.pool.Query(context.Background(), `select table_name || '.' || column_name || ' ' || data_type
|
||||
from information_schema.columns where table_schema = 'public' and table_name <> 'migration' order by 1`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
_ = rows.Scan(&c)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The search finds by every word, across the accounts asked only, newest first, and answers no body.
|
||||
func TestTheIndexSearchesByWordAndAccount(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
for i, subj := range []string{"the invoice of october", "lunch", "another invoice"} {
|
||||
m := Message{Account: "mesh", Folder: "INBOX", UID: uint32(i + 1), Subject: subj, Date: judgedAt.AddDate(0, 0, i),
|
||||
From: []Person{{Address: "bob@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "text " + subj}
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
other := Message{Account: "gmail", Folder: "INBOX", UID: 1, Subject: "invoice elsewhere", Date: judgedAt, From: []Person{}, To: []Person{},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}}
|
||||
if err := st.Add(ctx, other, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := st.Search(ctx, Query{Text: "invoice", Accounts: []string{"mesh"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 2 || hits[0].Subject != "another invoice" || hits[0].Body != "" {
|
||||
t.Fatalf("%+v", hits)
|
||||
}
|
||||
if hits, _ := st.Search(ctx, Query{Text: "invoice october", Accounts: []string{"mesh"}}); len(hits) != 1 {
|
||||
t.Fatalf("%+v", hits)
|
||||
}
|
||||
if hits, _ := st.Search(ctx, Query{Text: "bob", Accounts: []string{"mesh"}}); len(hits) != 3 {
|
||||
t.Fatalf("by sender: %+v", hits)
|
||||
}
|
||||
got, ok, err := st.Get(ctx, "mesh", "INBOX", 2)
|
||||
if err != nil || !ok || got.Body != "text lunch" {
|
||||
t.Fatalf("%+v %v %v", got, ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
-- The consolidated schema, through migration 0001: what a fresh database is given at once (the mesh's rule: a
|
||||
-- numbered migration for the running store, and this for a fresh install).
|
||||
create table folder (
|
||||
account text not null,
|
||||
name text not null,
|
||||
uidvalidity bigint not null,
|
||||
messages integer not null default 0,
|
||||
synced timestamptz not null default now(),
|
||||
primary key (account, name));
|
||||
|
||||
create table message (
|
||||
account text not null,
|
||||
folder text not null,
|
||||
uid bigint not null,
|
||||
uidvalidity bigint not null,
|
||||
message_id text not null default '',
|
||||
in_reply_to text not null default '',
|
||||
date timestamptz not null,
|
||||
sender jsonb not null default '[]',
|
||||
recipients jsonb not null default '[]',
|
||||
copied jsonb not null default '[]',
|
||||
people text not null default '',
|
||||
subject text not null default '',
|
||||
size bigint not null default 0,
|
||||
flags text[] not null default '{}',
|
||||
attachments jsonb not null default '[]',
|
||||
body text not null default '',
|
||||
search tsvector generated always as (
|
||||
to_tsvector('simple'::regconfig, subject || ' ' || people || ' ' || body)) stored,
|
||||
primary key (account, folder, uid));
|
||||
create index message_search on message using gin (search);
|
||||
create index message_date on message (account, date desc);
|
||||
|
||||
create table account_state (
|
||||
account text primary key,
|
||||
state text not null,
|
||||
why text not null default '',
|
||||
heard timestamptz,
|
||||
synced timestamptz,
|
||||
said timestamptz not null default now());
|
||||
|
||||
create table outbox (
|
||||
id bigserial primary key,
|
||||
key text not null,
|
||||
body jsonb not null,
|
||||
made timestamptz not null default now());
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user