Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f555d197b |
@@ -41,4 +41,3 @@ checks/placement/placement
|
||||
# Built by their modules' build step (issue 1577), never committed.
|
||||
modules/forticlient/cmd/forticlient-tools/forticlient-tools
|
||||
modules/networkmanager/uplink-tools
|
||||
checks/words/words
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package nobinaries refuses a compiled program anywhere in the repository (issue 1577; first a checks/ binary slipped past a modules/-only rule): it is build output, made by the module's build step,
|
||||
// Package nobinaries refuses a compiled program under modules/ (issue 1577): it is build output, made by the module's build step,
|
||||
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
|
||||
package nobinaries
|
||||
|
||||
@@ -27,10 +27,10 @@ func Kind(head []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Files are the files of the catalogue's root that the change holds, anywhere: the tracked ones when the root is a git
|
||||
// checkout (an ignored binary a local build left is not the change's), else every file there but .git's.
|
||||
// Files are the files of the catalogue's root that the change holds under modules/: the tracked ones when the root is a git
|
||||
// checkout (an ignored binary a local build left is not the change's), else every file there.
|
||||
func Files(root string) ([]string, error) {
|
||||
cmd := exec.Command("git", "-C", root, "ls-files", "-z")
|
||||
cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "modules")
|
||||
if out, err := cmd.Output(); err == nil && len(out) > 0 {
|
||||
var files []string
|
||||
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
|
||||
@@ -41,16 +41,10 @@ func Files(root string) ([]string, error) {
|
||||
return files, nil
|
||||
}
|
||||
var files []string
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !d.Type().IsRegular() {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
files = append(files, rel)
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,6 @@ package nobinaries
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -17,7 +16,6 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
|
||||
"modules/e/short": []byte("\x7f"),
|
||||
"modules/f/empty": {},
|
||||
"modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O
|
||||
"checks/words/words": []byte("\x7fELF\x02\x01\x01\x00rest"), // outside modules/ too
|
||||
}
|
||||
var names []string
|
||||
for f, b := range files {
|
||||
@@ -34,8 +32,8 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c and checks/words/words only", got)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c only", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,36 +52,3 @@ func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
|
||||
t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f)
|
||||
}
|
||||
}
|
||||
|
||||
// A compiled program outside modules/ is refused too (a binary under checks/ slipped past the modules/-only rule): Files holds
|
||||
// every tracked file of a git checkout and every file of a plain folder, and Find names the binary in both.
|
||||
func TestAProgramUnderChecksIsRefused(t *testing.T) {
|
||||
for _, git := range []bool{true, false} {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "checks", "words", "words")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("\x7fELF\x02\x01\x01\x00rest"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if git {
|
||||
for _, args := range [][]string{{"init", "-q"}, {"add", "-A"}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
files, err := Files(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := Find(root, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Errorf("git=%v: found %v; wanted checks/words/words (ELF)", git, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
# every tracked file of the repository (git ls-files, no path limit): the whole tree, so this package's verdict is never kept.
|
||||
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
|
||||
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
|
||||
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
|
||||
# every file under modules/ (git ls-files and a walk).
|
||||
modules
|
||||
|
||||
@@ -206,9 +206,6 @@ func excerpt(s string, from, to int) string {
|
||||
return strings.Join(strings.Fields(s[a:b]), " ")
|
||||
}
|
||||
|
||||
// modulePath matches a string that is only a module or import path ("git.example.org/org/repo/go/pkg").
|
||||
var modulePath = regexp.MustCompile(`^[a-z0-9.-]+\.[a-z]+/[A-Za-z0-9_./-]+$`)
|
||||
|
||||
// goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them.
|
||||
func goStrings(path, rel string) ([]text, error) {
|
||||
src, err := os.ReadFile(path)
|
||||
@@ -234,11 +231,6 @@ func goStrings(path, rel string) ([]text, error) {
|
||||
if err != nil {
|
||||
v = lit
|
||||
}
|
||||
if modulePath.MatchString(v) {
|
||||
// An import path or another module path names code, not words an agent reads.
|
||||
cur, joining = nil, false
|
||||
continue
|
||||
}
|
||||
if cur != nil && joining {
|
||||
cur.s += v
|
||||
} else {
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// manifest's.
|
||||
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
||||
|
||||
// CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through.
|
||||
// CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
|
||||
const (
|
||||
CatalogueModules = "mesh-catalog.catalog_modules"
|
||||
CatalogueModule = "mesh-catalog.catalog_module"
|
||||
@@ -143,21 +143,21 @@ func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
|
||||
return out
|
||||
}
|
||||
|
||||
// Definitions is what the catalog's definitions pin in the store, and what of them could not be read.
|
||||
// Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
|
||||
type Definitions struct {
|
||||
// Pins are by Key(repository, digest).
|
||||
Pins map[string][]Pin
|
||||
// Read is how many definitions were read; Unread says, per module, why its definition was not.
|
||||
Read int
|
||||
Unread map[string]string
|
||||
// NotAsked says why the catalog could not be asked at all, when it could not.
|
||||
// NotAsked says why the catalogue could not be asked at all, when it could not.
|
||||
NotAsked string
|
||||
// NamesNotRead says why the store's older names could not be read, when they could not.
|
||||
NamesNotRead string
|
||||
Names StoreNames
|
||||
}
|
||||
|
||||
// Catalogue reads the definitions through the catalog's tools, and keeps each definition it read by its
|
||||
// Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
|
||||
// module and commit, which never change together.
|
||||
type Catalogue struct {
|
||||
Ask Asking
|
||||
@@ -171,21 +171,21 @@ type catalogueEntry struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Read asks the catalog for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
// Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
|
||||
if c == nil || c.Ask == nil {
|
||||
defs.NotAsked = "this bundle has no way to ask the catalog"
|
||||
defs.NotAsked = "this bundle has no way to ask the catalogue"
|
||||
return defs
|
||||
}
|
||||
raw, err := c.Ask(CatalogueModules, map[string]any{})
|
||||
if err != nil {
|
||||
defs.NotAsked = "the catalog's modules could not be asked: " + err.Error()
|
||||
defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
|
||||
return defs
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
defs.NotAsked = "the catalog refused its modules: " + lastLine(output)
|
||||
defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
|
||||
return defs
|
||||
}
|
||||
var list struct {
|
||||
@@ -193,7 +193,7 @@ func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
|
||||
defs.NotAsked = "the catalog's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
return defs
|
||||
}
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -54,7 +54,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
Name: "artifact_store_references",
|
||||
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
|
||||
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
|
||||
"record names it, and a definition in the catalog pins it by digest, under the store's current name or " +
|
||||
"record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
|
||||
"an older one: the module is said), the holder of a kept archive, " +
|
||||
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
|
||||
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
|
||||
|
||||
@@ -45,8 +45,8 @@ whenever the node's tool runtime collects the module's tools:
|
||||
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh` (with a `timeout` of 660000 ms, mesh_wait's longest wait and a minute, so the agent's one-minute default does not cut a wait, issue 1871), and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` (660000) in `env` beside the operator's own, the mesh's key winning |
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
||||
|
||||
|
||||
@@ -83,20 +83,6 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||
for k := range MarketplaceKeys() {
|
||||
delete(m, k)
|
||||
}
|
||||
// The wait bound is new since the TypeScript (issue 1871), and is held below.
|
||||
if env, ok := m["env"].(map[string]any); ok {
|
||||
delete(env, "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT")
|
||||
if len(env) == 0 {
|
||||
delete(m, "env")
|
||||
}
|
||||
}
|
||||
}
|
||||
if m, ok := a.(map[string]any); ok && file == "managed-mcp.json" {
|
||||
if servers, ok := m["mcpServers"].(map[string]any); ok {
|
||||
if entry, ok := servers["mesh"].(map[string]any); ok {
|
||||
delete(entry, "timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
@@ -118,21 +104,6 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
|
||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||
t.Fatalf("%v", mcp.MCPServers)
|
||||
}
|
||||
// The entry bounds a call by mesh_wait's longest wait and the idle bound is laid over the operator's env
|
||||
// (issue 1871): a session's wait is not cut at the agent's one-minute default.
|
||||
if mcp.MCPServers["mesh"]["timeout"] != float64(660000) {
|
||||
t.Errorf("the mesh entry's timeout is %v", mcp.MCPServers["mesh"]["timeout"])
|
||||
}
|
||||
for _, held := range []map[string]any{nil, {"CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "1", "FOO": "bar"}} {
|
||||
var m struct {
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
got := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"env": held}}, nil, "/h", nil, Config{})
|
||||
_ = json.Unmarshal([]byte(got["managed-settings.json"]), &m)
|
||||
if m.Env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] != "660000" || (held != nil && m.Env["FOO"] != "bar") {
|
||||
t.Errorf("env %v rendered as %v", held, m.Env)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
||||
t.Fatal("rendering is not deterministic")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ package main
|
||||
// the file under the agent's managed directory did not change by a byte when the module moved to Go;
|
||||
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
|
||||
func instructionsText(node, role string) string {
|
||||
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||
node +
|
||||
"`\n- **Role:** " +
|
||||
role +
|
||||
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -99,27 +98,6 @@ func jsonFile(v any) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// MeshWaitBoundMillis is how long the agent may wait on the mesh's console for one call: mesh_wait's longest
|
||||
// wait (600 s, mesh-tools node-tools console.WaitLongest) plus a minute's margin. The agent bounds an HTTP
|
||||
// server's request by the largest of 60 s, the server entry's "timeout" and MCP_TIMEOUT, and a call that
|
||||
// receives nothing for CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (5 minutes by default) is aborted
|
||||
// (code.claude.com/docs/en/mcp.md). Without these every mesh_wait ended "The operation timed out." at a
|
||||
// minute, whatever its timeout (issue 1871).
|
||||
const MeshWaitBoundMillis = 660000
|
||||
|
||||
// meshWaitEnv lays the idle bound into the environment the settings give every session, the mesh's key winning
|
||||
// over the operator's, like the forge's keys.
|
||||
func meshWaitEnv(managed map[string]any) {
|
||||
env := map[string]any{}
|
||||
if held, ok := managed["env"].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] = strconv.Itoa(MeshWaitBoundMillis)
|
||||
managed["env"] = env
|
||||
}
|
||||
|
||||
// Render composes the three files. registered — what was registered through this module and applies
|
||||
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
||||
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||
@@ -141,7 +119,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
servers[name] = entry
|
||||
}
|
||||
}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console, "timeout": MeshWaitBoundMillis}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||
|
||||
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
||||
// through this module's tools is laid over it (novox/hq issue 340).
|
||||
@@ -165,7 +143,6 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
managed[key] = entries
|
||||
}
|
||||
forgeEnv(managed, facts.ForgeCredentials)
|
||||
meshWaitEnv(managed)
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
"withKey": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
},
|
||||
"plain": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
}
|
||||
@@ -312,15 +312,6 @@ read-only. With a `user` it adds the teams that user is in and its effective rig
|
||||
listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The
|
||||
tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only.
|
||||
|
||||
### Two settings have named tools, and a retargeted pull request is judged again
|
||||
|
||||
`gitea_api` refuses a repository's settings and a pull request's base (guard.ts). Issue 1422 note 7 gave each its tool:
|
||||
`gitea_default_branch_set` (owner, repo, branch, why) and `gitea_pull_base_set` (owner, repo, number, base, why). Both
|
||||
act as the tools' account, need a reason, refuse a branch the repository does not have, and answer the value before and
|
||||
after; a default must also be a branch a protection rule guards, and only an open pull request has its base changed.
|
||||
A pull request's announced head is kept under `owner/repo#number@base`, so a retarget (same head, new base) is
|
||||
announced again into the new base by the next poll instead of being skipped as already announced.
|
||||
|
||||
### The merging account writes the repositories the mesh delivers
|
||||
|
||||
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
|
||||
|
||||
@@ -834,30 +834,6 @@ export class GiteaClient {
|
||||
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
|
||||
}
|
||||
|
||||
/** One branch as the forge keeps it: whether a protection rule guards it; null when the repository has no such branch. */
|
||||
async branchState(owner: string, repo: string, branch: string): Promise<{ protected: boolean } | null> {
|
||||
try {
|
||||
const b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
|
||||
return { protected: Boolean(b?.protected) };
|
||||
} catch (err) {
|
||||
if (/: 404 /.test(err instanceof Error ? err.message : "")) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Make a branch the repository's default (issue 1422): what the forge shows, clones and opens pull requests against. */
|
||||
async setDefaultBranch(owner: string, repo: string, branch: string): Promise<GiteaRepo> {
|
||||
return GiteaClient.mapRepo(await this.request<any>(repoPath(owner, repo), { method: "PATCH", body: JSON.stringify({ default_branch: branch }) }));
|
||||
}
|
||||
|
||||
/** Change the branch an open pull request merges into (issue 1422). The head stays; the forge recomputes the diff. */
|
||||
async setPullBase(owner: string, repo: string, index: number, base: string): Promise<GiteaPull> {
|
||||
indexOf(index);
|
||||
return GiteaClient.mapPull(
|
||||
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ base }) }),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
|
||||
await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
// forge as themselves;
|
||||
// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps
|
||||
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
|
||||
// - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that
|
||||
// exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422);
|
||||
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed
|
||||
// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
|
||||
// that head;
|
||||
|
||||
+66
-67
@@ -53,9 +53,8 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { keepMainRules, mainRulesStep } from "./mainrules.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained,
|
||||
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js";
|
||||
import { actOn, announceNewHeads, startPass } from "./watch.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
|
||||
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { GateWriter } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||
@@ -139,7 +138,12 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
|
||||
const closedKey = `closed:${repo.full_name}#${pull.number}`;
|
||||
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
|
||||
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) await announceClosed(client, repo, pull);
|
||||
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
|
||||
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
|
||||
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
|
||||
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
||||
await siblingMoves(client, repo.full_name, pull.number);
|
||||
}
|
||||
announced.add(closedKey);
|
||||
continue;
|
||||
}
|
||||
@@ -164,23 +168,6 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
|
||||
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
|
||||
|
||||
/** A pull request closed without a merge, announced once (ADR 0239): by its event, or by the start's pass. */
|
||||
async function announceClosed(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
|
||||
const closedKey = `closed:${repo.full_name}#${pull.number}`;
|
||||
if (pull.merged || pull.state !== "closed" || announced.has(closedKey)) return false;
|
||||
announced.add(closedKey);
|
||||
try {
|
||||
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
|
||||
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
|
||||
} catch (err) {
|
||||
announced.delete(closedKey);
|
||||
throw err;
|
||||
}
|
||||
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
||||
await siblingMoves(client, repo.full_name, pull.number);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
|
||||
@@ -256,7 +243,7 @@ let heads: Announced = {};
|
||||
let primedPulls = false;
|
||||
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
|
||||
const key = headKey(repo.full_name, pull.number, pull.base);
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const sha = String(pull.head_sha ?? "");
|
||||
const before = heads[key];
|
||||
// Claimed before the first wait, so the other path finds it taken while this one is still announcing.
|
||||
@@ -294,18 +281,43 @@ async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf):
|
||||
}
|
||||
|
||||
let lastPullLook = "";
|
||||
/** The pass over every open pull request: run at the start and when the receiver listens, never on a timer (issue 1422). */
|
||||
async function passPulls(client: GiteaClient): Promise<void> {
|
||||
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
|
||||
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
|
||||
const SWEEP_MS = 10 * 60_000;
|
||||
let lastSweep = 0;
|
||||
async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
const began = new Date().toISOString();
|
||||
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
|
||||
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
|
||||
for (const repo of await client.listAllRepos()) {
|
||||
// Every open pull request, paged (novox/hq issue 579).
|
||||
const open = await client.listOpenPullsSince(repo.owner, repo.name, "");
|
||||
await announceNewHeads(repo.full_name, open, heads, primedPulls, dayAgo, {
|
||||
alreadyChecked: (pull) => alreadyChecked(client, repo, pull),
|
||||
announceHead: (pull) => announceHead(client, repo, pull),
|
||||
missed: (what) => pace.miss(what),
|
||||
});
|
||||
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
|
||||
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
|
||||
let sweep = Date.now() - lastSweep >= SWEEP_MS;
|
||||
let updated: { owner: string; name: string; updated_at?: string }[] = [];
|
||||
if (floor && !sweep) {
|
||||
try {
|
||||
updated = await client.searchOpenPulls(floor);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`);
|
||||
sweep = true;
|
||||
}
|
||||
}
|
||||
if (sweep) lastSweep = Date.now();
|
||||
for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) {
|
||||
// Every open pull request updated since the floor, paged (novox/hq issue 579): the newest 20 alone left a head
|
||||
// beyond them unannounced. A sweep and the first pass read every page.
|
||||
const open = await client.listOpenPullsSince(repo.owner, repo.name, sweep ? "" : floor);
|
||||
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
|
||||
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
|
||||
heads[key] = String(pull.head_sha);
|
||||
continue;
|
||||
}
|
||||
if (fresh) {
|
||||
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||
} else heads[key] = String(pull.head_sha);
|
||||
}
|
||||
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
||||
}
|
||||
if (changedTrunks) keepTrunks();
|
||||
@@ -485,9 +497,6 @@ const checks = new CheckStatuses({
|
||||
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what
|
||||
* gitea_events says of them (webhook.ts). */
|
||||
const pace = new Pace();
|
||||
/** Asks the pass over every repository again, shortly and once (set where the pass is made): an event that could not be
|
||||
* acted on is not lost until the next start. */
|
||||
let catchUp: () => void = () => {};
|
||||
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
|
||||
|
||||
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
|
||||
@@ -513,15 +522,22 @@ registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll),
|
||||
gates,
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
|
||||
/** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */
|
||||
const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise<void> => actOn<RepoOf, PullOf>(event, {
|
||||
getRepo: (o, r) => client.getRepo(o, r),
|
||||
getPull: (o, r, n) => client.getPullRequest(o, r, n) as Promise<PullOf & { state?: string; merged?: boolean }>,
|
||||
announceHead: (repo, pull) => announceHead(client, repo, pull as OpenOf),
|
||||
announceMerge: (repo, pull) => announceMerge(client, repo, pull),
|
||||
announceClosed: (repo, pull) => announceClosed(client, repo, pull),
|
||||
siblingMoves: (full, n) => siblingMoves(client, full, n),
|
||||
});
|
||||
/** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
|
||||
async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
|
||||
const b = event.body as { owner?: string; repo?: string; number?: number };
|
||||
if (!b.owner || !b.repo || !b.number) return;
|
||||
if (event.name === "forge.pull-request.closed") {
|
||||
// Closed unmerged is a sibling's move; a merge is weighed with its announcement below (ADR 0382 §5).
|
||||
if (!(event.body as { merged?: boolean }).merged) await siblingMoves(client, `${b.owner}/${b.repo}`, b.number);
|
||||
return;
|
||||
}
|
||||
const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name);
|
||||
const merged = event.name === "forge.pull-request.merged";
|
||||
if (!head && !merged) return;
|
||||
const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]);
|
||||
if (head && pull.state === "open") await announceHead(client, repo, pull);
|
||||
if (merged) await announceMerge(client, repo, pull);
|
||||
}
|
||||
|
||||
/**
|
||||
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
|
||||
@@ -547,12 +563,7 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
|
||||
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
|
||||
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
|
||||
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
|
||||
try {
|
||||
await actOnEvent(client, event);
|
||||
} catch (err) {
|
||||
catchUp();
|
||||
throw err;
|
||||
}
|
||||
await actOn(client, event);
|
||||
} });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
@@ -601,9 +612,9 @@ if (gitea) {
|
||||
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
|
||||
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
|
||||
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
|
||||
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below
|
||||
// runs again once it is listening, so nothing said before is missed; listenForForge says its own failure.
|
||||
const listenOk = listenForForge(client);
|
||||
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
|
||||
// listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
|
||||
void listenForForge(client);
|
||||
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
|
||||
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
|
||||
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
|
||||
@@ -635,20 +646,8 @@ if (gitea) {
|
||||
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
|
||||
tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000);
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
// **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over
|
||||
// every repository runs at the start and again once the receiver listens, so what happened while the module was away or
|
||||
// not yet listening is announced; passes run one at a time, a failed one is asked again a minute later.
|
||||
let passes: Promise<void> = Promise.resolve();
|
||||
const pass = (): Promise<void> => (passes = passes.catch(() => {}).then(() =>
|
||||
startPass(async () => { await pollMerged(client); await passPulls(client); }, { retryMs: 60_000 })));
|
||||
void pass();
|
||||
let catchUpAsked = false;
|
||||
catchUp = () => {
|
||||
if (catchUpAsked) return;
|
||||
catchUpAsked = true;
|
||||
setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000);
|
||||
};
|
||||
void listenOk.then((ok) => (ok ? pass() : undefined));
|
||||
tick(() => pollMerged(client), () => pace.every());
|
||||
tick(() => pollPulls(client), () => pace.every());
|
||||
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
|
||||
// handshake in 10 s, so the import must end while a refused bind is still asked again.
|
||||
bindInBackground([
|
||||
|
||||
@@ -141,19 +141,13 @@ export function planText(plan: ChangePlan): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */
|
||||
/** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
|
||||
export type Announced = Record<string, string>;
|
||||
|
||||
/** The key a pull request's announced head is kept under: `owner/repo#number@base`. The base is in it because a pull
|
||||
* request retargeted keeps its head, and a head judged into one base is not judged into another (issue 1422, de-spiegel #6). */
|
||||
export function headKey(full: string, number: number, base?: string): string {
|
||||
return `${full}#${number}@${base ?? ""}`;
|
||||
}
|
||||
|
||||
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not
|
||||
* open and is never asked about. */
|
||||
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[headKey(full, p.number, p.base)] !== p.head_sha);
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
|
||||
}
|
||||
|
||||
/** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
|
||||
|
||||
@@ -34,12 +34,6 @@ const state = {
|
||||
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
|
||||
admin: false,
|
||||
lastCommit: "2026-01-01T00:00:00Z",
|
||||
// The repository's default branch and pull request 7's base, as the forge keeps them (issue 1422 note 7).
|
||||
defaultBranch: "main",
|
||||
base: "main",
|
||||
prState: "open",
|
||||
// Branches the forge answers 404 for; "scratch" exists but no protection rule guards it.
|
||||
absent: ["nope"],
|
||||
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
|
||||
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
||||
// merge commit's own sha.
|
||||
@@ -72,10 +66,7 @@ beforeEach(() => {
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
state.trunk = TRUNK;
|
||||
state.emitted = [];
|
||||
state.rules = [protectedMain(), { rule_name: "release/*", enable_push: false }];
|
||||
state.defaultBranch = "main";
|
||||
state.base = "main";
|
||||
state.prState = "open";
|
||||
state.rules = [protectedMain()];
|
||||
state.admin = false;
|
||||
state.head = REVIEWED;
|
||||
state.landed = "";
|
||||
@@ -107,22 +98,12 @@ const server = createServer(async (req, res) => {
|
||||
state.asked.push(`${method} ${path}${url.search}`);
|
||||
if (method !== "GET") state.sent.push({ method, path, body });
|
||||
const repo = "/repos/novox/mesh-catalog";
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
if (method === "PATCH" && path === repo) {
|
||||
if (body?.default_branch) state.defaultBranch = body.default_branch;
|
||||
return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
}
|
||||
if (method === "PATCH" && path === `${repo}/pulls/7`) {
|
||||
if (body?.base) state.base = body.base;
|
||||
return json(res, 201, { number: 7, title: "x", state: state.prState, merged: false, head: { ref: "feat/x", sha: state.head },
|
||||
base: { ref: state.base }, html_url: "http://fake/7" });
|
||||
}
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
|
||||
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
|
||||
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
|
||||
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
|
||||
if (branch !== null && state.absent.includes(branch)) return json(res, 404, { message: "branch does not exist" });
|
||||
if (branch !== null) {
|
||||
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
|
||||
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
|
||||
@@ -144,8 +125,8 @@ const server = createServer(async (req, res) => {
|
||||
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/pulls/7`) {
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged,
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7",
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : "open", merged: state.merged,
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: "main" }, html_url: "http://fake/7",
|
||||
merge_commit_sha: state.merged ? state.mergeCommit : null });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
|
||||
@@ -828,47 +809,3 @@ test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on
|
||||
state.statuses = [judgedAt(TRUNK)[1]];
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as
|
||||
// gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards.
|
||||
test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => {
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_default_branch_set").run({ owner: "novox", repo: "mesh-catalog", why: "the trunk was recorded as unknown", ...args });
|
||||
await assert.rejects(set({ branch: "nope" }), /no branch nope/);
|
||||
await assert.rejects(set({ branch: "scratch" }), /no protection rule/);
|
||||
await assert.rejects(set({ branch: "release/1", why: " " }), /why/);
|
||||
await assert.rejects(set({ branch: ".." }), /dot segment|not a branch/);
|
||||
assert.deepEqual(writes(), []);
|
||||
assert.deepEqual(await set({ branch: "release/1" }), { branch: "release/1", before: "main", after: "release/1", changed: true,
|
||||
why: "the trunk was recorded as unknown" });
|
||||
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog"]);
|
||||
assert.deepEqual(state.sent[0].body, { default_branch: "release/1" });
|
||||
assert.equal(state.defaultBranch, "release/1");
|
||||
state.sent = [];
|
||||
assert.equal(((await set({ branch: "release/1" })) as any).changed, false, "already the default: nothing is sent");
|
||||
assert.deepEqual(writes(), []);
|
||||
});
|
||||
|
||||
test("gitea_pull_base_set answers the base before and after, and refuses a base that is missing, a closed pull request or no reason", async () => {
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_pull_base_set").run({ owner: "novox", repo: "mesh-catalog", number: 7, why: "it was opened against the wrong trunk", ...args });
|
||||
await assert.rejects(set({ base: "nope" }), /no branch nope/);
|
||||
await assert.rejects(set({ base: "release/1", why: "" }), /why/);
|
||||
await assert.rejects(set({ base: "release/1", number: 0 }), /pull request's number/);
|
||||
state.prState = "closed";
|
||||
await assert.rejects(set({ base: "release/1" }), /not open/);
|
||||
state.prState = "open";
|
||||
assert.deepEqual(writes(), []);
|
||||
assert.deepEqual(await set({ base: "release/1" }), { number: 7, before: "main", after: "release/1", changed: true,
|
||||
why: "it was opened against the wrong trunk" });
|
||||
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/pulls/7"]);
|
||||
assert.deepEqual(state.sent[0].body, { base: "release/1" });
|
||||
assert.equal(state.base, "release/1");
|
||||
});
|
||||
|
||||
test("the two new tools are named for what mesh_search is asked, and gitea_api still refuses both settings", () => {
|
||||
assert.match(tool("gitea_default_branch_set").description, /default branch/i);
|
||||
assert.match(tool("gitea_pull_base_set").description, /pull request.s base/i);
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" }));
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" }));
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
|
||||
const { headsToAnnounce } = await import("../pulls.ts");
|
||||
// 25 open, every head announced before; #3 — the 23rd most recently updated — got a new head.
|
||||
const announced: Record<string, string> = {};
|
||||
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}@main`] = sha(n, "a");
|
||||
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}`] = sha(n, "a");
|
||||
const { client, server, asked } = await serve(25, { 3: sha(3, "b") });
|
||||
try {
|
||||
// The fault: the newest 20 alone do not hold it.
|
||||
@@ -58,7 +58,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
|
||||
const open = await client.listOpenPullsSince("novox", "hq", "");
|
||||
for (const p of headsToAnnounce("novox/hq", open, announced)) {
|
||||
said.push(`#${p.number}@${p.head_sha}`);
|
||||
announced[`novox/hq#${p.number}@${p.base}`] = String(p.head_sha);
|
||||
announced[`novox/hq#${p.number}`] = String(p.head_sha);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(said, [`#3@${sha(3, "b")}`], "the new head past the newest 20, announced once over two passes");
|
||||
|
||||
@@ -8,27 +8,16 @@ import { createServer } from "node:http";
|
||||
test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
|
||||
const { headsToAnnounce } = await import("../pulls.ts");
|
||||
const pulls = [
|
||||
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", base: "main", html_url: "" },
|
||||
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
|
||||
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
|
||||
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
|
||||
{ number: 4, title: "d", state: "open", merged: false, html_url: "" },
|
||||
];
|
||||
const announced = { "novox/mesh-catalog#1@main": "aaa", "novox/mesh-catalog#2@": "old" };
|
||||
const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
|
||||
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
|
||||
"only a head not announced, of a pull request that is open");
|
||||
});
|
||||
|
||||
test("a pull request retargeted keeps its head and is announced again, into its new base (de-spiegel #6)", async () => {
|
||||
const { headsToAnnounce, headKey } = await import("../pulls.ts");
|
||||
const was = { number: 6, title: "a", state: "open", merged: false, head_sha: "aaa", base: "old-trunk", html_url: "" };
|
||||
const announced: Record<string, string> = { [headKey("novox/de-spiegel", 6, "old-trunk")]: "aaa" };
|
||||
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [was], announced), [], "the same head into the same base is announced once");
|
||||
const retargeted = { ...was, base: "main" };
|
||||
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [retargeted], announced).map((p) => p.number), [6],
|
||||
"the same head into a new base is judged afresh");
|
||||
assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk"));
|
||||
});
|
||||
|
||||
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
|
||||
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
|
||||
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
|
||||
|
||||
@@ -539,7 +539,6 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set",
|
||||
"gitea_default_branch_set", "gitea_pull_base_set",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mock, test } from "node:test";
|
||||
import type { GiteaPull } from "../client.ts";
|
||||
|
||||
// **No poll of the forge** (issue 1422, notes 10 and 11): the forge's events announce heads and merges; a pass runs once at
|
||||
// the start and when the receiver listens.
|
||||
|
||||
const edited = (base: string) => ({ name: "forge.pull-request.edited", body: { owner: "novox", repo: "hq", number: 7, base, merged: false } });
|
||||
|
||||
function deps() {
|
||||
const calls: string[] = [];
|
||||
return { calls, d: {
|
||||
getRepo: async () => ({ r: 1 }),
|
||||
getPull: async () => ({ state: "open", base: "trunk" }) as any,
|
||||
announceHead: async () => { calls.push("head"); },
|
||||
announceMerge: async () => { calls.push("merge"); },
|
||||
announceClosed: async () => { calls.push("closed"); },
|
||||
siblingMoves: async () => { calls.push("siblings"); },
|
||||
} };
|
||||
}
|
||||
|
||||
test("an edited event announces the head into the base it now has, without a poll", async () => {
|
||||
const { actOn } = await import("../watch.ts");
|
||||
const { calls, d } = deps();
|
||||
await actOn(edited("trunk"), d);
|
||||
assert.deepEqual(calls, ["head"]);
|
||||
});
|
||||
|
||||
test("a close without a merge is announced by its event, a merged close is left to the merge event", async () => {
|
||||
const { actOn } = await import("../watch.ts");
|
||||
const { calls, d } = deps();
|
||||
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: false } }, d);
|
||||
assert.deepEqual(calls, ["closed", "siblings"]);
|
||||
calls.length = 0;
|
||||
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: true } }, d);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("no timer polls after the start: one pass ends and nothing is scheduled; a failed one is asked again", async () => {
|
||||
const { startPass } = await import("../watch.ts");
|
||||
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
|
||||
try {
|
||||
let runs = 0;
|
||||
await startPass(async () => { runs++; }, { retryMs: 60_000, log: () => {} });
|
||||
mock.timers.tick(24 * 3600_000);
|
||||
assert.equal(runs, 1, "a day later the forge was not asked again");
|
||||
let tries = 0;
|
||||
const retried = startPass(async () => { if (++tries < 2) throw new Error("forge down"); }, { retryMs: 60_000, log: () => {} });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
mock.timers.tick(60_000);
|
||||
await retried;
|
||||
assert.equal(tries, 2);
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("a start pass announces a head nobody announced, and only remembers one the forge shows a check of", async () => {
|
||||
const { announceNewHeads } = await import("../watch.ts");
|
||||
const pull = (n: number, sha: string): GiteaPull => ({ number: n, state: "open", head_sha: sha, base: "main",
|
||||
updated_at: new Date().toISOString() } as unknown as GiteaPull);
|
||||
const announced: number[] = [], said: string[] = [];
|
||||
const heads: Record<string, string> = {};
|
||||
await announceNewHeads("novox/hq", [pull(1, "a".repeat(40)), pull(2, "b".repeat(40))], heads, false,
|
||||
new Date(Date.now() - 24 * 3600_000).toISOString(), {
|
||||
alreadyChecked: async (p) => p.number === 2,
|
||||
announceHead: async (p) => { announced.push(p.number); return true; },
|
||||
missed: (w) => said.push(w),
|
||||
});
|
||||
assert.deepEqual(announced, [1]);
|
||||
assert.equal(heads["novox/hq#2@main"], "b".repeat(40), "the checked head is remembered, not announced");
|
||||
assert.equal(said.length, 1);
|
||||
});
|
||||
@@ -251,8 +251,7 @@ test("a forge that never delivers is said loudly once, and unhealthy, when the p
|
||||
assert.equal(r.healthy, true);
|
||||
assert.equal(r.verified.count, 1);
|
||||
assert.equal(r.verified.last.event, "push");
|
||||
assert.equal(r.polls.every_seconds, null);
|
||||
assert.equal(r.polls.hearing, true);
|
||||
assert.equal(r.polls.every_seconds, 300);
|
||||
assert.match(said.join("\n"), /heard again/);
|
||||
});
|
||||
|
||||
@@ -315,19 +314,24 @@ test("without a receiver by configuration the polls alone watch, healthy; a rece
|
||||
assert.match(r.why, /403/);
|
||||
});
|
||||
|
||||
test("nothing is paced: the report says no timer asks the forge, and a miss is still counted and said", async () => {
|
||||
test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
|
||||
const { Pace } = await import("../webhook.ts");
|
||||
let now = 1_000_000;
|
||||
const said: string[] = [];
|
||||
const pace = new Pace(() => now, (l) => said.push(l));
|
||||
assert.equal((pace as any).every, undefined, "no pace to ask");
|
||||
assert.equal((pace.report() as any).polls.every_seconds, null);
|
||||
assert.equal(pace.every(), 30_000, "no delivery yet");
|
||||
pace.heard();
|
||||
assert.equal(pace.every(), 300_000, "a delivery heard");
|
||||
now += 11 * 60_000;
|
||||
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
|
||||
pace.heard();
|
||||
assert.equal((pace.report() as any).polls.hearing, true);
|
||||
now += 1000;
|
||||
pace.miss("novox/hq#7 at aaaaaaaa");
|
||||
assert.equal((pace.report() as any).polls.hearing, false, "the pass found what no event said");
|
||||
assert.equal(pace.every(), 30_000, "the poll found what no event said");
|
||||
assert.match(said.join("\n"), /MISSED novox\/hq#7/);
|
||||
now += 1000;
|
||||
pace.heard();
|
||||
assert.equal(pace.every(), 300_000, "the next delivery");
|
||||
});
|
||||
|
||||
// Issue 1079: a pull request's edit event carries its description, so mesh-delivery reads a Reviewed-by line written after
|
||||
|
||||
@@ -488,61 +488,6 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ----
|
||||
{
|
||||
name: "gitea_default_branch_set",
|
||||
description: "Set a repository's default branch — the branch the forge shows, clones and opens pull requests against, and the one gitea_api never writes a file to. Refused when the branch does not exist, when no protection rule guards it (a default nothing protects would open it to direct writes), or when no reason (why) is given; acts as the tools' account, so the forge refuses it where that account is no administrator of the repository. Answers the default branch before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch to make the default; it must exist and be guarded by a protection rule" },
|
||||
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
|
||||
const why = String(args.why ?? "").trim();
|
||||
if (!branch) throw new Error("name the branch to make the default");
|
||||
if (!why) throw new Error("say why (why): a default branch is changed for a reason, which the call's record keeps");
|
||||
pathSegments("branch", branch);
|
||||
const state = await gitea.branchState(owner, repo, branch);
|
||||
if (!state) throw new Refused(`${owner}/${repo} has no branch ${branch}, so it cannot be the default branch. Nothing was sent`);
|
||||
if (!state.protected) {
|
||||
throw new Refused(`no protection rule guards ${branch} of ${owner}/${repo}: a default branch nothing protects takes direct writes ` +
|
||||
`(gitea_branch_protection_set guards it first). Nothing was sent`);
|
||||
}
|
||||
const before = String((await gitea.getRepo(owner, repo)).default_branch ?? "");
|
||||
if (before === branch) return { branch, before, after: before, changed: false, why };
|
||||
const after = String((await gitea.setDefaultBranch(owner, repo, branch)).default_branch ?? "");
|
||||
return { branch, before, after, changed: before !== after, why };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_pull_base_set",
|
||||
description: "Change an open pull request's base — the branch it merges into — keeping its head. The checks were judged against the base it had, so the pull request is announced again into the new one. Refused when the base branch does not exist, when the pull request is not open, or when no reason (why) is given; acts as the tools' account. Answers the pull request's base before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the pull request's number" },
|
||||
base: { type: "string", description: "the branch it should merge into; it must exist" },
|
||||
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
|
||||
const base = String(args.base ?? "").trim(), why = String(args.why ?? "").trim();
|
||||
if (!Number.isInteger(number) || number < 1) throw new Error(`${args.number} is not a pull request's number`);
|
||||
if (!base) throw new Error("name the base branch");
|
||||
if (!why) throw new Error("say why (why): a pull request's base is changed for a reason, which the call's record keeps");
|
||||
pathSegments("branch", base);
|
||||
if (!(await gitea.branchState(owner, repo, base))) throw new Refused(`${owner}/${repo} has no branch ${base}, so a pull request cannot merge into it. Nothing was sent`);
|
||||
const had = await gitea.getPullRequest(owner, repo, number);
|
||||
if (had.state !== "open" || had.merged) throw new Refused(`${owner}/${repo}#${number} is not open, so its base is not changed. Nothing was sent`);
|
||||
const before = String(had.base ?? "");
|
||||
if (before === base) return { number, before, after: before, changed: false, why };
|
||||
const after = String((await gitea.setPullBase(owner, repo, number, base)).base ?? "");
|
||||
return { number, before, after, changed: before !== after, why };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
|
||||
{
|
||||
name: "gitea_note_append",
|
||||
@@ -637,7 +582,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_trees",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalog's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalogue's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// What the forge's events ask of the announcer, and the one pass at a start (issue 1422, notes 10 and 11). Nothing polls the
|
||||
// forge: a head or merge is announced when its event is heard, and a pass over every repository runs once when the module
|
||||
// starts and again when the receiver is listening, so what happened while it was down is announced too.
|
||||
//
|
||||
// Apart from index.ts, which has side effects at import, so these are tested with the announcers injected.
|
||||
|
||||
import type { GiteaPull, GiteaRepo } from "./client.js";
|
||||
import type { Announced } from "./pulls.js";
|
||||
import { headKey, headsToAnnounce } from "./pulls.js";
|
||||
import type { ForgeEvent } from "./webhook.js";
|
||||
|
||||
export interface ActDeps<R, P> {
|
||||
getRepo(owner: string, repo: string): Promise<R>;
|
||||
getPull(owner: string, repo: string, number: number): Promise<P & { state?: string; merged?: boolean }>;
|
||||
announceHead(repo: R, pull: P): Promise<unknown>;
|
||||
announceMerge(repo: R, pull: P): Promise<unknown>;
|
||||
announceClosed(repo: R, pull: P): Promise<unknown>;
|
||||
siblingMoves(full: string, number: number): Promise<unknown>;
|
||||
}
|
||||
|
||||
const HEAD_EVENTS = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"];
|
||||
|
||||
/**
|
||||
* What the forge's event asks of the announcer at once: a new head is announced, a merge, a close without a merge, and a
|
||||
* pull request retargeted (an edit: the head is announced into the base it now has — the base is in the head's key, so an
|
||||
* edit that changed nothing about the base announces nothing).
|
||||
*/
|
||||
export async function actOn<R, P>(event: ForgeEvent, d: ActDeps<R, P>): Promise<void> {
|
||||
const b = event.body as { owner?: string; repo?: string; number?: number; merged?: boolean };
|
||||
if (!b.owner || !b.repo || !b.number) return;
|
||||
const edited = event.name === "forge.pull-request.edited";
|
||||
const closed = event.name === "forge.pull-request.closed";
|
||||
const head = HEAD_EVENTS.includes(event.name) || edited;
|
||||
const merged = event.name === "forge.pull-request.merged";
|
||||
if (!head && !merged && !closed) return;
|
||||
if (closed && b.merged) return; // a merge is weighed with its own event (ADR 0382 §5)
|
||||
const [repo, pull] = await Promise.all([d.getRepo(b.owner, b.repo), d.getPull(b.owner, b.repo, b.number)]);
|
||||
if (closed) {
|
||||
await d.announceClosed(repo, pull);
|
||||
await d.siblingMoves(`${b.owner}/${b.repo}`, b.number);
|
||||
return;
|
||||
}
|
||||
if (head && pull.state === "open") await d.announceHead(repo, pull);
|
||||
if (merged) await d.announceMerge(repo, pull);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one pass at a start, and its retry: run now; if it fails, say so and run again after `retryMs`, until one pass has
|
||||
* ended. A retry of a failed pass, never a poll: after a pass has ended nothing is scheduled.
|
||||
*/
|
||||
export function startPass(run: () => Promise<void>, opts: { retryMs: number; log?: (l: string) => void }): Promise<void> {
|
||||
const log = opts.log ?? ((l) => console.error(`[gitea] ${l}`));
|
||||
return run().catch((err) => {
|
||||
log(`the pass over every repository failed, asked again in ${opts.retryMs / 1000} s — ${err instanceof Error ? err.message : err}`);
|
||||
return new Promise<void>((resolve) => setTimeout(() => resolve(startPass(run, opts)), opts.retryMs));
|
||||
});
|
||||
}
|
||||
|
||||
export interface HeadDeps {
|
||||
/** Whether the forge shows a merge check of the head: only an announced head has one. */
|
||||
alreadyChecked(pull: GiteaPull): Promise<boolean>;
|
||||
/** Announce it; true when it was this call that announced it (not an event before it). */
|
||||
announceHead(pull: GiteaPull): Promise<boolean>;
|
||||
/** Told of a head this pass announced that no event had. */
|
||||
missed(what: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heads of one repository's open pull requests a pass announces. A head is announced when it is new and fresh (moved in
|
||||
* the last day); at the first pass after a start (`primed` false) one the forge shows a merge check of was announced
|
||||
* already, and is only remembered.
|
||||
*/
|
||||
export async function announceNewHeads(full: string, open: GiteaPull[], heads: Announced, primed: boolean, dayAgo: string, d: HeadDeps): Promise<void> {
|
||||
for (const pull of headsToAnnounce(full, open, heads)) {
|
||||
const key = headKey(full, pull.number, pull.base);
|
||||
const fresh = primed || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
if (fresh && !primed && await d.alreadyChecked(pull)) {
|
||||
heads[key] = String(pull.head_sha);
|
||||
continue;
|
||||
}
|
||||
if (fresh) {
|
||||
if (await d.announceHead(pull)) d.missed(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||
} else heads[key] = String(pull.head_sha);
|
||||
}
|
||||
}
|
||||
|
||||
export type { GiteaRepo };
|
||||
+22
-19
@@ -3,7 +3,7 @@
|
||||
// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and
|
||||
// publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …),
|
||||
// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships.
|
||||
// No poll is the safety net (issue 1422); the announced-heads and announced-merges records keep anything from
|
||||
// The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
|
||||
// being announced twice, whichever path saw it first.
|
||||
//
|
||||
// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is.
|
||||
@@ -144,7 +144,7 @@ export interface ReceiverOptions {
|
||||
publish: (event: ForgeEvent) => Promise<void>;
|
||||
/** Acts on it at once, beside publishing it (the announcer). */
|
||||
act?: (event: ForgeEvent) => Promise<void>;
|
||||
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the module knows events are
|
||||
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are
|
||||
* heard, and the module can say when it last heard one. */
|
||||
heard?: (kind: string, delivery: string) => void;
|
||||
/** Told of every refused delivery, with the reason and where it came from. */
|
||||
@@ -242,7 +242,7 @@ export function receiver(opts: ReceiverOptions): Server & { idle(): Promise<void
|
||||
await opts.publish(event);
|
||||
if (opts.act) await opts.act(event);
|
||||
} catch (err) {
|
||||
log(`a forge event ${event.name} could not be published or acted on; the pass over every repository is asked again shortly — ${err instanceof Error ? err.message : err}`);
|
||||
log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -276,13 +276,14 @@ export interface HookState {
|
||||
export const SILENT_AFTER = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* **Whether the forge's events are heard** (issues 729, 1422). Nothing polls the forge, so there is no pace: a delivery
|
||||
* heard within the last ten minutes, and none missed since, is `hearing`.
|
||||
* **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the
|
||||
* forge's events are heard — a verified delivery within the last ten minutes, and none missed since. Until the first
|
||||
* delivery, after ten minutes without one, or once the poll found something no event said, every thirty seconds.
|
||||
*
|
||||
* It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused
|
||||
* deliveries, and the finds of the pass over every repository (at the start and when the receiver listens) that no event said. **A forge whose events stop is said** (issue 729): once the receiver
|
||||
* deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver
|
||||
* listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery —
|
||||
* and a pass has found at least two heads or merges no event said in that quiet (one could be a pass beating an
|
||||
* and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an
|
||||
* event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a
|
||||
* delivery verifies. Deliveries refused since the last verified one are said unhealthy too.
|
||||
*/
|
||||
@@ -317,7 +318,7 @@ export class Pace {
|
||||
this.notHeard = null;
|
||||
}
|
||||
/** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen
|
||||
* or the hook could not be ensured (`fails` true, said unhealthy). Only the pass at the start watches. */
|
||||
* or the hook could not be ensured (`fails` true, said unhealthy). The polls alone watch. */
|
||||
unheard(why: string, fails = true): void {
|
||||
this.notHeard = why;
|
||||
this.notHeardFails = fails;
|
||||
@@ -336,10 +337,10 @@ export class Pace {
|
||||
this.refusedSpell++;
|
||||
this.lastRefused = { at: this.now(), reason, from };
|
||||
}
|
||||
/** The pass over every repository found a head or merge no forge event announced. */
|
||||
/** The poll found a head or merge no forge event announced. */
|
||||
miss(what: string): void {
|
||||
const t = this.now();
|
||||
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the pass over every repository found it`);
|
||||
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`);
|
||||
this.missed = t;
|
||||
this.missCount++;
|
||||
this.lastMiss = { at: t, what };
|
||||
@@ -354,8 +355,8 @@ export class Pace {
|
||||
this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` +
|
||||
`${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` +
|
||||
`system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` +
|
||||
`${this.lastRefused?.reason}` : ""}; the pass found ${what}; nothing else watches the forge ` +
|
||||
`(novox/hq issue 729, 1422)`);
|
||||
`${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` +
|
||||
`(novox/hq issue 729)`);
|
||||
}
|
||||
}
|
||||
/** Whether a delivery was refused after the last verified one (or with none verified). */
|
||||
@@ -366,6 +367,9 @@ export class Pace {
|
||||
const t = this.now();
|
||||
return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed;
|
||||
}
|
||||
every(): number {
|
||||
return this.hearing() ? 5 * 60_000 : 30_000;
|
||||
}
|
||||
private expecting(): boolean {
|
||||
return this.where !== null && this.hook !== null;
|
||||
}
|
||||
@@ -379,14 +383,14 @@ export class Pace {
|
||||
if (this.silentSince) {
|
||||
state = "unhealthy";
|
||||
why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` +
|
||||
`event since the start at ${iso(this.started)}`}, while the pass found ${this.missCount} head(s) or merge(s) no ` +
|
||||
`event since the start at ${iso(this.started)}`}, while the poll found ${this.missCount} head(s) or merge(s) no ` +
|
||||
`event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`;
|
||||
} else if (refusedSince) {
|
||||
state = "unhealthy";
|
||||
why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` :
|
||||
"and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`;
|
||||
} else if (!this.expecting()) {
|
||||
why = this.notHeard ? `no events are listened for, so only the pass at the start watches the forge: ${this.notHeard}` :
|
||||
why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` :
|
||||
"the receiver is not listening yet";
|
||||
if (this.notHeard && this.notHeardFails) state = "unhealthy";
|
||||
} else if (this.hearing()) {
|
||||
@@ -394,7 +398,7 @@ export class Pace {
|
||||
} else if (this.last === 0) {
|
||||
why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`;
|
||||
} else {
|
||||
why = `the last delivery was at ${iso(this.last)}; nothing polls the forge, so what happens meanwhile is announced at the next start or when the receiver listens again`;
|
||||
why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`;
|
||||
}
|
||||
return {
|
||||
healthy: state !== "unhealthy", state, why,
|
||||
@@ -403,8 +407,7 @@ export class Pace {
|
||||
hook: this.hook,
|
||||
verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } },
|
||||
refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } },
|
||||
polls: { every_seconds: null, note: "no timer asks the forge; one pass at the start and when the receiver listens (issue 1422)",
|
||||
hearing: this.hearing(), found_unsaid: this.missCount,
|
||||
polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount,
|
||||
last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } },
|
||||
silent: this.silentSince ? { since: iso(this.silentSince) } : null,
|
||||
};
|
||||
@@ -499,8 +502,8 @@ export function eventsTool(pace: Pace): { name: string; description: string; inp
|
||||
name: "gitea_events",
|
||||
description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " +
|
||||
"system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " +
|
||||
"verified and the last one's time and event, how many were refused and the last reason, whether " +
|
||||
"events are heard (nothing polls; one pass runs at the start), and how many heads or merges that pass found that no event said. Unhealthy when the " +
|
||||
"verified and the last one's time and event, how many were refused and the last reason, how often the polls look " +
|
||||
"and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " +
|
||||
"forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " +
|
||||
"poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " +
|
||||
"could not listen or the webhook could not be made.",
|
||||
|
||||
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS module_version (
|
||||
PRIMARY KEY (module, commit_sha)
|
||||
);
|
||||
|
||||
-- What the catalog considers this module to be now. Separate from module_version because a
|
||||
-- What the catalogue considers this module to be now. Separate from module_version because a
|
||||
-- module has many versions and exactly one current one, and "current" is a decision rather than
|
||||
-- a consequence of being newest.
|
||||
CREATE TABLE IF NOT EXISTS module_current (
|
||||
@@ -144,7 +144,7 @@ export class Graph {
|
||||
const url = env["DATABASE_URL"] ?? readMaybe(env["DATABASE_URL_FILE"]);
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"no DATABASE_URL: the catalog holds the module graph and cannot hold it in memory, " +
|
||||
"no DATABASE_URL: the catalogue holds the module graph and cannot hold it in memory, " +
|
||||
"because a graph that disappears on restart is not a record of anything",
|
||||
);
|
||||
}
|
||||
@@ -246,7 +246,7 @@ export class Graph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every module the catalog holds, with the commit it considers current. */
|
||||
/** Every module the catalogue holds, with the commit it considers current. */
|
||||
async modules(): Promise<{ module: string; commit: string; repository: string; path: string }[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT c.module, c.commit_sha, v.repository, v.path
|
||||
|
||||
@@ -12,7 +12,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
|
||||
{
|
||||
name: "catalog_modules",
|
||||
description:
|
||||
"Every module this mesh holds, with the commit the catalog considers current and where it came from.",
|
||||
"Every module this mesh holds, with the commit the catalogue considers current and where it came from.",
|
||||
input: {},
|
||||
run: async () => ({ modules: await graph.modules() }),
|
||||
},
|
||||
@@ -28,7 +28,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
|
||||
const module = String(args.module ?? "");
|
||||
if (!module) return { error: "name a module" };
|
||||
const found = await graph.show(module, args.commit ? String(args.commit) : undefined);
|
||||
return found ? { module: found } : { error: `the catalog holds no ${module}` };
|
||||
return found ? { module: found } : { error: `the catalogue holds no ${module}` };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -184,7 +184,7 @@ func (call Call) level() (Level, error) {
|
||||
case call.Module != "":
|
||||
return Level{}, fmt.Errorf("a module's own value is set for one node, and no node is named: there is no "+
|
||||
"module value for the whole mesh (novox/hq ADR 0348 §3); name the node, or set mesh config for the "+
|
||||
"predefined key %s's value falls back to, or change its default in the catalog", call.Module)
|
||||
"predefined key %s's value falls back to, or change its default in the catalogue", call.Module)
|
||||
case call.Node != "":
|
||||
return Level{Kind: LevelNode, Node: call.Node}, nil
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (c *Config) sources() (Sources, error) {
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the assignments cannot be read: %w", err)
|
||||
}
|
||||
// And every module the catalog's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
|
||||
// And every module the catalogue's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
|
||||
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
|
||||
// Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said.
|
||||
standing := map[string]bool{}
|
||||
@@ -57,7 +57,7 @@ func (c *Config) sources() (Sources, error) {
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the catalog's placement cannot be read, so the assignments alone are resolved: %v", err))
|
||||
problems = append(problems, fmt.Sprintf("the catalogue's placement cannot be read, so the assignments alone are resolved: %v", err))
|
||||
}
|
||||
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
|
||||
m, err := ParseModuleDeclarations(raw)
|
||||
|
||||
@@ -57,8 +57,6 @@ type Holder struct {
|
||||
|
||||
// catchUp asks the next look to weigh again the newest merge of each repository against source.main (SourceCatchUp).
|
||||
catchUp bool
|
||||
// seeded are the repositories source.main was found to name, or was seeded with, since this owner started (seedTrunks).
|
||||
seeded map[string]bool
|
||||
|
||||
mu sync.Mutex
|
||||
deliveries map[string]*Delivery
|
||||
@@ -345,15 +343,13 @@ func (h *Holder) pullUpdated(p PullEvent) (isNew bool) {
|
||||
// Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its
|
||||
// check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound
|
||||
// runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it.
|
||||
if p.BaseSHA != "" {
|
||||
d.BaseSHA, d.BaseSHAAt = p.BaseSHA, now
|
||||
if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA {
|
||||
d.BaseSHA = p.BaseSHA
|
||||
h.keep(d)
|
||||
}
|
||||
// A delivery made from its verdict before its head was heard (issue 1726) holds none of what an announcement
|
||||
// carries: filled from this one, so its base is known and the operator is asked.
|
||||
h.fillFromAnnouncement(d, p)
|
||||
// A pull request retargeted to another base (review of issue 1422): what it merges into is the forge's word now.
|
||||
h.retarget(d, p)
|
||||
// The review record, corrected in the description without a push, is read again (ADR 0421 §5).
|
||||
if by, at := ReviewIn(p.Body); p.Body != "" && (by != d.ReviewedBy || at != d.ReviewedAt) {
|
||||
d.ReviewedBy, d.ReviewedAt = by, at
|
||||
@@ -443,7 +439,7 @@ func (h *Holder) fillFromAnnouncement(d *Delivery, p PullEvent) {
|
||||
d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body)
|
||||
d.PushedBy = PushedIn(p.Body)
|
||||
if p.BaseSHA != "" {
|
||||
d.BaseSHA, d.BaseSHAAt = p.BaseSHA, h.Now()
|
||||
d.BaseSHA = p.BaseSHA
|
||||
}
|
||||
d.Number = p.Number
|
||||
h.Logf("[mesh-delivery] %s: its pull request's announcement filled its base (%s), branch and title", d.ID, p.Base)
|
||||
@@ -455,27 +451,6 @@ func (h *Holder) fillFromAnnouncement(d *Delivery, p PullEvent) {
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
// retarget takes the base a live, unmerged delivery's pull request was announced again with when it differs from the one
|
||||
// kept: into main it is asked and merged as any other, and source.main may be seeded from it (seedTrunks); out of main an
|
||||
// ask nobody answered yet is taken back, and the next look says it is not asked (ready.go). A delivery whose base is not yet
|
||||
// known is fillFromAnnouncement's. Said in the log. Under the lock.
|
||||
func (h *Holder) retarget(d *Delivery, p PullEvent) {
|
||||
if p.Base == "" || d.Base == "" || p.Base == d.Base || d.State.Final() || d.MergedAs != "" ||
|
||||
(d.Ready != nil && d.Ready.Merged != nil) {
|
||||
return
|
||||
}
|
||||
from := d.Base
|
||||
d.Base = p.Base
|
||||
if r := d.Ready; r != nil {
|
||||
r.OtherBase = ""
|
||||
if r.AskID != "" && r.Approved == nil {
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, fmt.Sprintf("%s was retargeted from %s to %s", d.ID, from, p.Base)))
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: its pull request was retargeted from %s to %s", d.ID, from, p.Base)
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// PullEdited takes a pull request's edit (novox/hq issue 1079): its description is read again for the review record, the
|
||||
// `after:` lines and the issues it names, exactly as an announcement reads them. An Approve ask still unanswered whose words
|
||||
// the new record changes is withdrawn and asked again with it read, without a word on the pull request: nothing was
|
||||
@@ -583,9 +558,6 @@ func (h *Holder) freshAnnouncement(p PullEvent, now time.Time, startedOver strin
|
||||
Title: p.Title, Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths,
|
||||
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
|
||||
After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, BaseSHA: p.BaseSHA}
|
||||
if p.BaseSHA != "" {
|
||||
d.BaseSHAAt = now
|
||||
}
|
||||
d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body)
|
||||
d.PushedBy = PushedIn(p.Body)
|
||||
why := fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -49,86 +48,6 @@ func TestStartingWritesTheNewestMergeOfEachRepositoryNotYetInSourceMain(t *testi
|
||||
t.Fatalf("a second catch-up moved source.main again: %d moves, then %d", n, len(w.source.moves))
|
||||
}
|
||||
|
||||
// Issue 1422: a repository never merged through the gate gets main's head as the forge announced its open pull request
|
||||
// with, once, beside the others; a pull request into another base, or one announced with no base head (the forge could
|
||||
// not read it), seeds nothing; a head source.main holds is never replaced.
|
||||
const never, trunkN, trunkM = "novox/never-merged", "eeee000000000000000000000000000000000001", "eeee000000000000000000000000000000000002"
|
||||
s := newWorld(t)
|
||||
s.source.main = &sdk.Source{V: sdk.Version, Group: "before", Heads: map[string]string{i1726Repo: i1726Merge1}}
|
||||
other := pr("novox/other-base", 6, "f1f1000000000000000000000000000000000001", "fix/c", "a.go")
|
||||
other.Base, other.BaseSHA = "nox-mesh", trunkM
|
||||
s.h.PullUpdated(other)
|
||||
unread := pr("novox/unread", 2, "f2f2000000000000000000000000000000000002", "fix/d", "a.go")
|
||||
s.h.PullUpdated(unread)
|
||||
seed := pr(never, 4, "f3f3000000000000000000000000000000000003", "fix/e", "a.go")
|
||||
seed.BaseSHA = trunkN
|
||||
s.h.PullUpdated(seed)
|
||||
s.settleAll()
|
||||
if h := s.source.main.Heads; h[never] != trunkN || h[i1726Repo] != i1726Merge1 || h["novox/other-base"] != "" || h["novox/unread"] != "" || len(s.source.moves) != 1 {
|
||||
t.Fatalf("source.main is %v after %d moves; wanted %s seeded at %s, the catalogue kept and nothing else, in one move", h, len(s.source.moves), never, trunkN)
|
||||
}
|
||||
if !strings.Contains(s.logs(), "seeded with "+trunkN[:12]) {
|
||||
t.Errorf("the seed was not said: %s", s.logs())
|
||||
}
|
||||
again := pr(never, 5, "f4f4000000000000000000000000000000000004", "fix/f", "a.go")
|
||||
again.BaseSHA = trunkM
|
||||
s.h.PullUpdated(again)
|
||||
s.h.SourceCatchUp()
|
||||
s.settleAll()
|
||||
if got := s.source.main.Heads[never]; got != trunkN || len(s.source.moves) != 1 {
|
||||
t.Fatalf("a seeded head was replaced by %s (%d moves)", got, len(s.source.moves))
|
||||
}
|
||||
|
||||
// The freshest announcement's base head is the seed, not the newest delivery's: #4, made first, is announced again after #5
|
||||
// with main's head moved on.
|
||||
const trunkF = "eeee000000000000000000000000000000000003"
|
||||
f := newWorld(t)
|
||||
first := pr(never, 4, "f5f5000000000000000000000000000000000005", "fix/g", "a.go")
|
||||
first.BaseSHA = trunkN
|
||||
f.h.PullUpdated(first)
|
||||
f.later(time.Minute)
|
||||
second := pr(never, 5, "f6f6000000000000000000000000000000000006", "fix/h", "a.go")
|
||||
second.BaseSHA = trunkM
|
||||
f.h.PullUpdated(second)
|
||||
f.later(time.Minute)
|
||||
first.BaseSHA = trunkF
|
||||
f.h.PullUpdated(first)
|
||||
f.settleAll()
|
||||
if got := f.source.main.Heads[never]; got != trunkF {
|
||||
t.Fatalf("seeded with %s; wanted %s, the base head of the latest announcement", got, trunkF)
|
||||
}
|
||||
|
||||
// A green pull request retargeted from nox-mesh to main is seeded and asked; one retargeted from main to nox-mesh has
|
||||
// its ask taken back and is not merged (review round 2).
|
||||
retarget := func(from, to string) (*world, int) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
p := pr(never, 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Base, p.BaseSHA = from, trunkN
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt(never, 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
open := openPull(never, 7, readyHead)
|
||||
open.Base = from
|
||||
w.forge.open = []OpenPull{open}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
before := len(w.op.open())
|
||||
p.Base, open.Base = to, to
|
||||
w.forge.open = []OpenPull{open}
|
||||
w.h.PullUpdated(p)
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
return w, before
|
||||
}
|
||||
if r, before := retarget("nox-mesh", "main"); before != 0 || r.source.main == nil || r.source.main.Heads[never] != trunkN || len(r.op.open()) != 1 {
|
||||
t.Fatalf("retargeted to main: %d asks before, source.main %+v, open asks %v; wanted none before, then seeded at %s and asked", before, r.source.main, r.op.open(), trunkN)
|
||||
}
|
||||
if r, before := retarget("main", "nox-mesh"); before != 1 || len(r.op.open()) != 0 || len(r.forge.merges) != 0 || !strings.Contains(r.logs(), "retargeted") {
|
||||
t.Fatalf("retargeted to nox-mesh: %d asks before, open asks %v, merges %v; wanted one before, then none and the retarget said", before, r.op.open(), r.forge.merges)
|
||||
}
|
||||
|
||||
o := newWorld(t)
|
||||
o.source.main = &sdk.Source{V: sdk.Version, Group: "newer", Heads: map[string]string{i1726Repo: i1726Merge2}}
|
||||
o.h.PullUpdated(pr(i1726Repo, 471, head2, "fix/b", "modules/app/b.go"))
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -525,75 +524,6 @@ func (h *Holder) settleForgeMerges() []func() {
|
||||
return out
|
||||
}
|
||||
|
||||
// trunkHead is a full commit id as the forge names a branch's head.
|
||||
var trunkHead = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
// seedTrunks gives source.main the trunk head of a repository it names none of (issue 1422): one never merged through the
|
||||
// gate, whose open pull requests the checker could otherwise never judge. The head is the base_sha of the latest announcement of
|
||||
// any open pull request into main (gitea.pull.updated; ADR 0323; lastAnnounced), the trunk's freshest head the gate heard. It is
|
||||
// added in one compare-and-set write that never replaces a head source.main holds, so a merge always wins; the first merge
|
||||
// through the gate moves it as any other. source.main is per repository and names main's head only (ADR 0484 §11, MergeBranch):
|
||||
// a pull request into another base seeds nothing. A repository is weighed once per start after it was found or seeded.
|
||||
// Under the lock; the writing happens outside it.
|
||||
func (h *Holder) seedTrunks() []func() {
|
||||
newest := map[string]*Delivery{}
|
||||
for _, d := range h.deliveries {
|
||||
if h.seeded[d.Repository] || d.Base != MergeBranch || !trunkHead.MatchString(d.BaseSHA) || d.State.Final() || d.MergedAs != "" {
|
||||
continue
|
||||
}
|
||||
if o := newest[d.Repository]; o == nil || lastAnnounced(d).After(lastAnnounced(o)) {
|
||||
newest[d.Repository] = d
|
||||
}
|
||||
}
|
||||
var out []func()
|
||||
for repository, d := range newest {
|
||||
id, head, group := d.ID, d.BaseSHA, firstOf(d.Branch, d.Repository)
|
||||
out = append(out, func() {
|
||||
seeded := false
|
||||
err := h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
|
||||
seeded = false
|
||||
next := sdk.Source{Group: group, Heads: map[string]string{}}
|
||||
if prev != nil {
|
||||
if prev.Heads[repository] != "" {
|
||||
return *prev, false // named already: never replaced
|
||||
}
|
||||
next.Group = prev.Group
|
||||
for r, held := range prev.Heads {
|
||||
next.Heads[r] = held
|
||||
}
|
||||
}
|
||||
next.Heads[repository] = head
|
||||
seeded = true
|
||||
return next, true
|
||||
})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s: source.main names no trunk head of %s, and it cannot be seeded yet: %v; asked again in %s", id, repository, err, askAgainAfter)
|
||||
h.again("source.main, seeded with the trunk head of a repository it names none of")
|
||||
return
|
||||
}
|
||||
if h.seeded == nil {
|
||||
h.seeded = map[string]bool{}
|
||||
}
|
||||
h.seeded[repository] = true
|
||||
if seeded {
|
||||
h.Logf("[mesh-delivery] source.main named no trunk head of %s: seeded with %s, main's head as the forge announced %s with, so its pull requests are judged (issue 1422)", repository, shortOf(head, 12), id)
|
||||
}
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lastAnnounced is when the forge last announced a delivery's head with its base head: BaseSHAAt, or when the gate made it
|
||||
// for one kept before that was written.
|
||||
func lastAnnounced(d *Delivery) time.Time {
|
||||
if !d.BaseSHAAt.IsZero() {
|
||||
return d.BaseSHAAt
|
||||
}
|
||||
return d.Created
|
||||
}
|
||||
|
||||
// replayLate is how long after its merge a merge heard only from the forge's word is a replay: the forge announces a merge
|
||||
// within seconds, so one heard this late was replayed from the bus's history, and the source already holds what came after.
|
||||
const replayLate = 15 * time.Minute
|
||||
|
||||
@@ -66,9 +66,6 @@ type Delivery struct {
|
||||
// BaseSHA is the base branch's head the forge last announced its head with (ADR 0323): sent with a check this
|
||||
// owner asks, for a verdict no build judges to name its trunk commit.
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
// BaseSHAAt is when the forge last announced its head with BaseSHA: the freshest of a repository's announcements gives
|
||||
// source.main its trunk head when it names none (seedTrunks, issue 1422). Zero for one kept before it was written.
|
||||
BaseSHAAt time.Time `json:"base_sha_at,omitzero"`
|
||||
// AskedAgain is when its head was announced again while its verdict was out of date — the merge tool's ask of
|
||||
// its check against the trunk's head (ADR 0323 §5) — and no verdict has come since: its one-hour bound runs from
|
||||
// then. Nil while nothing is asked, and cleared by a verdict or another move of its trunk.
|
||||
|
||||
@@ -294,7 +294,6 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
asks = append(asks, h.turnWrites()...) // the merge turns, after the holds of this look (ADR 0548 §3)
|
||||
asks = append(asks, h.settleMerges()...)
|
||||
asks = append(asks, h.settleForgeMerges()...)
|
||||
asks = append(asks, h.seedTrunks()...)
|
||||
asks = append(asks, h.withdrawnKeys()...)
|
||||
asks = append(h.cancels, asks...)
|
||||
h.cancels = nil
|
||||
|
||||
@@ -33,7 +33,6 @@ what it refuses.
|
||||
| `reserve` | `kind` (`decision` or `research`), optionally `for` (one line: what the record is about) | the next number of that kind, spent forever; answers `kind` and `number` (and `prefix`, the number as the record's name starts with it); see below |
|
||||
| `fix-times` | optionally `days` (1 to 90, default 7) | each class's fix budget, the median and worst fix time of the issues whose last fix was delivered in the window, within and over, and every issue over its budget with its longest phase; see *A fix's time* |
|
||||
| `learn-fixes` | `number` | asks again for the fixes of one issue the tracker keeps no record of (a pull request merged before it heard deliveries): the delivery, then the forge, say whether each is merged (and the commit it landed as) or closed without merging; answers what each is kept as and the delivery line |
|
||||
| `changes` | `since` (a window such as `60m`, at most `24h`), `number` (optional) | each issue's moves over the window, read from the tracker's own log `changes`: opened, moved, resolved or wontfix, the status it moved from and to, who moved it (and the worker, for diagnosing or fixing) and when, oldest first, at most 500; the window's first entry is found by its time, so a day's window reads only that day of the log. knowledge-base.progress with `since` asks it for each move's "from" |
|
||||
|
||||
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
|
||||
folders until the import, and hq ADR 0284): a status is one of `open`,
|
||||
|
||||
@@ -23,7 +23,7 @@ const Seat = "issue-tracker"
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest declares and claims them.
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "milestones", "milestone-set", "milestone-move", "import",
|
||||
"groom", "set", "tracking", "reserve", "fix-times", "learn-fixes", "changes"}
|
||||
"groom", "set", "tracking", "reserve", "fix-times", "learn-fixes"}
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
@@ -943,6 +943,5 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
}
|
||||
return t.LearnFixes(n)
|
||||
}},
|
||||
changesTool(t),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,13 +72,6 @@ func TestAnIssueRaceIntoTwoMilestonesOverTheBusEndsInOne(t *testing.T) {
|
||||
if len(hist.History) != 1 {
|
||||
t.Fatalf("history %d values", len(hist.History))
|
||||
}
|
||||
// The moves history over the real log: the open, from nothing to open, read by the other instance.
|
||||
var ch ChangesRead
|
||||
_ = json.Unmarshal(must(two.call("changes", map[string]any{"since": "60m"})), &ch)
|
||||
if len(ch.Changes) != 1 || ch.Changes[0].Number != 365 || ch.Changes[0].Kind != OpOpened || ch.Changes[0].To != StatusOpen ||
|
||||
ch.Changes[0].Title != "a milestone's issue" {
|
||||
t.Fatalf("changes %+v", ch)
|
||||
}
|
||||
evMu.Lock()
|
||||
defer evMu.Unlock()
|
||||
var kinds []string
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
package main
|
||||
|
||||
// changes: each issue's moves over a window, read from the tracker's own record, its log `changes` (novox/hq ADR 0300),
|
||||
// which holds every operation with who made it and when. The bucket `issues` keeps only what they add up to, so this
|
||||
// is the one place an issue's earlier status is known: knowledge-base.progress with since asks it for the "from" of
|
||||
// each move (the operator, 2026-10-28: the progress made in the last 60 minutes).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// MostChangesWindow is the longest window changes reads.
|
||||
const MostChangesWindow = 24 * time.Hour
|
||||
|
||||
// MostChanges is the most changes one answer holds, the newest kept.
|
||||
const MostChanges = 500
|
||||
|
||||
// changesSlack is how far before the window the log is read from: an entry's time is the bus's, an operation's the
|
||||
// tracker's, and the two may differ by a little.
|
||||
const changesSlack = time.Minute
|
||||
|
||||
// Change is one issue's status changing: opened, moved, resolved or wontfix, from what to what, by whom and when.
|
||||
type Change struct {
|
||||
Number uint64 `json:"number"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
By string `json:"by"`
|
||||
// Worker is who works on it, for a move to diagnosing or fixing: whom by names (the caller is By).
|
||||
Worker string `json:"worker,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// ChangesRead is the answer of changes.
|
||||
type ChangesRead struct {
|
||||
Since time.Time `json:"since"`
|
||||
Changes []Change `json:"changes"`
|
||||
// Entries is how many entries of the log were read for it.
|
||||
Entries int `json:"entries-read"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// Changes reads the moves of the last window, of every issue or of one.
|
||||
func (t *Tracker) Changes(window time.Duration, number uint64) (*ChangesRead, error) {
|
||||
if window <= 0 || window > MostChangesWindow {
|
||||
return nil, fmt.Errorf("since is a window more than nothing and at most 24h, not %s", window)
|
||||
}
|
||||
if t.Log == nil {
|
||||
return nil, errNoLog
|
||||
}
|
||||
from := t.now().Add(-window)
|
||||
out := &ChangesRead{Since: from, Changes: []Change{}}
|
||||
var numbers []uint64
|
||||
if number != 0 {
|
||||
numbers = []uint64{number}
|
||||
} else {
|
||||
var err error
|
||||
if numbers, err = t.movedSince(from, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, n := range numbers {
|
||||
entries, err := t.readKey(n, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
if number != 0 {
|
||||
return nil, fmt.Errorf("%d is not an issue the tracker holds", n)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out.Entries += len(entries)
|
||||
var cur *Issue
|
||||
for _, e := range entries {
|
||||
var op Operation
|
||||
if err := json.Unmarshal(e.Value, &op); err != nil {
|
||||
return nil, fmt.Errorf("the entry at %d is no operation this module reads: %v", e.Sequence, err)
|
||||
}
|
||||
next, err := fold(cur, &op, e.Sequence)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue %d cannot be folded from its log: %v", n, err)
|
||||
}
|
||||
was := ""
|
||||
if cur != nil {
|
||||
was = cur.Status
|
||||
}
|
||||
cur = next
|
||||
if op.Op == OpCarried || op.At.Before(from) || (was == next.Status && was != "") {
|
||||
continue
|
||||
}
|
||||
kind := EventMoved
|
||||
switch {
|
||||
case was == "" && (op.Op == OpOpened || op.Op == OpImported):
|
||||
kind = op.Op
|
||||
case next.Status == StatusResolved || next.Status == StatusWontfix:
|
||||
kind = next.Status
|
||||
}
|
||||
c := Change{Number: n, Kind: kind, From: was, To: next.Status, By: op.By, At: op.At}
|
||||
if next.Worker != nil && (next.Status == StatusDiagnosing || next.Status == StatusFixing) {
|
||||
c.Worker = next.Worker.By
|
||||
}
|
||||
out.Changes = append(out.Changes, c)
|
||||
}
|
||||
for i := range out.Changes {
|
||||
if out.Changes[i].Number == n && cur != nil {
|
||||
out.Changes[i].Title = cur.Title
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out.Changes, func(i, j int) bool {
|
||||
if !out.Changes[i].At.Equal(out.Changes[j].At) {
|
||||
return out.Changes[i].At.Before(out.Changes[j].At)
|
||||
}
|
||||
return out.Changes[i].Number < out.Changes[j].Number
|
||||
})
|
||||
cut := ""
|
||||
if len(out.Changes) > MostChanges {
|
||||
cut = fmt.Sprintf("; the oldest %d are left out", len(out.Changes)-MostChanges)
|
||||
out.Changes = out.Changes[len(out.Changes)-MostChanges:]
|
||||
}
|
||||
out.Said = fmt.Sprintf("%d changes of status since %s, from the tracker's log (%d entries read)%s.",
|
||||
len(out.Changes), from.Format(time.RFC3339), out.Entries, cut)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// movedSince is every issue an operation of which was written from the window on, found in the whole log by its
|
||||
// entries' times: the first entry of the window is searched for, then the log is read on from it.
|
||||
func (t *Tracker) movedSince(from time.Time, out *ChangesRead) ([]uint64, error) {
|
||||
first, err := t.Log.Read(1, "", 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the log could not be read: %v", err)
|
||||
}
|
||||
if len(first.Entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
lo, hi := first.Entries[0].Sequence, first.Last
|
||||
start := from.Add(-changesSlack)
|
||||
// The first sequence whose entry is at or after start: the entries are in the order they were written.
|
||||
for lo < hi {
|
||||
mid := lo + (hi-lo)/2
|
||||
p, err := t.Log.Read(mid, "", 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the log could not be read at %d: %v", mid, err)
|
||||
}
|
||||
out.Entries++
|
||||
if len(p.Entries) == 0 || !p.Entries[0].At.Before(start) {
|
||||
hi = mid
|
||||
} else {
|
||||
lo = p.Entries[0].Sequence + 1
|
||||
}
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
var numbers []uint64
|
||||
for seq := lo; seq != 0; {
|
||||
p, err := t.Log.Read(seq, "", logPage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the log could not be read from %d: %v", seq, err)
|
||||
}
|
||||
out.Entries += len(p.Entries)
|
||||
for _, e := range p.Entries {
|
||||
n, ok := numberOfKey(e.Key)
|
||||
if !ok || seen[n] || e.At.Before(start) || !movesStatus(e) {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
numbers = append(numbers, n)
|
||||
}
|
||||
seq = p.Next
|
||||
}
|
||||
return numbers, nil
|
||||
}
|
||||
|
||||
// movesStatus says whether a log entry may change an issue's status: one that holds the issue whole, or sets it.
|
||||
func movesStatus(e stdio.LogEntry) bool {
|
||||
var op Operation
|
||||
if json.Unmarshal(e.Value, &op) != nil {
|
||||
return true
|
||||
}
|
||||
_, sets := op.Set["status"]
|
||||
return sets || (op.Issue != nil && op.Op != OpCarried)
|
||||
}
|
||||
|
||||
// changesTool is the verb, its arguments read as the other verbs read them.
|
||||
func changesTool(t *Tracker) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + ".changes",
|
||||
Description: "Each issue's moves over a window, from the tracker's own log of every change: the issue moves history " +
|
||||
"of the last minutes or hours (since, such as 60m, at most 24h), every issue's or one's (number), each change " +
|
||||
"opened, moved, resolved or wontfix with the status it moved from and to, who moved it and when, oldest first. " +
|
||||
"What the issues are now is list and get; this is what they were before. Refused: a window of nothing or over " +
|
||||
"24h, a number that is not an issue.",
|
||||
Input: schema([]string{"since"}, map[string]any{
|
||||
"since": text("the window, back from now: 60m, 5m, 2h, at most 24h"),
|
||||
"number": number("only this issue"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("changes", m, "since", "number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := a.str("since")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("since is a window such as 60m, 5m or 2h, not %q", s)
|
||||
}
|
||||
n, err := a.number("number", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.Changes(d, n)
|
||||
}}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// changes reads the tracker's own log over a window (the lead, 2026-10-28, for knowledge-base.progress since): each
|
||||
// move from what to what, by whom and when, and each open, as the tracker's verbs wrote them into its log here.
|
||||
func TestChangesSaysEachMoveFromWhatToWhatOverAWindow(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clock := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC)
|
||||
h.Tracker.Now = func() time.Time { return clock }
|
||||
at := func(hh, mm int) { clock = time.Date(2026, 10, 28, hh, mm, 0, 0, time.UTC); h.log.clock = clock }
|
||||
at(9, 0)
|
||||
a := h.open(t, "an issue fixed in the window", "hq")
|
||||
at(9, 10)
|
||||
b := h.open(t, "an issue given up in the window")
|
||||
at(10, 0)
|
||||
if _, err := h.Move(MoveAsk{Number: a, Status: StatusDiagnosing, By: "a@g14"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at(10, 30)
|
||||
if _, err := h.Move(MoveAsk{Number: a, Status: StatusFixing, By: "a@g14"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at(10, 40)
|
||||
if _, err := h.Note(a, "a note moves nothing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at(11, 0)
|
||||
if _, err := h.Move(MoveAsk{Number: a, Status: StatusResolved, FixedBy: []string{"hq#1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at(11, 5)
|
||||
if _, err := h.Move(MoveAsk{Number: b, Status: StatusWontfix, Reason: "the module is retired"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at(11, 30)
|
||||
say := func(r *ChangesRead) string {
|
||||
var lines []string
|
||||
for _, c := range r.Changes {
|
||||
lines = append(lines, strings.Join([]string{c.At.Format("15:04"), c.Kind, itoa(int(c.Number)), c.From + ">" + c.To, c.By + "/" + c.Worker}, " "))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
got, err := h.call(t, "changes", map[string]any{"since": "60m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := got.(*ChangesRead)
|
||||
if want := "10:30 moved 365 diagnosing>fixing " + Unproven + "/a@g14\n11:00 resolved 365 fixing>resolved " + Unproven + "/\n11:05 wontfix 366 open>wontfix " + Unproven + "/"; say(r) != want {
|
||||
t.Errorf("the last hour:\n%s\nwant\n%s", say(r), want)
|
||||
}
|
||||
if !r.Since.Equal(time.Date(2026, 10, 28, 10, 30, 0, 0, time.UTC)) || r.Changes[0].Title != "an issue fixed in the window" || !strings.Contains(r.Said, "3 changes") {
|
||||
t.Errorf("since %v, title %q, said %q", r.Since, r.Changes[0].Title, r.Said)
|
||||
}
|
||||
got, err = h.call(t, "changes", map[string]any{"since": "3h", "number": b})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "09:10 opened 366 >open " + Unproven + "/\n11:05 wontfix 366 open>wontfix " + Unproven + "/"; say(got.(*ChangesRead)) != want {
|
||||
t.Errorf("one issue:\n%s", say(got.(*ChangesRead)))
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
if !strings.Contains(string(raw), `"from":"open","to":"wontfix"`) {
|
||||
t.Errorf("as JSON: %s", raw)
|
||||
}
|
||||
for _, bad := range []map[string]any{{"since": "25h"}, {"since": "0m"}, {}, {"since": "60m", "number": 9999}} {
|
||||
if _, err := h.call(t, "changes", bad); err == nil {
|
||||
t.Errorf("%v was answered", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,7 +559,7 @@ func cycleRules(is *Issue, from, to string) error {
|
||||
}
|
||||
if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" {
|
||||
return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+
|
||||
"runner, the SDK, the delivery parts, or the catalog's bus, forge or build modules): it resolves with replay, the replay's id "+
|
||||
"runner, the SDK, the delivery parts, or the catalogue's bus, forge or build modules): it resolves with replay, the replay's id "+
|
||||
"in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number)
|
||||
}
|
||||
case StatusWontfix:
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
"tracking",
|
||||
"reserve",
|
||||
"fix-times",
|
||||
"learn-fixes",
|
||||
"changes"
|
||||
"learn-fixes"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -47,8 +46,7 @@
|
||||
"tracking",
|
||||
"reserve",
|
||||
"fix-times",
|
||||
"learn-fixes",
|
||||
"changes"
|
||||
"learn-fixes"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -27,7 +27,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
|
||||
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
|
||||
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
|
||||
| `withdraw` | `number`, `kind`, `why` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn |
|
||||
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound`, `since` (a window such as `60m`, at most `24h`) | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`). With `since`, only what changed in the window under `of`, headed "N changes in the last 60 min": milestones moved (from what to what, from `issue-tracker.milestones` `one`), issues opened or moved from what to what (from `issue-tracker.changes`, the tracker's log), noted or linked (from `issue-tracker.get`), pull requests opened, checked, merged, held or closed (from `mesh-delivery.show`'s transitions and check). It keeps no state; its warnings say when the tracker cannot answer `changes` (a move is then said by the status it reached, not from what) and that mesh-delivery keeps no time a merge reached its machines |
|
||||
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound` | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`) |
|
||||
|
||||
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
|
||||
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
|
||||
|
||||
@@ -34,8 +34,6 @@ type Milestone struct {
|
||||
// Status is open or closed, with its Reason; a closed milestone holding no issue is done.
|
||||
Status string
|
||||
Reason string
|
||||
// Moved is who last moved it and when, as the tracker says it; nil before its first move.
|
||||
Moved *stamp
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -1130,19 +1130,14 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
"milestone, cheap enough for a report every five minutes; detailed gives the report's tree: each " +
|
||||
"milestone not done with its issues, their pull requests, the groups holding them and what each waits " +
|
||||
"for, for about every thirty. A design's milestones and their issues are the issue tracker's milestone " +
|
||||
"records (its milestones); a design with none is refused, naming the seed. Within a bound it states, 4 KiB unless asked for more, saying what it cut. " +
|
||||
"With since, a window such as 60m or 5m (at most 24h), it says only the progress made since then, such as in the last hour: " +
|
||||
"the milestones and issues that moved (from what to what where the tracker keeps it), were opened, noted or resolved, " +
|
||||
"the pull requests opened, checked, merged or held and the deliveries that reached their machines, and what became stuck, " +
|
||||
"headed by how many changes in the last N minutes.",
|
||||
"records (its milestones); a design with none is refused, naming the seed. Within a bound it states, 4 KiB unless asked for more, saying what it cut.",
|
||||
Input: schema([]string{"of"}, map[string]any{
|
||||
"of": text("what: mesh, design 47, milestone 47/R6, issue 1945, decision 493 (or ADR 0493) or research 72"),
|
||||
"detail": map[string]any{"type": "string", "enum": []string{DetailMinimal, DetailDetailed},
|
||||
"description": "minimal (the default): one line per child; detailed: the tree with issues, pull requests and groups"},
|
||||
"bound": boundArg(),
|
||||
"since": text("a window such as 60m, 5m or 2h, at most 24h: only what changed in it, from the tracker's and mesh-delivery's history")}),
|
||||
"bound": boundArg()}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("progress", m, "of", "detail", "bound", "since")
|
||||
a, err := readArgs("progress", m, "of", "detail", "bound")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1156,9 +1151,6 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
if p.Bound, err = a.num("bound", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Since, err = a.str("since"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Progress(p)
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -80,11 +80,11 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status",
|
||||
"records.records_list", "records.records_read", "seat:operator-channel.notify", "seat:issue-tracker.list", "seat:issue-tracker.get",
|
||||
"seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
|
||||
"seat:issue-tracker.milestones", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
|
||||
t.Fatalf("invokes %v", m.Invokes)
|
||||
}
|
||||
src := ""
|
||||
for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go"} {
|
||||
for _, f := range []string{"main.go", "hq.go", "progress.go", "milestones.go"} {
|
||||
src += readFile(t, f)
|
||||
}
|
||||
for _, inv := range m.Invokes {
|
||||
|
||||
@@ -7,7 +7,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MilestonesVerb is the tracker's verb that answers the milestones.
|
||||
@@ -21,13 +20,6 @@ type trackerMilestone struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Issues []int `json:"issues"`
|
||||
Moved *stamp `json:"moved"`
|
||||
}
|
||||
|
||||
// stamp is who did something and when, as the tracker says it.
|
||||
type stamp struct {
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// withMilestones is each design with its milestones as the tracker holds them, in their table's order; design 0 asks
|
||||
@@ -49,7 +41,7 @@ func (k *KB) withMilestones(ds []*Design, design int) ([]*Design, error) {
|
||||
}
|
||||
by := map[int][]Milestone{}
|
||||
for _, m := range l.Milestones {
|
||||
by[m.Design] = append(by[m.Design], Milestone{ID: m.ID, Outcome: m.Outcome, Issues: m.Issues, Status: m.Status, Reason: m.Reason, Moved: m.Moved})
|
||||
by[m.Design] = append(by[m.Design], Milestone{ID: m.ID, Outcome: m.Outcome, Issues: m.Issues, Status: m.Status, Reason: m.Reason})
|
||||
}
|
||||
out := make([]*Design, 0, len(ds))
|
||||
for _, d := range ds {
|
||||
|
||||
@@ -56,8 +56,6 @@ type ProgressAsk struct {
|
||||
Of string
|
||||
Detail string
|
||||
Bound int
|
||||
// Since is a window, such as 60m: the answer is then only what changed in it (progress_since.go).
|
||||
Since string
|
||||
}
|
||||
|
||||
// Item is one thing whose progress is said: a design, a milestone, an issue, a pull request.
|
||||
@@ -83,11 +81,6 @@ type Progress struct {
|
||||
Source string `json:"source"`
|
||||
ReadAt time.Time `json:"read-at"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
// Since and Changes are said for an answer about a window: how long it is and how many changes it holds.
|
||||
Since string `json:"since,omitempty"`
|
||||
Changes int `json:"changes,omitempty"`
|
||||
// scope is what the answer is about, kept for a window's answer; nil without since.
|
||||
scope *sinceScope
|
||||
}
|
||||
|
||||
// trackerRow is a row of `issue-tracker.list`.
|
||||
@@ -144,15 +137,9 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
|
||||
if k.Seats == nil {
|
||||
return nil, errors.New("the knowledge base reaches no other seat here")
|
||||
}
|
||||
window, err := parseSince(a.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
of := strings.Join(strings.Fields(a.Of), " ")
|
||||
p := &Progress{Of: of, Detail: detail, ReadAt: k.now()}
|
||||
if window > 0 {
|
||||
p.scope = &sinceScope{rows: map[int]trackerRow{}}
|
||||
}
|
||||
var err error
|
||||
switch {
|
||||
case strings.EqualFold(of, "mesh"):
|
||||
err = k.progressMesh(p)
|
||||
@@ -181,9 +168,6 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if window > 0 {
|
||||
k.progressSince(p, window)
|
||||
}
|
||||
// A cut takes the warnings from the last: the calls that failed are said before the answers that did not parse.
|
||||
callsFirst(p.Warnings)
|
||||
fitProgress(p, boundOf(a.Bound))
|
||||
@@ -356,9 +340,8 @@ func (k *KB) progressDesign(p *Progress, number int, milestone string) error {
|
||||
}
|
||||
items, warn := milestoneItems(ms, rows, missing)
|
||||
p.Warnings = append(p.Warnings, warn...)
|
||||
p.scope.hold(rows, ms)
|
||||
var extra *extras
|
||||
if p.Detail == DetailDetailed && p.scope == nil {
|
||||
if p.Detail == DetailDetailed {
|
||||
if extra, err = k.readExtras(items); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -411,10 +394,6 @@ func (k *KB) progressMesh(p *Progress) error {
|
||||
for _, n := range missingList {
|
||||
missing[n] = true
|
||||
}
|
||||
p.scope.hold(rows, with...)
|
||||
if p.scope != nil {
|
||||
p.scope.every = true
|
||||
}
|
||||
top := Item{ID: "mesh", Title: "the designs being built"}
|
||||
var kids []Item
|
||||
for _, d := range with {
|
||||
@@ -462,11 +441,6 @@ func (k *KB) progressIssue(p *Progress, n int) error {
|
||||
}
|
||||
it.ID = fmt.Sprintf("issue %d", n)
|
||||
p.Source = "the issue tracker and mesh-delivery"
|
||||
if p.scope != nil {
|
||||
p.scope.hold(rows)
|
||||
p.State, p.Items = it.State, []Item{it}
|
||||
return nil
|
||||
}
|
||||
extra, err := k.readExtras([]Item{{Children: []Item{{ID: strconv.Itoa(n), State: StateMoving}}}})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -539,9 +513,6 @@ func (k *KB) progressRecord(p *Progress, kind string, n int) error {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("%d issues are linked to %s; the first %d are counted", l.Matched, name, len(l.Rows)))
|
||||
}
|
||||
for _, r := range l.Rows {
|
||||
if p.scope != nil {
|
||||
p.scope.rows[r.Number] = r
|
||||
}
|
||||
if it, counts := issueItem(r); counts {
|
||||
kids = append(kids, it)
|
||||
}
|
||||
|
||||
@@ -58,24 +58,12 @@ func TestProgressOverTheBus(t *testing.T) {
|
||||
t.Errorf("detailed: no %q in\n%s", want, p.Tree)
|
||||
}
|
||||
}
|
||||
// A window over the bus: since is taken and the answer says how many changes it holds (none moved in the captured rows).
|
||||
raw, err = b.call(Seat+".progress", map[string]any{"of": "milestone 47/R6", "since": "60m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
if !strings.HasPrefix(p.Tree, "0 changes in the last 60 min under milestone 47/R6 (since ") || p.Since != "1h0m0s" {
|
||||
t.Errorf("since over the bus:\n%s", p.Tree)
|
||||
}
|
||||
if _, err := b.call(Seat+".progress", map[string]any{"of": "design 47", "since": "2d"}); err == nil {
|
||||
t.Error("a window of two days was taken")
|
||||
}
|
||||
if _, err := b.call(Seat+".progress", map[string]any{"of": "design 47", "by": "someone@else"}); err == nil {
|
||||
t.Error("an argument naming the caller was taken")
|
||||
}
|
||||
seats.mu.Lock()
|
||||
defer seats.mu.Unlock()
|
||||
if seats.asked["seat:issue-tracker.list"] != 3 || seats.asked["seat:mesh-delivery.groups"] != 1 || seats.asked[MilestonesVerb] != 3 {
|
||||
if seats.asked["seat:issue-tracker.list"] != 2 || seats.asked["seat:mesh-delivery.groups"] != 1 || seats.asked[MilestonesVerb] != 2 {
|
||||
t.Fatalf("asked %v", seats.asked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
package main
|
||||
|
||||
// progress with since: only what changed in a window under of (the operator, 2026-10-28: "can the progress tool also
|
||||
// report the progress made since the last call, or maybe better, the last 60 minutes"). A window, not a caller's last
|
||||
// call: the knowledge base keeps no state of its own, so it reads what the other seats keep, each a request and reply
|
||||
// over the bus:
|
||||
//
|
||||
// - the issue tracker's moves over the window, from its own log (`issue-tracker.changes`): each issue opened or
|
||||
// moved, from what to what, by whom and when;
|
||||
// - the issue tracker's milestones with their last move, and a moved one's history (`issue-tracker.milestones`
|
||||
// with one): what it was before, what it is now, who moved it and when;
|
||||
// - each issue that moved in the window whole (`issue-tracker.get`): when it was opened, its notes and links with who
|
||||
// and when, its worker since when, and when it was closed;
|
||||
// - mesh-delivery's deliveries and each one's transitions and its check (`mesh-delivery.deliveries`, `.show`).
|
||||
//
|
||||
// When the tracker cannot say its moves (a tracker without `changes`), a move is said by the status it reached, with its
|
||||
// worker's since or its closed time, not from what it moved, and the answer says so. mesh-delivery keeps no time a
|
||||
// merge reached its machines, so "on the machines" is as it reads now, said too.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MostSince is the longest window since asks: the deliveries mesh-delivery lists end a day back.
|
||||
const MostSince = 24 * time.Hour
|
||||
|
||||
// parseSince reads a window such as 60m, 5m or 2h; empty is none.
|
||||
func parseSince(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil || d <= 0 || d > MostSince {
|
||||
return 0, fmt.Errorf("since is a window such as 60m, 5m or 2h, more than nothing and at most %s, not %q", "24h", s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// sinceScope is what an answer about a window is about: the issues under of, as the tracker answered their rows, and
|
||||
// the milestones; every says that of is the mesh, so every delivery counts.
|
||||
type sinceScope struct {
|
||||
rows map[int]trackerRow
|
||||
milestones []sinceMilestone
|
||||
every bool
|
||||
}
|
||||
|
||||
type sinceMilestone struct {
|
||||
design int
|
||||
m Milestone
|
||||
}
|
||||
|
||||
// hold keeps the rows and the designs' milestones for a window's answer; on a nil scope it does nothing.
|
||||
func (s *sinceScope) hold(rows map[int]trackerRow, ds ...*Design) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for n, r := range rows {
|
||||
s.rows[n] = r
|
||||
}
|
||||
for _, d := range ds {
|
||||
for _, m := range d.Milestones {
|
||||
s.milestones = append(s.milestones, sinceMilestone{design: d.Number, m: m})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// change is one thing that changed in the window, with its changes, newest last.
|
||||
type change struct {
|
||||
line string
|
||||
last time.Time
|
||||
events []event
|
||||
}
|
||||
|
||||
type event struct {
|
||||
at time.Time
|
||||
line string
|
||||
// brief is the event in the change's one line, when it differs from line.
|
||||
brief string
|
||||
}
|
||||
|
||||
// window is the time between from and to.
|
||||
type window struct{ from, to time.Time }
|
||||
|
||||
func (w window) holds(t time.Time) bool { return !t.IsZero() && !t.Before(w.from) && !t.After(w.to) }
|
||||
|
||||
// progressSince replaces the answer's tree with what changed in the window under p.scope.
|
||||
func (k *KB) progressSince(p *Progress, d time.Duration) {
|
||||
w := window{from: p.ReadAt.Add(-d), to: p.ReadAt}
|
||||
p.Since, p.Items = d.String(), nil
|
||||
var milestones, issues, pulls []change
|
||||
milestones = k.milestoneChanges(p, w)
|
||||
issues = k.issueChanges(p, w)
|
||||
pulls = k.pullChanges(p, w)
|
||||
for _, cs := range [][]change{milestones, issues, pulls} {
|
||||
sort.SliceStable(cs, func(i, j int) bool { return cs[i].last.After(cs[j].last) })
|
||||
}
|
||||
all := append(append(milestones, issues...), pulls...)
|
||||
for _, c := range all {
|
||||
p.Changes += len(c.events)
|
||||
}
|
||||
var b strings.Builder
|
||||
noun := "changes"
|
||||
if p.Changes == 1 {
|
||||
noun = "change"
|
||||
}
|
||||
fmt.Fprintf(&b, "%d %s in the last %d min under %s (since %s)\n", p.Changes, noun, int(d.Minutes()), p.Of, when(&w.from))
|
||||
for i, c := range all {
|
||||
branch, under := "├── ", "│ "
|
||||
if i == len(all)-1 {
|
||||
branch, under = "└── ", " "
|
||||
}
|
||||
b.WriteString(branch + c.line + "\n")
|
||||
if p.Detail != DetailDetailed {
|
||||
continue
|
||||
}
|
||||
for j, e := range c.events {
|
||||
br := "├── "
|
||||
if j == len(c.events)-1 {
|
||||
br = "└── "
|
||||
}
|
||||
b.WriteString(under + br + e.line + "\n")
|
||||
}
|
||||
}
|
||||
p.Tree = strings.TrimRight(b.String(), "\n")
|
||||
p.Source += "; the window from the tracker's milestones and their history, its issues' notes, links, workers and " +
|
||||
"closes, and mesh-delivery's transitions and checks"
|
||||
}
|
||||
|
||||
// milestoneChanges is each milestone moved in the window, from its history: what it was and what it is.
|
||||
func (k *KB) milestoneChanges(p *Progress, w window) []change {
|
||||
var out []change
|
||||
asked := 0
|
||||
for _, sm := range p.scope.milestones {
|
||||
m := sm.m
|
||||
if m.Moved == nil || !w.holds(m.Moved.At) {
|
||||
continue
|
||||
}
|
||||
ref := fmt.Sprintf("%d/%s", sm.design, m.ID)
|
||||
now := m.Status
|
||||
if m.Reason != "" {
|
||||
now += ": " + m.Reason
|
||||
}
|
||||
was := "a state its history does not say"
|
||||
if asked < MostGets {
|
||||
asked++
|
||||
if prev, err := k.milestoneBefore(ref); err != nil {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("issue-tracker.milestones one %s: %v; said without what it was", ref, err))
|
||||
} else if prev != "" {
|
||||
was = prev
|
||||
}
|
||||
}
|
||||
mark := marks[StateMoving]
|
||||
if m.Status == "closed" {
|
||||
mark = marks[StateDone]
|
||||
}
|
||||
line := fmt.Sprintf("%s milestone %s %s → %s · by %s %s", mark, ref, was, now, m.Moved.By, when(&m.Moved.At))
|
||||
out = append(out, change{line: line, last: m.Moved.At, events: []event{{at: m.Moved.At, line: fmt.Sprintf("moved %s by %s", when(&m.Moved.At), m.Moved.By)}}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// milestoneBefore is a milestone's status before its last move, from the tracker's history of it.
|
||||
func (k *KB) milestoneBefore(ref string) (string, error) {
|
||||
raw, err := k.Seats(MilestonesVerb, map[string]any{"one": ref})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
History []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"history"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return "", fmt.Errorf("answered something else: %v", err)
|
||||
}
|
||||
if len(r.History) < 2 {
|
||||
return "", nil
|
||||
}
|
||||
return r.History[len(r.History)-2].Status, nil
|
||||
}
|
||||
|
||||
// issueGotWhole is the part of `issue-tracker.get` a window reads.
|
||||
type issueGotWhole struct {
|
||||
Status string `json:"status"`
|
||||
Opened time.Time `json:"opened"`
|
||||
OpenedBy string `json:"opened-by"`
|
||||
Closed *time.Time `json:"closed"`
|
||||
FixedBy []string `json:"fixed-by"`
|
||||
Worker *struct {
|
||||
By string `json:"by"`
|
||||
Since time.Time `json:"since"`
|
||||
} `json:"worker"`
|
||||
Notes []struct {
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Text string `json:"text"`
|
||||
} `json:"notes"`
|
||||
Links []struct {
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Kind string `json:"kind"`
|
||||
Target string `json:"target"`
|
||||
} `json:"links"`
|
||||
}
|
||||
|
||||
// issueChanges is each issue under of that moved in the window, read whole: opened, moved, noted, linked, closed.
|
||||
func (k *KB) issueChanges(p *Progress, w window) []change {
|
||||
var moved []trackerRow
|
||||
for _, r := range p.scope.rows {
|
||||
if r.Moved != nil && w.holds(*r.Moved) {
|
||||
moved = append(moved, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(moved, func(i, j int) bool { return moved[i].Moved.After(*moved[j].Moved) })
|
||||
var moves map[int][]trackerMove
|
||||
var merr error
|
||||
if len(moved) > 0 {
|
||||
if moves, merr = k.trackerMoves(w); merr != nil {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("issue-tracker.changes: %v; a move is said by the status it reached, "+
|
||||
"its worker's since and its closed time, not from what it moved", merr))
|
||||
}
|
||||
}
|
||||
var out []change
|
||||
for i, r := range moved {
|
||||
it, _ := issueItem(r)
|
||||
if it.State == "" {
|
||||
it.State = StateDone
|
||||
}
|
||||
head := fmt.Sprintf("%s %d %s — ", marks[it.State], r.Number, clip(r.Title, 80))
|
||||
fallback := func(why string) {
|
||||
p.Warnings = append(p.Warnings, why)
|
||||
line := fmt.Sprintf("moved %s, now %s", when(r.Moved), r.Status)
|
||||
if r.Worker != "" {
|
||||
line += " (" + r.Worker + ")"
|
||||
}
|
||||
out = append(out, change{line: head + line, last: *r.Moved, events: []event{{at: *r.Moved, line: line}}})
|
||||
}
|
||||
if i >= MostGets {
|
||||
fallback(fmt.Sprintf("more than %d issues moved; the rest are said by their rows' moved time", MostGets))
|
||||
continue
|
||||
}
|
||||
raw, err := k.Seats("seat:issue-tracker.get", map[string]any{"number": r.Number})
|
||||
if err != nil {
|
||||
fallback(fmt.Sprintf("issue-tracker.get %d: %v; said by its row's moved time", r.Number, err))
|
||||
continue
|
||||
}
|
||||
var g issueGotWhole
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
fallback(fmt.Sprintf("issue-tracker.get %d answered something else: %v; said by its row's moved time", r.Number, err))
|
||||
continue
|
||||
}
|
||||
var evs []event
|
||||
var says []string
|
||||
if merr == nil {
|
||||
for _, m := range moves[r.Number] {
|
||||
if m.Kind == "opened" || m.Kind == "imported" {
|
||||
evs = append(evs, event{at: m.At, line: fmt.Sprintf("%s %s by %s", m.Kind, when(&m.At), m.By)})
|
||||
says = append(says, m.Kind+" by "+m.By+" "+when(&m.At))
|
||||
continue
|
||||
}
|
||||
who, line := m.By, fmt.Sprintf("%s → %s %s by %s", m.From, m.To, when(&m.At), m.By)
|
||||
if m.Worker != "" {
|
||||
who, line = m.Worker, line+", worker "+m.Worker
|
||||
}
|
||||
short := fmt.Sprintf("%s → %s by %s %s", m.From, m.To, who, when(&m.At))
|
||||
if (m.To == "resolved" || m.To == "wontfix") && len(g.FixedBy) > 0 {
|
||||
short += ", fixed by " + strings.Join(g.FixedBy, ", ")
|
||||
}
|
||||
evs = append(evs, event{at: m.At, line: line})
|
||||
says = append(says, short)
|
||||
}
|
||||
}
|
||||
if merr != nil && w.holds(g.Opened) {
|
||||
evs = append(evs, event{at: g.Opened, line: fmt.Sprintf("opened %s by %s", when(&g.Opened), g.OpenedBy)})
|
||||
says = append(says, "opened by "+g.OpenedBy+" "+when(&g.Opened))
|
||||
}
|
||||
if merr != nil && g.Worker != nil && w.holds(g.Worker.Since) && (g.Status == "diagnosing" || g.Status == "fixing") {
|
||||
s := fmt.Sprintf("→ %s by %s %s", g.Status, g.Worker.By, when(&g.Worker.Since))
|
||||
evs = append(evs, event{at: g.Worker.Since, line: s})
|
||||
says = append(says, s)
|
||||
}
|
||||
if merr != nil && g.Closed != nil && w.holds(*g.Closed) {
|
||||
s := fmt.Sprintf("→ %s %s", g.Status, when(g.Closed))
|
||||
if len(g.FixedBy) > 0 {
|
||||
s += ", fixed by " + strings.Join(g.FixedBy, ", ")
|
||||
}
|
||||
evs = append(evs, event{at: *g.Closed, line: s})
|
||||
says = append(says, s)
|
||||
}
|
||||
notes := 0
|
||||
var last string
|
||||
for _, n := range g.Notes {
|
||||
if w.holds(n.At) {
|
||||
notes++
|
||||
last = fmt.Sprintf("the last %s by %s", when(&n.At), n.By)
|
||||
evs = append(evs, event{at: n.At, line: fmt.Sprintf("noted %s by %s: %s", when(&n.At), n.By, clip(n.Text, 80))})
|
||||
}
|
||||
}
|
||||
if notes == 1 {
|
||||
says = append(says, "1 note, "+last)
|
||||
} else if notes > 1 {
|
||||
says = append(says, fmt.Sprintf("%d notes, %s", notes, last))
|
||||
}
|
||||
links := 0
|
||||
for _, l := range g.Links {
|
||||
if w.holds(l.At) {
|
||||
links++
|
||||
evs = append(evs, event{at: l.At, line: fmt.Sprintf("linked %s %s by %s %s", l.Kind, l.Target, l.By, when(&l.At))})
|
||||
}
|
||||
}
|
||||
if links > 0 {
|
||||
says = append(says, fmt.Sprintf("%d links", links))
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
// It moved by what the tracker records of nobody: its fix's delivery said a new state.
|
||||
s := fmt.Sprintf("moved %s, now %s", when(r.Moved), it.Why)
|
||||
evs, says = []event{{at: *r.Moved, line: s}}, []string{s}
|
||||
}
|
||||
if it.State == StateStuck {
|
||||
says = append(says, "stuck: "+it.Why)
|
||||
}
|
||||
sort.SliceStable(evs, func(i, j int) bool { return evs[i].at.Before(evs[j].at) })
|
||||
out = append(out, change{line: head + strings.Join(says, "; "), last: *r.Moved, events: evs})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deliveryWhole is the part of `mesh-delivery.show` a window reads.
|
||||
type deliveryWhole struct {
|
||||
Delivery struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Issues []int `json:"issues"`
|
||||
State string `json:"state"`
|
||||
Check *struct {
|
||||
At time.Time `json:"at"`
|
||||
Gate string `json:"gate"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"check"`
|
||||
Transitions []struct {
|
||||
At time.Time `json:"at"`
|
||||
Event string `json:"event"`
|
||||
To string `json:"to"`
|
||||
Why string `json:"why"`
|
||||
} `json:"transitions"`
|
||||
} `json:"delivery"`
|
||||
Machines string `json:"machines"`
|
||||
Waits string `json:"waits"`
|
||||
}
|
||||
|
||||
// pullChanges is each pull request naming an issue under of (every one, for the mesh) that was opened, checked,
|
||||
// merged, held or closed in the window.
|
||||
func (k *KB) pullChanges(p *Progress, w window) []change {
|
||||
raw, err := k.Seats("seat:mesh-delivery.deliveries", map[string]any{})
|
||||
if err != nil {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery does not answer, so no pull request is said: %v", err))
|
||||
return nil
|
||||
}
|
||||
var dl struct {
|
||||
Deliveries []deliveryRow `json:"deliveries"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &dl); err != nil {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.deliveries answered something else, so no pull request is said: %v", err))
|
||||
return nil
|
||||
}
|
||||
var out []change
|
||||
shown, reached := 0, false
|
||||
for _, d := range dl.Deliveries {
|
||||
// A delivery's since is its last transition; one not final may still have been checked since.
|
||||
if final(d.State) && !w.holds(d.Since) {
|
||||
continue
|
||||
}
|
||||
if shown == MostShows {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("more than %d deliveries moved; the rest are not read", MostShows))
|
||||
break
|
||||
}
|
||||
shown++
|
||||
raw, err := k.Seats("seat:mesh-delivery.show", map[string]any{"id": d.ID})
|
||||
if err != nil {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.show %s: %v", d.ID, err))
|
||||
continue
|
||||
}
|
||||
var s deliveryWhole
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.show %s answered something else: %v", d.ID, err))
|
||||
continue
|
||||
}
|
||||
under := p.scope.every
|
||||
var names []string
|
||||
for _, n := range s.Delivery.Issues {
|
||||
names = append(names, strconv.Itoa(n))
|
||||
if _, ok := p.scope.rows[n]; ok {
|
||||
under = true
|
||||
}
|
||||
}
|
||||
if !under {
|
||||
continue
|
||||
}
|
||||
var evs []event
|
||||
for _, t := range s.Delivery.Transitions {
|
||||
if !w.holds(t.At) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case t.Event == "announced":
|
||||
evs = append(evs, event{at: t.At, line: "opened " + when(&t.At)})
|
||||
case t.To == "merged" || t.To == "closed" || strings.HasPrefix(t.To, "held") || t.To == "delivered":
|
||||
evs = append(evs, event{t.At, fmt.Sprintf("%s %s: %s", t.To, when(&t.At), clip(t.Why, 80)), t.To + " " + when(&t.At)})
|
||||
}
|
||||
}
|
||||
if c := s.Delivery.Check; c != nil && w.holds(c.At) {
|
||||
evs = append(evs, event{c.At, fmt.Sprintf("check %s %s: %s", c.Gate, when(&c.At), clip(c.Summary, 80)), fmt.Sprintf("check %s %s", c.Gate, when(&c.At))})
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(evs, func(i, j int) bool { return evs[i].at.Before(evs[j].at) })
|
||||
var says []string
|
||||
for _, e := range evs {
|
||||
if e.brief != "" {
|
||||
says = append(says, e.brief)
|
||||
} else {
|
||||
says = append(says, e.line)
|
||||
}
|
||||
}
|
||||
state := s.Delivery.State
|
||||
mark := marks[StateMoving]
|
||||
switch {
|
||||
case state == "merged" || state == "delivered":
|
||||
mark = marks[StateDone]
|
||||
case strings.HasPrefix(state, "held"):
|
||||
mark = marks[StateStuck]
|
||||
case state == "closed":
|
||||
mark = marks[StateWaiting]
|
||||
}
|
||||
repo := s.Delivery.Repository
|
||||
if i := strings.LastIndex(repo, "/"); i >= 0 {
|
||||
repo = repo[i+1:]
|
||||
}
|
||||
line := fmt.Sprintf("%s %s#%d (%s) %s", mark, repo, s.Delivery.Number, strings.Join(names, ", "), strings.Join(says, " · "))
|
||||
switch {
|
||||
case state == "merged" && s.Machines != "":
|
||||
line += ", " + s.Machines
|
||||
reached = true
|
||||
case !final(state):
|
||||
line += ", now " + state
|
||||
if s.Waits != "" {
|
||||
line += ", waits for " + s.Waits
|
||||
}
|
||||
}
|
||||
out = append(out, change{line: line, last: evs[len(evs)-1].at, events: evs})
|
||||
}
|
||||
if reached {
|
||||
p.Warnings = append(p.Warnings, "mesh-delivery keeps no time a merge reached its machines: \"on the machines\" is as it reads now")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trackerMove is one change of `issue-tracker.changes`: an issue opened or moved, from what to what, by whom and when.
|
||||
type trackerMove struct {
|
||||
Number int `json:"number"`
|
||||
Kind string `json:"kind"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
By string `json:"by"`
|
||||
Worker string `json:"worker"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// trackerMoves asks the tracker for every issue's moves in the window, from its own log, by issue, oldest first.
|
||||
func (k *KB) trackerMoves(w window) (map[int][]trackerMove, error) {
|
||||
raw, err := k.Seats(ChangesVerb, map[string]any{"since": w.to.Sub(w.from).String()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Changes []trackerMove `json:"changes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, fmt.Errorf("answered something else: %v", err)
|
||||
}
|
||||
out := map[int][]trackerMove{}
|
||||
for _, m := range r.Changes {
|
||||
if w.holds(m.At) {
|
||||
out[m.Number] = append(out[m.Number], m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChangesVerb is the tracker's verb that answers its issues' moves over a window.
|
||||
const ChangesVerb = "seat:issue-tracker.changes"
|
||||
@@ -2,8 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -395,150 +393,3 @@ func TestProgressOfAnIssueAndARecord(t *testing.T) {
|
||||
t.Error("an issue the tracker does not hold was answered")
|
||||
}
|
||||
}
|
||||
|
||||
// sinceWorld plays the tracker and the delivery seat from what they answered on 2026-10-28 (testdata/progress/since):
|
||||
// a get or a show it holds no answer for is refused, as a seat that cannot answer. The tracker's changes answers what
|
||||
// mesh-issues' verb made of 1422's open and move to fixing (testdata/progress/README.md); without changes it is
|
||||
// refused, as by a tracker that has no such verb yet.
|
||||
func sinceWorld(t *testing.T, changes bool) func(string, any) (json.RawMessage, error) {
|
||||
t.Helper()
|
||||
read := func(name string) json.RawMessage { return readFixture(t, filepath.Join("since", name)) }
|
||||
var rows []json.RawMessage
|
||||
if err := json.Unmarshal(read("rows.json"), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byNumber := map[int]json.RawMessage{}
|
||||
for _, r := range rows {
|
||||
var n struct{ Number int }
|
||||
_ = json.Unmarshal(r, &n)
|
||||
byNumber[n.Number] = r
|
||||
}
|
||||
shows := map[string]string{"novox/mesh-catalog@470687ea4cce": "show-mesh-catalog-539.json", "novox/mesh-catalog@f5d4b2f86ed4": "show-mesh-catalog-537.json",
|
||||
"novox/mesh-delivery@2b801f5bd49a": "show-mesh-delivery-94.json", "novox/de-spiegel@fba6dbe3259e": "show-de-spiegel-6.json"}
|
||||
return func(key string, body any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
var a struct {
|
||||
Numbers []int `json:"numbers"`
|
||||
Number int `json:"number"`
|
||||
ID string `json:"id"`
|
||||
One string `json:"one"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
switch key {
|
||||
case MilestonesVerb:
|
||||
return read("milestone-47-6.json"), nil
|
||||
case "seat:issue-tracker.list":
|
||||
var out []json.RawMessage
|
||||
for _, n := range a.Numbers {
|
||||
if r, ok := byNumber[n]; ok {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return json.Marshal(map[string]any{"rows": out, "matched": len(out)})
|
||||
case "seat:issue-tracker.changes":
|
||||
if changes {
|
||||
return read("changes.json"), nil
|
||||
}
|
||||
return nil, errors.New("mesh-issues has no tool issue-tracker.changes")
|
||||
case "seat:issue-tracker.get":
|
||||
if a.Number == 1422 || a.Number == 1956 {
|
||||
return read(fmt.Sprintf("get-%d.json", a.Number)), nil
|
||||
}
|
||||
case "seat:mesh-delivery.deliveries":
|
||||
return read("deliveries.json"), nil
|
||||
case "seat:mesh-delivery.groups":
|
||||
return json.RawMessage(`[]`), nil
|
||||
case "seat:mesh-delivery.show":
|
||||
if f, ok := shows[a.ID]; ok {
|
||||
return read(f), nil
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
}
|
||||
|
||||
// The operator, 2026-10-28: "can the progress tool also report the progress made since the last call, or maybe better,
|
||||
// the last 60 minutes". With since, an answer lists only what changed in that window under of, read from the
|
||||
// tracker's notes, links, workers and closes, its milestones' history and mesh-delivery's transitions and checks.
|
||||
func TestProgressSinceAWindowSaysOnlyWhatChanged(t *testing.T) {
|
||||
at := func(s string) *time.Time { v, _ := time.Parse(time.RFC3339, s); return &v }
|
||||
k := &KB{Records: progressRecords(), Seats: sinceWorld(t, true), Now: func() time.Time { return *at("2026-10-28T11:13:30Z") }}
|
||||
p, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(p.Tree, "\n")
|
||||
if lines[0] != "17 changes in the last 120 min under issue 1422 (since "+when(at("2026-10-28T09:13:30Z"))+")" || p.Changes != 17 || p.Since != "2h0m0s" {
|
||||
t.Fatalf("%d changes, since %q:\n%s", p.Changes, p.Since, p.Tree)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"▶ 1422 " + clip("The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none", 80) + " — open → fixing by fix-trunk-unknown@shanks " + when(at("2026-10-28T09:38:37Z")) +
|
||||
"; 9 notes, the last " + when(at("2026-10-28T11:05:21Z")) + " by shanks.node-tools",
|
||||
"✓ mesh-catalog#537 (1422) opened " + when(at("2026-10-28T10:20:56Z")) + " · check pass " + when(at("2026-10-28T10:30:45Z")) + " · merged " + when(at("2026-10-28T10:34:49Z")) + ", on the machines",
|
||||
"✓ mesh-delivery#94 (1126, 1422) opened ",
|
||||
"▶ mesh-catalog#539 (1422, 1956) opened " + when(at("2026-10-28T11:07:42Z")) + ", now ready, waits for its merge",
|
||||
} {
|
||||
if !strings.Contains(p.Tree, want) {
|
||||
t.Errorf("no %q in\n%s", want, p.Tree)
|
||||
}
|
||||
}
|
||||
if strings.Contains(p.Tree, "de-spiegel") || strings.Contains(p.Tree, "triage") {
|
||||
t.Errorf("a change outside issue 1422 or the window is said:\n%s", p.Tree)
|
||||
}
|
||||
w := strings.Join(p.Warnings, "\n")
|
||||
if !strings.Contains(w, "keeps no time a merge reached its machines") || strings.Contains(w, "issue-tracker.changes") {
|
||||
t.Errorf("the warnings: %v", p.Warnings)
|
||||
}
|
||||
// A tracker that cannot say its moves: each is said by the status it reached, and that is said.
|
||||
old := &KB{Records: progressRecords(), Seats: sinceWorld(t, false), Now: k.Now}
|
||||
o, err := old.Progress(ProgressAsk{Of: "issue 1422", Since: "2h"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(o.Tree, " — → fixing by fix-trunk-unknown@shanks "+when(at("2026-10-28T09:38:37Z"))) || o.Changes != 17 ||
|
||||
!strings.Contains(strings.Join(o.Warnings, "\n"), "issue-tracker.changes: mesh-issues has no tool issue-tracker.changes; a move is said by the status it reached") {
|
||||
t.Errorf("without changes:\n%s\n%v", o.Tree, o.Warnings)
|
||||
}
|
||||
d, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h", Detail: DetailDetailed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"│ ├── open → fixing " + when(at("2026-10-28T09:38:37Z")) + " by shanks.node-tools, worker fix-trunk-unknown@shanks",
|
||||
"│ ├── noted " + when(at("2026-10-28T09:38:09Z")) + " by shanks.node-tools: lead-47@shanks 2026-10-28 09:40Z.",
|
||||
" └── merged " + when(at("2026-10-28T10:34:49Z")) + ": on the trunk its modules follow: merged there"} {
|
||||
if !strings.Contains(d.Tree, want) {
|
||||
t.Errorf("detailed: no %q in\n%s", want, d.Tree)
|
||||
}
|
||||
}
|
||||
// A milestone's issue the tracker's get cannot answer falls back to its row's moved time, said as such.
|
||||
m, err := k.Progress(ProgressAsk{Of: "milestone 47/L2", Since: "60m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"6 changes in the last 60 min under milestone 47/L2", "▶ 1126 " + clip("About 49,800 controller code lines stay after the delivery model, against a first limit of 25,000", 80) + " — moved " +
|
||||
when(at("2026-10-28T11:13:28Z")) + ", now fixing (lead-47@shanks)", "✓ de-spiegel#6 (1126) check pass ", "✓ mesh-delivery#94 (1126, 1422) opened "} {
|
||||
if !strings.Contains(m.Tree, want) {
|
||||
t.Errorf("milestone: no %q in\n%s", want, m.Tree)
|
||||
}
|
||||
}
|
||||
if w := strings.Join(m.Warnings, "\n"); !strings.Contains(w, "issue-tracker.get 1126") {
|
||||
t.Errorf("the fallback is not said: %v", m.Warnings)
|
||||
}
|
||||
if small, _ := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h", Detail: DetailDetailed, Bound: LeastBound}); small == nil || size(small) > LeastBound || !strings.Contains(small.Cut, "raise bound") {
|
||||
t.Errorf("a window's answer is not kept within its bound: %+v", small)
|
||||
}
|
||||
// A milestone's move, from its history: what it was before, what it is now, who moved it and when.
|
||||
k.Now = func() time.Time { return *at("2026-10-26T18:40:00Z") }
|
||||
ds, err := k.Progress(ProgressAsk{Of: "design 47", Since: "60m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ds.Tree != "1 change in the last 60 min under design 47 (since "+when(at("2026-10-26T17:40:00Z"))+")\n└── ✓ milestone 47/6 open → closed: folded into R4g · by shanks.node-tools "+when(at("2026-10-26T18:10:10Z")) {
|
||||
t.Errorf("design:\n%s", ds.Tree)
|
||||
}
|
||||
for _, bad := range []string{"0m", "-5m", "25h", "a day", "5"} {
|
||||
if _, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: bad}); err == nil || !strings.Contains(err.Error(), "since") {
|
||||
t.Errorf("since %q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,3 @@ Captured for the progress verb's tests (novox/hq issue 1947), never written by h
|
||||
- `tracker-milestones-47.json`: the answer of `issue-tracker.milestones` for design 47 (issue 1781), made by running
|
||||
the tracker's own table parse (mesh-issues `parseTable`, its seed) over `hq/`'s design 47, each issue two rows name
|
||||
left in neither, as the seed refuses it; the tracker held no milestone records yet to capture from.
|
||||
- `since/`: answers of the live tracker and delivery seat on 2026-10-28 between 11:10Z and 11:20Z, for `since`
|
||||
(issue 1422 and its pull requests): `issue-tracker.list` rows, `issue-tracker.get` 1422 and 1956 (the report,
|
||||
the triage and each note's text after its first sentence or two cut), `mesh-delivery.deliveries` (its first ten
|
||||
rows) and `.show` of four deliveries (the ready record, the ask and the paths cut), and `issue-tracker.milestones`
|
||||
with `one` 47/6, its milestones joined by 47/L2's row from `one` 47/L2.
|
||||
- `since/changes.json`: the answer of mesh-issues' `issue-tracker.changes` (window 2h at 2026-10-28 11:13:30Z) made by
|
||||
the tracker's own code over its log, replaying issue 1422's open (10-20 14:36:07Z) and its move to fixing by
|
||||
fix-trunk-unknown@shanks (10-28 09:38:37Z) through its verbs: the live verb was not delivered yet to capture from, and
|
||||
the status 1422 moved from (open) is the replay's, as no verb read the live log then. Recapture once it is delivered.
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"since": "2026-10-28T09:13:30Z",
|
||||
"changes": [
|
||||
{
|
||||
"number": 1422,
|
||||
"title": "The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none",
|
||||
"kind": "moved",
|
||||
"from": "open",
|
||||
"to": "fixing",
|
||||
"by": "shanks.node-tools",
|
||||
"worker": "fix-trunk-unknown@shanks",
|
||||
"at": "2026-10-28T09:38:37Z"
|
||||
}
|
||||
],
|
||||
"entries-read": 4,
|
||||
"said": "1 changes of status since 2026-10-28T09:13:30Z, from the tracker's log (4 entries read)."
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{"deliveries": [
|
||||
{"for": "4m8s", "id": "novox/mesh-catalog@470687ea4cce", "number": 539, "since": "2026-10-28T11:07:43Z", "state": "ready", "waits": "its merge"},
|
||||
{"for": "14m17s", "id": "novox/mesh-catalog@fc5335360967", "number": 538, "since": "2026-10-28T10:57:33Z", "state": "closed"},
|
||||
{"for": "26m23s", "id": "novox/de-spiegel@fba6dbe3259e", "machines": "on the machines", "number": 6, "since": "2026-10-28T10:45:28Z", "state": "merged"},
|
||||
{"for": "36m56s", "group": "fix/1422-trunk-unknown", "id": "novox/mesh-delivery@2b801f5bd49a", "machines": "on the machines", "number": 94, "since": "2026-10-28T10:34:54Z", "state": "merged"},
|
||||
{"for": "37m2s", "group": "fix/1422-trunk-unknown", "id": "novox/mesh-catalog@f5d4b2f86ed4", "machines": "on the machines", "number": 537, "since": "2026-10-28T10:34:49Z", "state": "merged"},
|
||||
{"for": "56m30s", "id": "novox/hq@dd36b7557ae6", "machines": "on the machines", "number": 489, "since": "2026-10-28T10:15:20Z", "state": "merged"},
|
||||
{"for": "1h3m4s", "id": "novox/mesh-host@6502ba6ad6e6", "machines": "on the machines", "number": 159, "since": "2026-10-28T10:08:47Z", "state": "merged"},
|
||||
{"for": "1h24m56s", "group": "fix/1126-seed-same", "id": "novox/mesh-host@c912bcab6e73", "machines": "on the machines", "number": 158, "since": "2026-10-28T09:46:55Z", "state": "merged"},
|
||||
{"for": "2h51m31s", "id": "novox/mesh-catalog@c2606f99178a", "number": 534, "since": "2026-10-28T08:20:20Z", "state": "closed"},
|
||||
{"for": "3h25m11s", "group": "fix/1126-shadow-bodies", "id": "novox/mesh-host@82d0e125b2c0", "machines": "on the machines", "number": 157, "since": "2026-10-28T07:46:40Z", "state": "merged"}
|
||||
]}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"fixing-from": "2026-10-28T09:38:37Z",
|
||||
"links": [
|
||||
{"at": "2026-10-20T14:37:00Z", "by": "triage", "kind": "relates-to", "target": "1421"},
|
||||
{"at": "2026-10-20T14:37:00Z", "by": "triage", "kind": "relates-to", "target": "1420"}
|
||||
],
|
||||
"located-in": ["mesh-controller cmd/mesh-controller/catch_up.go"],
|
||||
"needs": "none",
|
||||
"notes": [
|
||||
{"at": "2026-10-20T14:37:00Z", "by": "triage", "text": "Triage of this issue: none of 429 older issues not resolved (the 429 most recent of 493; the titles of the rest did not fit the question) asks the same question."},
|
||||
{"at": "2026-10-20T14:44:57Z", "by": "triage", "text": "Triage: issue 1424 relates to this one, opened later."},
|
||||
{"at": "2026-10-20T14:45:10Z", "by": "triage", "text": "Triage: issue 1425 relates to this one, opened later."},
|
||||
{"at": "2026-10-28T09:38:09Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 09:40Z. New kind beside this issue (diag-despiegel-check@shanks): the checker judges a pull request only when the gate's source.main names its repository"},
|
||||
{"at": "2026-10-28T09:43:50Z", "by": "shanks.node-tools", "text": "fix-trunk-unknown@shanks, 2026-10-28. Quick fix: the checker says loudly that a pull request cannot be judged when source.main names no trunk head of its repository."},
|
||||
{"at": "2026-10-28T09:51:00Z", "by": "shanks.node-tools", "text": "fix-trunk-unknown@shanks, 2026-10-28, part (b) built locally, not pushed."},
|
||||
{"at": "2026-10-28T10:00:02Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:05Z. de-spiegel to main (the operator, 09:58Z: 'yes use the main branch please')."},
|
||||
{"at": "2026-10-28T10:07:56Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:10Z. Side finding (tools-gitea-base@shanks): modules/gitea guard.ts and gitea_api's refusal text cite 'ADR 0266'"},
|
||||
{"at": "2026-10-28T10:40:43Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:41Z. Fix group delivered in part (gate 4c091b08, gitea a6cc3509 on novox at 10:39Z; indexer applying; bridge and checker pending)."},
|
||||
{"at": "2026-10-28T10:44:38Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:46Z. Subtask (the operator asked why a retarget waits for a poll)"},
|
||||
{"at": "2026-10-28T10:45:16Z", "by": "shanks.node-tools", "text": "lead-47@shanks 2026-10-28 10:49Z. Correction to note 10"},
|
||||
{"at": "2026-10-28T11:05:21Z", "by": "shanks.node-tools", "text": "Gitea polling, quick versus thorough (fix-gitea-events@shanks, branch fix/1422-gitea-events, commit 470687ea)."}
|
||||
],
|
||||
"number": 1422,
|
||||
"opened": "2026-10-20T14:36:07Z",
|
||||
"opened-by": "shanks.node-tools",
|
||||
"ready": true,
|
||||
"severity": "minor",
|
||||
"status": "fixing",
|
||||
"title": "The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none",
|
||||
"worker": {"active": "2026-10-28T11:05:21Z", "by": "fix-trunk-unknown@shanks", "since": "2026-10-28T09:38:37Z"}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"links": [
|
||||
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1903"},
|
||||
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1327"},
|
||||
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1420"},
|
||||
{"at": "2026-10-28T11:07:31Z", "by": "triage", "kind": "relates-to", "target": "1184"}
|
||||
],
|
||||
"needs": "decision",
|
||||
"notes": [
|
||||
{"at": "2026-10-28T11:07:31Z", "by": "triage", "text": "Triage of this issue: not a duplicate; closest is 1903."}
|
||||
],
|
||||
"number": 1956,
|
||||
"opened": "2026-10-28T11:07:10Z",
|
||||
"opened-by": "shanks.node-tools",
|
||||
"ready": true,
|
||||
"severity": "major",
|
||||
"status": "open",
|
||||
"title": "Parts of the mesh still poll or wait on timers instead of reacting to events on the bus"
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{"history": [
|
||||
{"design": 47, "id": "6", "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "order": 9, "outcome": "Folded into release 4 by ADR 0429 §10 (the fetch over SSH in R4c, the cut-over in R4e): installed toolchains, builders fetching over SSH with their own key, and an SDK change shipping with its users are built in the builder's new home, so the builder is changed once", "status": "open"},
|
||||
{"design": 47, "id": "6", "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "moved": {"at": "2026-10-26T18:10:10Z", "by": "shanks.node-tools"}, "order": 9, "outcome": "Folded into release 4 by ADR 0429 §10 (the fetch over SSH in R4c, the cut-over in R4e): installed toolchains, builders fetching over SSH with their own key, and an SDK change shipping with its users are built in the builder's new home, so the builder is changed once", "reason": "folded into R4g", "status": "closed"}
|
||||
],
|
||||
"milestones": [
|
||||
{"design": 47, "done": 0, "id": "6", "issues": [], "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "moved": {"at": "2026-10-26T18:10:10Z", "by": "shanks.node-tools"}, "order": 9, "outcome": "Folded into release 4 by ADR 0429 §10 (the fetch over SSH in R4c, the cut-over in R4e): installed toolchains, builders fetching over SSH with their own key, and an SDK change shipping with its users are built in the builder's new home, so the builder is changed once", "reason": "folded into R4g", "ref": "47/6", "status": "closed"},
|
||||
{"design": 47, "done": 0, "id": "L2", "issues": [1126], "made": {"at": "2026-10-26T18:09:47Z", "by": "shanks.node-tools"}, "order": 31, "outcome": "Nodes compose: each node agent composes its own modules, and no node applies a composition made elsewhere but the bus's user list (ADR 0547 §11)", "ref": "47/L2", "status": "open"}
|
||||
],
|
||||
"said": "2 milestones."}
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{"moved": "2026-10-28T11:13:28Z", "needs": "decision", "number": 1126, "owner": "mesh-controller", "ready": false, "severity": "minor", "since": "2026-10-27T01:01:41Z", "status": "fixing", "title": "About 49,800 controller code lines stay after the delivery model, against a first limit of 25,000", "worker": "lead-47@shanks"},
|
||||
{"moved": "2026-10-28T11:05:21Z", "needs": "none", "number": 1422, "owner": "mesh-controller cmd/mesh-controller/catch_up.go", "ready": true, "severity": "minor", "since": "2026-10-28T09:38:37Z", "status": "fixing", "title": "The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none", "worker": "fix-trunk-unknown@shanks"},
|
||||
{"moved": "2026-10-28T11:07:31Z", "needs": "decision", "number": 1956, "owner": "mesh-host; mesh-catalog modules/gitea; mesh-delivery; mesh-controller", "ready": true, "severity": "major", "status": "open", "title": "Parts of the mesh still poll or wait on timers instead of reacting to events on the bus"}
|
||||
]
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{"delivery": {"id": "novox/de-spiegel@fba6dbe3259e", "number": 6, "repository": "novox/de-spiegel", "issues": [1126], "state": "merged", "since": "2026-10-28T10:45:28.285947172Z",
|
||||
"title": "Take the practice's public names from config, not the definition (issue 1126)",
|
||||
"check": {"at": "2026-10-28T10:44:13.035037566Z", "gate": "pass", "head": "fba6dbe3259e", "status": "mesh/check", "summary": "pass fba6dbe3259e on 927d302714af | unit 0 run 0 kept | int 0 run 0 kept | clone 31s lint 177ms | composition passed", "trunk": "927d302714af"},
|
||||
"merged_at": "2026-10-28T10:45:00Z",
|
||||
"transitions": [
|
||||
{"at": "2026-10-28T08:49:52.467925371Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/de-spiegel#6's head announced"},
|
||||
{"at": "2026-10-28T08:49:54.046089933Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"},
|
||||
{"at": "2026-10-28T10:45:28.285947172Z", "event": "merged", "from": "ready", "to": "merged", "why": "on the trunk its modules follow: merged there"}
|
||||
]},
|
||||
"machines": "on the machines", "waits": ""}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{"delivery": {"id": "novox/mesh-catalog@f5d4b2f86ed4", "number": 537, "repository": "novox/mesh-catalog", "issues": [1422], "group": "fix/1422-trunk-unknown", "state": "merged", "since": "2026-10-28T10:34:49.628764012Z",
|
||||
"title": "Gate seeds a never-merged repository's trunk head and follows a retargeted base; gitea announces a retarget and sets a default branch and a pull request's base (issue 1422; group fix/1422-trunk-unknown)",
|
||||
"check": {"at": "2026-10-28T10:30:45.793798647Z", "gate": "pass", "head": "f5d4b2f86ed4", "status": "mesh/check", "summary": "pass f5d4b2f86ed4 on 9fd95693db54 | unit 1329 run, 3852 kept, 13 skipped | integration 1330 run, 3968 kept, 21 skipped | over bound: unit, …", "trunk": "9fd95693db54"},
|
||||
"merged_at": "2026-10-28T10:34:47Z",
|
||||
"transitions": [
|
||||
{"at": "2026-10-28T10:20:56.142821047Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/mesh-catalog#537's head announced"},
|
||||
{"at": "2026-10-28T10:20:58.259805356Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"},
|
||||
{"at": "2026-10-28T10:34:49.628764012Z", "event": "merged", "from": "ready", "to": "merged", "why": "on the trunk its modules follow: merged there"}
|
||||
]},
|
||||
"machines": "on the machines", "waits": ""}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
{"delivery": {"id": "novox/mesh-catalog@470687ea4cce", "number": 539, "repository": "novox/mesh-catalog", "issues": [1422, 1956], "state": "ready", "since": "2026-10-28T11:07:43.555244108Z",
|
||||
"title": "gitea announces from the forge's events, not a poll: an edited base and a close are announced at once, one pass at start (issue 1422)",
|
||||
"check_state": "pending", "merged_at": "0001-01-01T00:00:00Z",
|
||||
"transitions": [
|
||||
{"at": "2026-10-28T11:07:42.740718441Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/mesh-catalog#539's head announced"},
|
||||
{"at": "2026-10-28T11:07:43.555244108Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"}
|
||||
]},
|
||||
"waits": "its merge"}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{"delivery": {"id": "novox/mesh-delivery@2b801f5bd49a", "number": 94, "repository": "novox/mesh-delivery", "issues": [1126, 1422], "group": "fix/1422-trunk-unknown", "state": "merged", "since": "2026-10-28T10:34:54.759088217Z",
|
||||
"title": "Say loudly what cannot be judged, read only a merge's changed directories, and move the taken key only by an applied merge (issues 1422, 1126; group fix/1422-trunk-unknown)",
|
||||
"check": {"at": "2026-10-28T10:30:45.875693927Z", "gate": "pass", "head": "2b801f5bd49a", "status": "mesh/check", "summary": "pass 2b801f5bd49a on ae7ec702a5e9 | unit 1329 run, 3852 kept, 13 skipped | integration 1330 run, 3968 kept, 21 skipped | over bound: unit, …", "trunk": "ae7ec702a5e9"},
|
||||
"merged_at": "2026-10-28T10:34:51Z",
|
||||
"transitions": [
|
||||
{"at": "2026-10-28T10:20:50.017669046Z", "event": "announced", "from": "", "to": "proposed", "why": "novox/mesh-delivery#94's head announced"},
|
||||
{"at": "2026-10-28T10:20:51.900542745Z", "event": "accepted", "from": "proposed", "to": "ready", "why": "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and the repository's own check did not fail; or the check gates no merge (check-gates-merge false)"},
|
||||
{"at": "2026-10-28T10:34:54.759088217Z", "event": "merged", "from": "ready", "to": "merged", "why": "on the trunk its modules follow: merged there"}
|
||||
]},
|
||||
"machines": "on the machines", "waits": ""}
|
||||
@@ -88,7 +88,6 @@
|
||||
"seat:issue-tracker.list",
|
||||
"seat:issue-tracker.get",
|
||||
"seat:issue-tracker.milestones",
|
||||
"seat:issue-tracker.changes",
|
||||
"seat:mesh-delivery.deliveries",
|
||||
"seat:mesh-delivery.show",
|
||||
"seat:mesh-delivery.groups"
|
||||
|
||||
@@ -206,29 +206,10 @@ account (and, until R1, the controller) and no other caller, and only sealed to
|
||||
answers the controller alone today; it must also answer `<node>.mesh-vault` when it moves with the media catalogue,
|
||||
after release 57.
|
||||
|
||||
## Taking what runs: `move-in` with `replace_running` (issue 1126, cause C4)
|
||||
|
||||
`move-in` never replaces a value the vault holds, so where the controller made an own secret anew after the move (a
|
||||
rotation at start, a re-issue), the vault kept the first value while the new one runs. The node-engine of the machine
|
||||
the secret is for hands the vault what runs, through `move-in` with `replace_running: true`, in an intake's two steps
|
||||
(`owntake.go`): module, name and why, then the value sealed to the call's key. It is `move-in`'s and no verb of its
|
||||
own, so the node-engine asks it on the subject it may publish already and the seat promises nothing new. Its own machine's secrets alone (the machine is the caller's, as the bus proved it); a
|
||||
value the vault holds, never a person's (`accept`) or a provider's software's (`issued`, but for a covered bus
|
||||
account); never over a value the vault changed in the last 15 minutes. The same value changes nothing and says so.
|
||||
The replaced value is kept sealed as the record's `previous` (dropped by any later change), the generation is raised,
|
||||
`secret-changed` and `own-replaced` are said, and the act is in `secret_handed` (`own-replaced`, `own-same`,
|
||||
`own-take-refused`). A covered bus account so replaced has its hash written again. `own_drift` lists, from every
|
||||
node's latest shadow answer (`<node>.node` in `node-engine.have`), each own secret whose node says another value,
|
||||
with what the vault holds and whether the node's ask would be taken now. **The switch** is the vault's: a person
|
||||
switches every machine's asks off or on with `own_take_switch` (`on`, `why`), kept in the vault's store over a
|
||||
restart; it is on until switched off, and `own_drift` shows it.
|
||||
|
||||
## Events
|
||||
|
||||
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
|
||||
or a sealed copy.
|
||||
- `own-replaced` — `secret`, `node`, `module`, `name`, `from_generation`, `to_generation`, `by`, `why`, `at`:
|
||||
an own secret replaced by what runs on its machine. Never a value or a fingerprint.
|
||||
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
|
||||
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
|
||||
`reenabled`, `deleted`, naming holders, never a value.
|
||||
|
||||
@@ -91,9 +91,7 @@ type callKey struct {
|
||||
// ask is the ask's record for `secret-ask`, which the call's id is.
|
||||
ask *AskRecord
|
||||
// move is what `move-in` takes: the row of the controller's store (move.go).
|
||||
move *MoveOf
|
||||
// why is what `move-in` with replace_running was asked for, in the caller's words (owntake.go).
|
||||
why string
|
||||
move *MoveOf
|
||||
public [32]byte
|
||||
private [32]byte
|
||||
until time.Time
|
||||
@@ -151,16 +149,6 @@ func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, strin
|
||||
return id, base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||
}
|
||||
|
||||
// callKind is the kind of the open call id, or "" when none is open: what its second step is for.
|
||||
func (v *Vault) callKind(id string) string {
|
||||
v.callsMu.Lock()
|
||||
defer v.callsMu.Unlock()
|
||||
if c, ok := v.pending[id]; ok {
|
||||
return c.verb
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// takeCall ends a call: its key is forgotten whatever follows.
|
||||
func (v *Vault) takeCall(verb, id string) (*callKey, error) {
|
||||
v.callsMu.Lock()
|
||||
|
||||
@@ -51,8 +51,7 @@ func build() *Env {
|
||||
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
|
||||
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
|
||||
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
||||
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave,
|
||||
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }}
|
||||
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave}
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
@@ -321,139 +319,3 @@ func TestMoveInThroughTheTool(t *testing.T) {
|
||||
t.Fatalf("asks through the tool: %+v, %v", moved, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1126, cause C4: a machine's node-engine hands the vault what runs of its own machine's own secret, where the
|
||||
// vault holds another. Refused for another machine's secret, one that is not an own secret or not held, a person's or a
|
||||
// provider's software's value, an empty value, and one the vault changed in the last 15 minutes; the same value changes
|
||||
// nothing. A replacement keeps the old value as the previous generation, raises the generation, and says own-replaced
|
||||
// with both generations and never a value; own_drift lists it from the node's shadow answer.
|
||||
func TestANodeEngineHandsTheVaultWhatRunsOnItsOwnMachine(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
tick, later := v.Now, time.Duration(0)
|
||||
v.Now = func() time.Time { return tick().Add(later) }
|
||||
old, running := "the-value-moved-in-first", "the-value-the-controller-made-at-start"
|
||||
if _, err := moveIn(t, v, "node.anchor", superuser(), old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, origin := range []string{"accepted", "issued"} {
|
||||
m := superuser()
|
||||
m.Name, m.Origin = "from-"+origin, origin
|
||||
if _, err := moveIn(t, v, "node.anchor", m, "value-"+origin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
later = time.Hour
|
||||
// Through move-in's tool as the node-engine asks it: replace_running in the first step, the second known by its call.
|
||||
moveInTool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".move-in")
|
||||
take := func(caller string, of OwnTakeOf, value string) (OwnReplaced, error) {
|
||||
got, err := moveInTool.runAs(caller, map[string]any{ReplaceRunningArg: true, "node": of.Node, "module": of.Module,
|
||||
"name": of.Name, "secret": of.Secret, "why": of.Why})
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
opened := got.(Opened)
|
||||
got, err = moveInTool.runAs(caller, map[string]any{"call": opened.Call, "sealed": sealToCall(t, opened.SealTo, value)})
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
return got.(OwnReplaced), nil
|
||||
}
|
||||
pg := OwnTakeOf{Module: "postgres", Name: "superuser-password", Why: "the shadow answer says another value"}
|
||||
for name, row := range map[string]struct {
|
||||
caller string
|
||||
of OwnTakeOf
|
||||
value string
|
||||
says string
|
||||
}{
|
||||
"another machine's, named": {"node.shanks", OwnTakeOf{Node: "anchor", Module: "postgres", Name: "superuser-password"}, running, "anchor's"},
|
||||
"another machine's, whole name": {"node.shanks", OwnTakeOf{Secret: "own/anchor/postgres/superuser-password"}, running, "anchor's"},
|
||||
"not a node-engine": {ControllerCaller, pg, running, "not a node-engine"},
|
||||
"a module's tools": {"anchor.node-tools", pg, running, "not a node-engine"},
|
||||
"not an own secret": {"node.anchor", OwnTakeOf{Secret: "pair/anchor/database/shanks/gitea"}, running, "not a module's own secret"},
|
||||
"not held": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "never-made"}, running, "nothing to replace"},
|
||||
"a person's value": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "from-accepted"}, running, "by a person"},
|
||||
"a provider's software's value": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "from-issued"}, running, "issued by its provider"},
|
||||
"an empty value": {"node.anchor", pg, " ", "empty"},
|
||||
} {
|
||||
if _, err := take(row.caller, row.of, row.value); err == nil || !strings.Contains(err.Error(), row.says) {
|
||||
t.Errorf("%s: %v, want refused saying %q", name, err, row.says)
|
||||
}
|
||||
}
|
||||
if rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password"); rec.Generation != 1 || rec.Previous != nil {
|
||||
t.Fatalf("a refusal changed the secret: generation %d, previous %+v", rec.Generation, rec.Previous)
|
||||
}
|
||||
// The value the vault holds already changes nothing, and says so.
|
||||
if same, err := take("node.anchor", pg, old); err != nil || !same.Unchanged || same.To != 1 {
|
||||
t.Errorf("the same value: %+v %v", same, err)
|
||||
}
|
||||
// What runs replaces it: one generation more, the old kept as the previous, origin kept, its fingerprint answered.
|
||||
got, err := take("node.anchor", pg, running)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password")
|
||||
if got.From != 1 || got.To != 2 || got.Unchanged || got.Fingerprint != Fingerprint(running) || rec.Generation != 2 ||
|
||||
rec.Origin != OriginMade || rec.ChangedBy != "node.anchor" || rec.Previous == nil || rec.Previous.Generation != 1 {
|
||||
t.Fatalf("replaced %+v, the record at generation %d origin %q by %s, previous %+v", got, rec.Generation, rec.Origin,
|
||||
rec.ChangedBy, rec.Previous)
|
||||
}
|
||||
if now, _ := seal.Open(rec.AtRest, v.Own.PrivateKey); now != running {
|
||||
t.Error("the vault does not hold what runs")
|
||||
}
|
||||
if was, _ := seal.Open(rec.Previous.AtRest, v.Own.PrivateKey); was != old ||
|
||||
openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Previous.Operator.Sealed) != old {
|
||||
t.Error("the previous value is not kept, for the vault and the operator")
|
||||
}
|
||||
// Said once, with both generations and why, never a value.
|
||||
var replaced []map[string]any
|
||||
for _, e := range v.bus.events {
|
||||
if e["event"] == OwnReplacedEvent {
|
||||
replaced = append(replaced, e["body"].(map[string]any))
|
||||
}
|
||||
}
|
||||
if len(replaced) != 1 || replaced[0]["secret"] != "own/anchor/postgres/superuser-password" ||
|
||||
replaced[0]["from_generation"] != 1 || replaced[0]["to_generation"] != 2 || replaced[0]["node"] != "anchor" ||
|
||||
replaced[0]["why"] != pg.Why {
|
||||
t.Errorf("own-replaced said %v", replaced)
|
||||
}
|
||||
if all := everything(t, v, got); strings.Contains(all, running) || strings.Contains(all, old) {
|
||||
t.Fatal("a value is in something the vault said, answered or wrote in the clear")
|
||||
}
|
||||
// Taken again at once, over the value the vault just took: refused.
|
||||
if _, err := take("node.anchor", pg, "a-value-older-than-the-vaults"); err == nil || !strings.Contains(err.Error(), "less than") {
|
||||
t.Errorf("a running value over one the vault just took: %v", err)
|
||||
}
|
||||
// Switched off by a person, every ask is refused, saying who and why; on again, taken again.
|
||||
later += time.Hour
|
||||
if _, err := v.SwitchOwnTake("jochen", false, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := take("node.anchor", pg, "after-the-switch"); err == nil || !strings.Contains(err.Error(), "switched off (by jochen") {
|
||||
t.Errorf("an ask while switched off: %v", err)
|
||||
}
|
||||
if _, err := v.SwitchOwnTake("jochen", true, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later -= time.Hour
|
||||
// own_drift reads it from the node's latest shadow answer, mesh-wide; the id as a node runs it, its module's name
|
||||
// first (captured from novox's shadow answer, 2026-10-28: only-office.needs-jwt).
|
||||
answer := delivery.CandidateAnswer{Head: "h", Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{
|
||||
Module: "postgres", Why: "the resource differs", Bodies: []delivery.ResourceBodies{{ID: "postgres.needs-superuser-password",
|
||||
Composed: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`, Running: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`}}}}}
|
||||
have, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": answer},
|
||||
Composes: delivery.ComposesController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.HaveKeys = func() ([]string, error) { return []string{"anchor.node", "anchor.postgres"}, nil }
|
||||
v.Haves = func(key string) ([]byte, error) { return have, nil }
|
||||
drift, err := v.OwnDrift()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(drift.Secrets) != 1 || drift.Secrets[0].Secret != "own/anchor/postgres/superuser-password" ||
|
||||
drift.Secrets[0].Generation != 2 || drift.Secrets[0].Previous != 1 || drift.Secrets[0].Takeable ||
|
||||
len(drift.Nodes) != 1 || drift.Nodes[0].ValuesDiffer["g"] != 1 || drift.Nodes[0].Composes != delivery.ComposesController {
|
||||
t.Errorf("own_drift: %+v", drift)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
package main
|
||||
|
||||
// The vault takes what runs (novox/hq issue 1126, cause C4; ADR 0550 §5: a module's own secrets are the vault's).
|
||||
//
|
||||
// `move-in` never replaces a value the vault holds (move.go), so its first import of a machine's own secret is fixed.
|
||||
// Where the controller later made that secret anew (a rotation at start, a re-issue) without handing it here, the new
|
||||
// value is what runs and what its consumers hold, and the vault keeps the old one. Only the machine the value is sealed
|
||||
// to can open what runs, so its node-engine hands it in through `move-in` with replace_running: true, in the two steps
|
||||
// of every intake (intake.go): name the module and the secret and why, and get a call and a key made for it; then send
|
||||
// the value sealed to that key. It is move-in's, not a verb of its own, so a node-engine asks it on the subject it may
|
||||
// already publish and no seat promises anything new (the lead, 2026-10-28).
|
||||
//
|
||||
// **From the node-engine of the machine the secret is for, about its own machine alone**: the machine is the caller's,
|
||||
// as the bus proved it (`node.<machine>`), never an argument; a machine named that is not the caller's is refused, and so
|
||||
// is a name that is not an own secret. **Only a value the vault holds is replaced**, and never a person's (accept) or a
|
||||
// provider's software's (issued) but for a covered bus account, nor one the vault changed less than RecentlyChanged ago:
|
||||
// a running value is older than a value the vault just took (a re-issue the machine has not applied yet), and is never
|
||||
// put back over it. The same value again changes nothing and says so; an empty one is refused.
|
||||
//
|
||||
// The value replaced is kept as the record's previous generation (Record.Previous), the generation is raised,
|
||||
// `secret-changed` and `own-replaced` are said (the secret, the machine, both generations and why; never a value), and the
|
||||
// act is written in the record of hand-outs (secret_handed). A covered bus account so replaced has its hash written again
|
||||
// (accounts.go), so the bus-accounts key is of the value the bus runs.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ReplaceRunningArg is the argument of `move-in` that makes it take a running own secret over the one the vault holds.
|
||||
const ReplaceRunningArg = "replace_running"
|
||||
|
||||
// ReplaceRunning names the act in what the vault says, and is the kind of its open calls.
|
||||
const ReplaceRunning = "move-in with " + ReplaceRunningArg
|
||||
|
||||
// OwnReplacedEvent is what the vault says when it replaced an own secret with what runs: never a value.
|
||||
const OwnReplacedEvent = "own-replaced"
|
||||
|
||||
// RecentlyChanged is how long after the vault changed a secret a running value is not taken over it.
|
||||
var RecentlyChanged = 15 * time.Minute
|
||||
|
||||
// OwnTakeOf names the secret of one `move-in` with replace_running: Module and Name (or Secret whole), Node only to be checked
|
||||
// against the caller's machine, and Why in the caller's words.
|
||||
type OwnTakeOf struct {
|
||||
Node, Module, Name, Secret, Why string
|
||||
}
|
||||
|
||||
// OwnReplaced is what one `move-in` with replace_running did: the secret, the generation it replaced and the one it holds now, the
|
||||
// fingerprint of the value held now (what the node-engine checks against the value it handed), whether the value was
|
||||
// held already. Never a value.
|
||||
type OwnReplaced struct {
|
||||
Secret string `json:"secret"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
From int `json:"from_generation"`
|
||||
To int `json:"to_generation"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// ownTakeSecret is the secret a node-engine names, and its machine: refused for a caller that is no node-engine, for
|
||||
// another machine, and for a name that is not an own secret.
|
||||
func ownTakeSecret(caller string, of OwnTakeOf) (secret, node string, err error) {
|
||||
node, ok := nodeEngineOf(caller)
|
||||
if !ok {
|
||||
if caller == "" || caller == stdio.Unproven {
|
||||
caller = "an unproven caller"
|
||||
}
|
||||
return "", "", fmt.Errorf("%s takes a value from a machine's node-engine alone, about its own machine; %s is "+
|
||||
"not a node-engine; nothing is taken", ReplaceRunning, caller)
|
||||
}
|
||||
if of.Secret != "" {
|
||||
parts := strings.Split(of.Secret, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" {
|
||||
return "", "", fmt.Errorf("%s is not a module's own secret (own/<machine>/<module>/<name>): %s replaces "+
|
||||
"those alone; nothing is taken", of.Secret, ReplaceRunning)
|
||||
}
|
||||
if of.Node != "" && of.Node != parts[1] {
|
||||
return "", "", fmt.Errorf("%s is not on %s; nothing is taken", of.Secret, of.Node)
|
||||
}
|
||||
of.Node, of.Module, of.Name = parts[1], parts[2], parts[3]
|
||||
}
|
||||
if of.Node != "" && of.Node != node {
|
||||
return "", "", fmt.Errorf("%s may hand the vault its own machine's secrets alone, and %s/%s is %s's: nothing "+
|
||||
"is taken", caller, of.Module, of.Name, of.Node)
|
||||
}
|
||||
secret, err = OwnSecret(node, of.Module, of.Name)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%w; nothing is taken", err)
|
||||
}
|
||||
return secret, node, nil
|
||||
}
|
||||
|
||||
// ownTakeRefusal says why the held record rec is not replaced by a running value, or "".
|
||||
func ownTakeRefusal(rec Record, now time.Time) string {
|
||||
switch {
|
||||
case rec.Origin == OriginAccepted:
|
||||
return fmt.Sprintf("%s was given by a person, and a person alone gives it anew (accept); nothing is taken", rec.Name)
|
||||
case rec.Origin == OriginIssued && !Covered(rec.Name):
|
||||
return fmt.Sprintf("%s is issued by its provider's software, and `issued` is the only way it enters the vault "+
|
||||
"(ADR 0305 §10); nothing is taken", rec.Name)
|
||||
case now.Sub(rec.ChangedAt) < RecentlyChanged:
|
||||
return fmt.Sprintf("the vault changed %s at %s (generation %d, by %s), less than %s ago: a running value is "+
|
||||
"taken only over an older one, so a value the vault just took is never put back; nothing is taken",
|
||||
rec.Name, rec.ChangedAt.Format(time.RFC3339), rec.Generation, rec.ChangedBy, RecentlyChanged)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OwnTakeOpen opens a call to take the running value of one of the caller's machine's own secrets.
|
||||
func (v *Vault) OwnTakeOpen(caller string, of OwnTakeOf) (Opened, error) {
|
||||
secret, _, err := ownTakeSecret(caller, of)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
why := strings.TrimSpace(of.Why)
|
||||
if len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
|
||||
return Opened{}, errors.New("why is not one short line; nothing is taken")
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
refused := v.ownTakeOff()
|
||||
switch {
|
||||
case refused != "":
|
||||
case !held:
|
||||
refused = fmt.Sprintf("the vault holds no secret named %s, so there is nothing to replace; nothing is taken", secret)
|
||||
default:
|
||||
refused = ownTakeRefusal(rec, v.Now())
|
||||
}
|
||||
if refused != "" {
|
||||
_ = v.recordHanding(Handing{To: caller, Name: secret, Generation: rec.Generation, Outcome: "own-take-refused", Why: refused})
|
||||
return Opened{}, errors.New(refused)
|
||||
}
|
||||
id, key, err := v.openCall(ReplaceRunning, secret, caller, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
v.callsMu.Lock()
|
||||
if c := v.pending[id]; c != nil {
|
||||
c.why = why
|
||||
}
|
||||
v.callsMu.Unlock()
|
||||
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// OwnTakeTake opens the value sealed to the call's key and replaces the secret with it, keeping the value it replaces.
|
||||
func (v *Vault) OwnTakeTake(caller, call, sealed string) (OwnReplaced, error) {
|
||||
if _, ok := nodeEngineOf(caller); !ok {
|
||||
return OwnReplaced{}, fmt.Errorf("%s takes a value from a machine's node-engine alone; %q is not one",
|
||||
ReplaceRunning, caller)
|
||||
}
|
||||
c, err := v.takeCall(ReplaceRunning, call)
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
if c.by != caller {
|
||||
return OwnReplaced{}, fmt.Errorf("the %s call %q was opened by another caller; nothing was taken", ReplaceRunning, call)
|
||||
}
|
||||
value, err := c.open(sealed)
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
defer zero(value)
|
||||
parts := strings.Split(c.secret, "/")
|
||||
out := OwnReplaced{Secret: c.secret, Node: parts[1], Module: parts[2], Name: parts[3]}
|
||||
if strings.TrimSpace(string(value)) == "" {
|
||||
why := "the value handed is empty; nothing was taken"
|
||||
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: why})
|
||||
return OwnReplaced{}, errors.New(why)
|
||||
}
|
||||
now := v.Now()
|
||||
changed, err := v.keepWith(c.secret, string(value), caller, "", "", keepOptions{replaceOnly: true, keepPrevious: true,
|
||||
refuse: func(before Record) string { return ownTakeRefusal(before, now) }})
|
||||
if err != nil {
|
||||
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: err.Error()})
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
out.Fingerprint = Fingerprint(string(value))
|
||||
if changed.Unchanged {
|
||||
out.From, out.To, out.Unchanged = changed.Generation, changed.Generation, true
|
||||
out.Said = fmt.Sprintf("the vault holds this value of %s already (generation %d): nothing changed", c.secret, changed.Generation)
|
||||
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Generation: changed.Generation, Outcome: "own-same", Why: c.why})
|
||||
v.log("%s handed %s's running value, the one the vault holds (generation %d): nothing changed", caller, c.secret,
|
||||
changed.Generation)
|
||||
return out, nil
|
||||
}
|
||||
out.From, out.To = changed.Generation-1, changed.Generation
|
||||
out.Said = fmt.Sprintf("%s is now the value that runs on %s, generation %d; generation %d is kept as the previous",
|
||||
c.secret, out.Node, out.To, out.From)
|
||||
if err := v.recordHanding(Handing{To: caller, Name: c.secret, Generation: out.To, Outcome: "own-replaced", Why: c.why}); err != nil {
|
||||
out.Said += "; the record of hand-outs could not be written: " + err.Error()
|
||||
}
|
||||
v.log("replaced %s (generation %d → %d) with the value that runs, handed by %s: %s", c.secret, out.From, out.To,
|
||||
caller, someoneOr(c.why, "no reason given"))
|
||||
if v.Emit != nil {
|
||||
body := map[string]any{"secret": c.secret, "node": out.Node, "module": out.Module, "name": out.Name,
|
||||
"from_generation": out.From, "to_generation": out.To, "by": caller, "why": c.why,
|
||||
"at": changed.At.Format(time.RFC3339Nano)}
|
||||
if err := v.Emit(OwnReplacedEvent, body); err != nil {
|
||||
v.log("%s is replaced at generation %d, but %s could not be said: %v", c.secret, out.To, OwnReplacedEvent, err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- own_drift ----------------------------------------------------------------------------------------------
|
||||
|
||||
// DriftRow is one own secret a node's latest shadow answer says the vault holds another value of than runs there.
|
||||
type DriftRow struct {
|
||||
Secret string `json:"secret"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
Groups []string `json:"groups"`
|
||||
Held bool `json:"held"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
Changed time.Time `json:"changed,omitzero"`
|
||||
ChangedBy string `json:"changed_by,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Previous int `json:"previous_generation,omitempty"`
|
||||
// Takeable says move-in with replace_running would take the running value now; Refused why not.
|
||||
Takeable bool `json:"takeable"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// DriftNode is one node's answers as read: whose compositions it applies, and per group how many values differ.
|
||||
type DriftNode struct {
|
||||
Node string `json:"node"`
|
||||
Composes string `json:"composes,omitempty"`
|
||||
ValuesDiffer map[string]int `json:"values_differ,omitempty"`
|
||||
Unread string `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// Drift is own_drift's answer.
|
||||
type Drift struct {
|
||||
Switch OwnTakeSwitch `json:"switch"`
|
||||
Secrets []DriftRow `json:"secrets"`
|
||||
Nodes []DriftNode `json:"nodes"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// OwnDrift reads every node's own have key (`<node>.node`) and lists, from its latest answers to the placement
|
||||
// candidates, every own secret whose shown bodies say another value (delivery.OwnValuesDiffer), with what the vault holds
|
||||
// of it and whether move-in with replace_running would take the running value now.
|
||||
func (v *Vault) OwnDrift() (Drift, error) {
|
||||
if v.Haves == nil || v.HaveKeys == nil {
|
||||
return Drift{}, errors.New("the vault reads no node's have, so it cannot say what drifted")
|
||||
}
|
||||
keys, err := v.HaveKeys()
|
||||
if err != nil {
|
||||
return Drift{}, fmt.Errorf("the keys of %s could not be listed: %w", HaveRead, err)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
rows := map[string]*DriftRow{}
|
||||
out := Drift{Secrets: []DriftRow{}, Nodes: []DriftNode{},
|
||||
Note: "from each node's latest shadow answers; a value is never shown: move-in with replace_running from that node replaces it"}
|
||||
for _, key := range keys {
|
||||
node, isNode := strings.CutSuffix(key, "."+delivery.HaveNodeWord)
|
||||
if !isNode || !namePart.MatchString(node) {
|
||||
continue
|
||||
}
|
||||
dn := DriftNode{Node: node}
|
||||
raw, err := v.Haves(key)
|
||||
if err != nil || raw == nil {
|
||||
dn.Unread = fmt.Sprintf("its have could not be read: %v", err)
|
||||
out.Nodes = append(out.Nodes, dn)
|
||||
continue
|
||||
}
|
||||
have, err := delivery.DecodeHaveNode(raw)
|
||||
if err != nil {
|
||||
dn.Unread = err.Error()
|
||||
out.Nodes = append(out.Nodes, dn)
|
||||
continue
|
||||
}
|
||||
dn.Composes = have.Composes
|
||||
for group, a := range have.Candidates {
|
||||
if n := delivery.ValuesDiffer(a); n > 0 {
|
||||
if dn.ValuesDiffer == nil {
|
||||
dn.ValuesDiffer = map[string]int{}
|
||||
}
|
||||
dn.ValuesDiffer[group] = n
|
||||
}
|
||||
for _, o := range delivery.OwnValuesDiffer(a) {
|
||||
secret, err := OwnSecret(node, o.Module, o.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r := rows[secret]
|
||||
if r == nil {
|
||||
r = &DriftRow{Secret: secret, Node: node, Module: o.Module, Name: o.Name}
|
||||
rows[secret] = r
|
||||
}
|
||||
r.Groups = append(r.Groups, group)
|
||||
}
|
||||
}
|
||||
out.Nodes = append(out.Nodes, dn)
|
||||
}
|
||||
names := make([]string, 0, len(rows))
|
||||
for n := range rows {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if out.Switch, err = v.Store.OwnTakeSwitch(); err != nil {
|
||||
return Drift{}, err
|
||||
}
|
||||
off := v.ownTakeOff()
|
||||
for _, n := range names {
|
||||
r := rows[n]
|
||||
sort.Strings(r.Groups)
|
||||
rec, held, err := v.Store.Read(n)
|
||||
switch {
|
||||
case err != nil:
|
||||
r.Refused = err.Error()
|
||||
case !held:
|
||||
r.Refused = "the vault holds no secret named " + n
|
||||
default:
|
||||
r.Held, r.Generation, r.Changed, r.ChangedBy, r.Origin = true, rec.Generation, rec.ChangedAt, rec.ChangedBy, rec.Origin
|
||||
if rec.Previous != nil {
|
||||
r.Previous = rec.Previous.Generation
|
||||
}
|
||||
r.Refused = ownTakeRefusal(rec, v.Now())
|
||||
if off != "" {
|
||||
r.Refused = off
|
||||
}
|
||||
r.Takeable = r.Refused == ""
|
||||
}
|
||||
out.Secrets = append(out.Secrets, *r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- own_take_switch ----------------------------------------------------------------------------------------
|
||||
|
||||
// OwnTakeSwitch is whether move-in with replace_running takes values, who switched it last, when and why. On until a person
|
||||
// switches it off; kept in the vault's own store, so it holds over a restart.
|
||||
type OwnTakeSwitch struct {
|
||||
On bool `json:"on"`
|
||||
By string `json:"by,omitempty"`
|
||||
At time.Time `json:"at,omitzero"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) ownTakeSwitchPath() string { return filepath.Join(s.dir, "replace-running.json") }
|
||||
|
||||
// OwnTakeSwitch is the switch as kept: on when nothing was ever switched.
|
||||
func (s *Store) OwnTakeSwitch() (OwnTakeSwitch, error) {
|
||||
raw, err := os.ReadFile(s.ownTakeSwitchPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return OwnTakeSwitch{On: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return OwnTakeSwitch{}, err
|
||||
}
|
||||
var sw OwnTakeSwitch
|
||||
if err := json.Unmarshal(raw, &sw); err != nil {
|
||||
return OwnTakeSwitch{}, fmt.Errorf("the switch of %s is not readable: %w", ReplaceRunning, err)
|
||||
}
|
||||
return sw, nil
|
||||
}
|
||||
|
||||
// SwitchOwnTake switches move-in with replace_running on or off, a person's act with why; the caller is recorded as the bus
|
||||
// proved it.
|
||||
func (v *Vault) SwitchOwnTake(caller string, on bool, why string) (OwnTakeSwitch, error) {
|
||||
why = strings.TrimSpace(why)
|
||||
if why == "" || len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
|
||||
return OwnTakeSwitch{}, errors.New("say why in one short line; nothing was switched")
|
||||
}
|
||||
sw := OwnTakeSwitch{On: on, By: someoneOr(caller, stdio.Unproven), At: v.Now().UTC(), Why: why}
|
||||
raw, _ := json.MarshalIndent(sw, "", " ")
|
||||
if err := writeWhole(v.Store.ownTakeSwitchPath(), append(raw, '\n')); err != nil {
|
||||
return OwnTakeSwitch{}, err
|
||||
}
|
||||
v.log("%s is switched %s by %s: %s", ReplaceRunning, map[bool]string{true: "on", false: "off"}[on], sw.By, why)
|
||||
return sw, nil
|
||||
}
|
||||
|
||||
// ownTakeOff says why move-in with replace_running takes nothing now, or "".
|
||||
func (v *Vault) ownTakeOff() string {
|
||||
sw, err := v.Store.OwnTakeSwitch()
|
||||
if err != nil {
|
||||
return err.Error() + "; nothing is taken"
|
||||
}
|
||||
if !sw.On {
|
||||
return fmt.Sprintf("%s is switched off (by %s at %s: %s); nothing is taken", ReplaceRunning, sw.By,
|
||||
sw.At.Format(time.RFC3339), sw.Why)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -48,20 +48,6 @@ type Record struct {
|
||||
// MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that
|
||||
// handed it in and the controller's generation for it, kept as it was.
|
||||
MovedFrom *MovedFrom `json:"moved-from,omitempty"`
|
||||
// Previous is the value this one replaced when a machine's node-engine handed the vault what runs there
|
||||
// (move-in with replace_running, owntake.go): kept sealed as it was, so the replacement can be read and undone. Any later
|
||||
// change of the secret drops it.
|
||||
Previous *PreviousValue `json:"previous,omitempty"`
|
||||
}
|
||||
|
||||
// PreviousValue is a replaced value as the store kept it: its generation, both its seals, and who changed it when.
|
||||
type PreviousValue struct {
|
||||
Generation int `json:"generation"`
|
||||
AtRest seal.Box `json:"at-rest"`
|
||||
Operator OperatorCopy `json:"operator"`
|
||||
ChangedAt time.Time `json:"changed-at"`
|
||||
ChangedBy string `json:"changed-by"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorCopy is a value sealed to the operator key named in Key.
|
||||
|
||||
@@ -195,53 +195,13 @@ func definitions(env *Env) []toolDef {
|
||||
}
|
||||
return v.RotateFor(caller, secret, one, arg(a, "by"), arg(a, "why"))
|
||||
}},
|
||||
{Name: "own_take_switch",
|
||||
Description: "Switch move-in's replace_running on or off for every machine, a person's act with why (on until " +
|
||||
"switched off; kept over a restart). While off, every machine's ask is refused, saying who switched it " +
|
||||
"off and why. own_drift shows the switch.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"on", "why"}, "properties": map[string]any{
|
||||
"on": map[string]any{"type": "boolean", "description": "true takes running values, false takes none"},
|
||||
"why": str("why, in one line")}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
on, ok := a["on"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New("say on: true or false; nothing was switched")
|
||||
}
|
||||
return v.SwitchOwnTake(caller, on, arg(a, "why"))
|
||||
}},
|
||||
{Name: "own_drift",
|
||||
Description: "Every own secret whose node's latest shadow answer says the vault holds another value than runs " +
|
||||
"there (the masked word \"another value\", issue 1126), for all nodes in one call: the secret, the " +
|
||||
"groups that show it, the vault's generation, origin and previous generation, and whether the node's " +
|
||||
"move-in with replace_running would take the running value now (or why not); and per node whose compositions it " +
|
||||
"applies and how many values differ per group. Never a value.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
run: func(_ string, _ map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.OwnDrift()
|
||||
}},
|
||||
{Name: Seat + ".move-in",
|
||||
Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " +
|
||||
"node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " +
|
||||
"module and name for a module's own; provider, provision, consumer, consumer_module and local for a " +
|
||||
"pair credential), its origin and the controller's generation, and get a call and a key made for it; " +
|
||||
"then send the call, the value sealed to that key and read_back_to, a key of your own. Never replaces " +
|
||||
"a value the vault holds; answers what it kept, read back from its store sealed to read_back_to. " +
|
||||
"With replace_running: true it does the one thing it otherwise never does (issue 1126, cause C4): it " +
|
||||
"replaces one own secret of the asking machine (the machine as the bus proved the caller) with the " +
|
||||
"value that runs there; first step module, name and why, second step call and sealed. It keeps the " +
|
||||
"old value as the previous generation, raises the generation and says own-replaced, never the value; " +
|
||||
"answers both generations and the fingerprint of what it holds. Refused: another machine's secret, " +
|
||||
"one that is not an own secret or not held, an empty value, a person's value, a provider's but a " +
|
||||
"covered bus account, a value the vault changed in the last 15 minutes, and everything while " +
|
||||
"own_take_switch is off. The same value is a no-op.",
|
||||
"a value the vault holds; answers what it kept, read back from its store sealed to read_back_to.",
|
||||
Input: intakeInput(map[string]string{"node": "a module's own secret: its machine", "module": "its module",
|
||||
"name": "its name in the module", "provider": "a pair credential: the provider's machine",
|
||||
"provision": "the provision", "consumer": "the consumer's machine",
|
||||
@@ -250,10 +210,6 @@ func definitions(env *Env) []toolDef {
|
||||
"generation": "the controller's generation for the value, kept as it is",
|
||||
"made_at": "when the controller made or took it (RFC 3339)",
|
||||
"read_back_to": "the second step: a raw X25519 public key in base64 the read-back is sealed to",
|
||||
ReplaceRunningArg: "true to replace an own secret of this machine with the value that runs here " +
|
||||
"(the second step is known by its call)",
|
||||
"secret": "with replace_running, or the secret's whole name, own/<machine>/<module>/<name>",
|
||||
"why": "with replace_running: why the value is handed, in words",
|
||||
"sealing_key": "the first step, optional: this machine's sealing key, a raw X25519 public key in " +
|
||||
"base64, which the core's credentials are sealed to from then on (ADR 0349 §16)"}),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
@@ -261,17 +217,9 @@ func definitions(env *Env) []toolDef {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replace := isTrue(a[ReplaceRunningArg])
|
||||
if call := arg(a, "call"); call != "" {
|
||||
if replace || v.callKind(call) == ReplaceRunning {
|
||||
return v.OwnTakeTake(caller, call, arg(a, "sealed"))
|
||||
}
|
||||
return v.MoveTake(caller, call, arg(a, "sealed"), arg(a, "read_back_to"))
|
||||
}
|
||||
if replace {
|
||||
return v.OwnTakeOpen(caller, OwnTakeOf{Node: arg(a, "node"), Module: arg(a, "module"),
|
||||
Name: arg(a, "name"), Secret: arg(a, "secret"), Why: arg(a, "why")})
|
||||
}
|
||||
m := MoveOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
|
||||
Provider: arg(a, "provider"), Provision: arg(a, "provision"), Consumer: arg(a, "consumer"),
|
||||
ConsumerModule: arg(a, "consumer_module"), Local: arg(a, "local"), Origin: arg(a, "origin"),
|
||||
@@ -607,17 +555,6 @@ func (e *Env) ready() (*Vault, error) {
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
|
||||
// isTrue is a switch argument said true, as a boolean or as the word.
|
||||
func isTrue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(t), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func arg(a map[string]any, key string) string {
|
||||
s, _ := a[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
@@ -845,9 +782,6 @@ func ownAnswer(v *Vault, node, module, prefix string) (map[string]any, error) {
|
||||
if r.ChangedVia != "" {
|
||||
row["changedVia"] = r.ChangedVia
|
||||
}
|
||||
if r.Previous != nil {
|
||||
row["previousGeneration"] = r.Previous.Generation
|
||||
}
|
||||
if note != nil {
|
||||
row["error"] = note
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ type Vault struct {
|
||||
// Haves reads the node-engines' have bucket, where the bus node hands the loaded list's hashes (adopt.go): nil
|
||||
// adopts nothing.
|
||||
Haves HaveReader
|
||||
// HaveKeys lists the keys of the node-engines' have bucket, for own_drift (owntake.go): nil lists none.
|
||||
HaveKeys func() ([]string, error)
|
||||
|
||||
mu sync.Mutex
|
||||
// hashMu keeps one hashing at a time, and hashes what is owed (accounts.go).
|
||||
@@ -108,11 +106,6 @@ type keepOptions struct {
|
||||
neverReplace bool
|
||||
madeAt time.Time
|
||||
movedFrom *MovedFrom
|
||||
// replaceOnly, keepPrevious and refuse are what move-in with replace_running keeps (owntake.go): only a value held is replaced,
|
||||
// keeping its origin and the value it replaces, and refuse, under the store's lock, says why a held value is not.
|
||||
replaceOnly bool
|
||||
keepPrevious bool
|
||||
refuse func(before Record) string
|
||||
}
|
||||
|
||||
// keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept
|
||||
@@ -154,18 +147,10 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if !held && opt.replaceOnly {
|
||||
return Changed{}, fmt.Errorf("the vault holds no secret named %s, so there is nothing to replace; nothing was taken", name)
|
||||
}
|
||||
if held {
|
||||
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
|
||||
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
|
||||
}
|
||||
if opt.refuse != nil {
|
||||
if why := opt.refuse(before); why != "" {
|
||||
return Changed{}, errors.New(why)
|
||||
}
|
||||
}
|
||||
if opt.neverReplace {
|
||||
return Changed{}, fmt.Errorf("the vault already holds another value for %s (generation %d, changed by %s "+
|
||||
"at %s), and the move never replaces a value the vault holds: nothing was taken", name,
|
||||
@@ -189,13 +174,6 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions)
|
||||
MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin}
|
||||
if held {
|
||||
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
|
||||
if opt.replaceOnly {
|
||||
rec.Origin, rec.MovedFrom = before.Origin, before.MovedFrom
|
||||
}
|
||||
if opt.keepPrevious {
|
||||
rec.Previous = &PreviousValue{Generation: before.Generation, AtRest: before.AtRest, Operator: before.Operator,
|
||||
ChangedAt: before.ChangedAt, ChangedBy: before.ChangedBy, Origin: before.Origin}
|
||||
}
|
||||
} else if !opt.madeAt.IsZero() {
|
||||
rec.MadeAt = opt.madeAt.UTC()
|
||||
}
|
||||
@@ -365,8 +343,7 @@ func (v *Vault) reseal(key string) (int, error) {
|
||||
n := 0
|
||||
var failed []error
|
||||
for _, rec := range all {
|
||||
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox &&
|
||||
(rec.Previous == nil || rec.Previous.Operator.Key == key && rec.Previous.Operator.Box == OperatorBox) {
|
||||
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox {
|
||||
continue
|
||||
}
|
||||
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
|
||||
@@ -375,16 +352,6 @@ func (v *Vault) reseal(key string) (int, error) {
|
||||
continue
|
||||
}
|
||||
sealed, err := operatorSeal(key, []byte(value))
|
||||
if err == nil && rec.Previous != nil {
|
||||
// And the value it replaced (owntake.go): a copy of every value the vault keeps is the operator's.
|
||||
var old string
|
||||
if old, err = seal.Open(rec.Previous.AtRest, v.Own.PrivateKey); err == nil {
|
||||
var prev string
|
||||
if prev, err = operatorSeal(key, []byte(old)); err == nil {
|
||||
rec.Previous.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: prev}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
|
||||
err = v.Store.Write(rec)
|
||||
|
||||
@@ -167,11 +167,10 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
|
||||
}
|
||||
// The haves, in main.go alone: one read, one watch and their keys (own_drift), never a write.
|
||||
// The haves, in main.go alone: one read and one watch, never a write.
|
||||
if f == "main.go" {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Keys(", "", 1))
|
||||
}
|
||||
if m := bucket.Find(code); m != nil {
|
||||
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-vault
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865 h1:VLir9NZbyi1LdH1YwWDIVRuL+lDC+AYlm6FZPlvmlm4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936 h1:HGBoWimZXYZp+Qn9VCstYDitOP84t9EUQYd1yM9j2eg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 h1:by2zBASLb8h5jA1IiYJWykXwLnHYpuZh/zFNa4NsbOU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
"provisioned",
|
||||
"rotated",
|
||||
"secret-changed",
|
||||
"account-hashed",
|
||||
"own-replaced"
|
||||
"account-hashed"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
@@ -164,9 +163,7 @@
|
||||
"provisioner_retire_reject",
|
||||
"provisioner_delete",
|
||||
"vault_status",
|
||||
"vault_health",
|
||||
"own_drift",
|
||||
"own_take_switch"
|
||||
"vault_health"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
|
||||
@@ -1130,8 +1130,6 @@ func (h *Holder) record(s Sent) {
|
||||
if h.Store != nil {
|
||||
if err := h.Store.PutRecent(recent); err != nil {
|
||||
h.noteStore(err)
|
||||
} else {
|
||||
h.storeWritten()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1145,20 +1143,6 @@ func (h *Holder) persist(r *Record) {
|
||||
h.mu.Unlock()
|
||||
if err := h.Store.Put(c); err != nil {
|
||||
h.noteStore(err)
|
||||
} else {
|
||||
h.storeWritten()
|
||||
}
|
||||
}
|
||||
|
||||
// storeWritten clears a state problem once a write succeeds again: the status says what holds now, never a
|
||||
// failure that passed (issue 1507's reading of 2026-10-28: a verdict kept a single failure of the day before).
|
||||
func (h *Holder) storeWritten() {
|
||||
h.mu.Lock()
|
||||
was := h.storeErr
|
||||
h.storeErr = ""
|
||||
h.mu.Unlock()
|
||||
if was != "" {
|
||||
h.Logf("[messenger] writes its state again (it could not: %s)", was)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,14 +71,9 @@ type memStore struct {
|
||||
passes map[string]Pass
|
||||
// puts counts the writes, so a write of bytes identical to what is stored is still seen (issue 422's review).
|
||||
puts int
|
||||
// fail, when set, refuses every write of a record or of the sends, as the bus does with no responders.
|
||||
fail error
|
||||
}
|
||||
|
||||
func (m *memStore) Put(r Record) error {
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
if m.recs == nil {
|
||||
m.recs = map[string]Record{}
|
||||
}
|
||||
@@ -94,13 +89,7 @@ func (m *memStore) All() ([]Record, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *memStore) PutRecent(s []Sent) error {
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
m.recent = s
|
||||
return nil
|
||||
}
|
||||
func (m *memStore) PutRecent(s []Sent) error { m.recent = s; return nil }
|
||||
func (m *memStore) PutWalk(w Walk) error {
|
||||
if m.walks == nil {
|
||||
m.walks = map[string]Walk{}
|
||||
@@ -442,21 +431,15 @@ func TestEscalationIsSaidOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
||||
h, tg, _, c, store := newHolder(t)
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desk = nil
|
||||
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
|
||||
// The state could not be written either, once: said while it lasts, and no longer once a write succeeds
|
||||
// (issue 1507's reading of 2026-10-28: a verdict kept a day-old "no responders").
|
||||
store.fail = errors.New("nats: no responders available for request")
|
||||
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
|
||||
st := h.Status("listening")
|
||||
if !strings.HasPrefix(st.Verdict, "CANNOT SEND") || len(st.Unsent) != 1 || st.Channels[0].LastError == "" {
|
||||
t.Fatalf("status: %+v", st)
|
||||
}
|
||||
if st.StateProblem == "" {
|
||||
t.Fatalf("a failed state write is not said: %+v", st)
|
||||
}
|
||||
tg.fail, store.fail = nil, nil
|
||||
tg.fail = nil
|
||||
h.Flush()
|
||||
if len(tg.sends) != 0 {
|
||||
t.Fatalf("tried again before a minute")
|
||||
@@ -466,9 +449,6 @@ func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
||||
if len(tg.sends) != 1 || h.Status("listening").Verdict != "ok" {
|
||||
t.Fatalf("not retried: %d, %s", len(tg.sends), h.Status("listening").Verdict)
|
||||
}
|
||||
if p := h.Status("listening").StateProblem; p != "" {
|
||||
t.Fatalf("a state write that succeeded again still says the old failure: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
|
||||
|
||||
@@ -677,8 +677,7 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
Run: func(map[string]any) (any, error) { return h.Open(), nil }},
|
||||
{Name: "operator-channel.history",
|
||||
Description: "What was said to the operator lately, newest first — each send, digest, edit and failure with its " +
|
||||
"channel, key and outcome — every message refused for carrying an address, a path or a secret, and what " +
|
||||
"waits to be sent on each channel, since when and when its burst window ends.",
|
||||
"channel, key and outcome — and every message refused for carrying an address, a path or a secret.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many of each (default 50)"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
|
||||
{Name: "operator-channel.notify",
|
||||
@@ -751,10 +750,9 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.Unlink(kind)
|
||||
}},
|
||||
{Name: "messenger_recent",
|
||||
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — the refusals, " +
|
||||
"and what waits to be sent: each message held on a channel (its burst window is 30 s), since when and when its window ends.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
|
||||
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — and the refusals.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
|
||||
{Name: "messenger_test",
|
||||
Description: "Send a test message now, to one channel kind or every one (default), through the content rule " +
|
||||
"and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.",
|
||||
|
||||
@@ -66,40 +66,3 @@ func TestAnAskAlreadySentIsNotSentAgainAfterARestart(t *testing.T) {
|
||||
t.Fatalf("sent again after a restart: %d", len(tg.sends))
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1507, read again 2026-10-28: three asks taken at 08:50:03-08:50:14 went out together at 08:50:33, at the
|
||||
// end of the first one's burst window; a reading of the history in between showed no send and no refusal, and the
|
||||
// asks read as lost. What waits in an outbox is in the history, with when its window ends, until it is sent.
|
||||
func TestTheHistorySaysAnAskWaitingForItsBurstWindow(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-config", heldAsk(c, "cfg-1"))
|
||||
waiting := func() []Waiting { return h.History(20)["waiting"].([]Waiting) }
|
||||
for _, step := range []struct {
|
||||
name string
|
||||
pass time.Duration
|
||||
want int
|
||||
}{
|
||||
{"taken, inside its window", 0, 1},
|
||||
{"its window passed and it was sent", BurstWindow + time.Second, 0},
|
||||
} {
|
||||
c.pass(step.pass)
|
||||
stand(h, c)
|
||||
h.Flush()
|
||||
w := waiting()
|
||||
if len(w) != step.want {
|
||||
t.Fatalf("%s: %d waiting, want %d: %+v", step.name, len(w), step.want, w)
|
||||
}
|
||||
if step.want == 1 {
|
||||
if w[0].Key != "ask.mesh-config.cfg-1" || w[0].Channel != tg.Name() || w[0].Due == "" {
|
||||
t.Fatalf("%s: waiting %+v, want the ask on %s with when its window ends", step.name, w[0], tg.Name())
|
||||
}
|
||||
if len(tg.sends) != 0 {
|
||||
t.Fatalf("%s: sent before its window ended", step.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tg.sends) != 1 {
|
||||
t.Fatalf("sent %d times, want once", len(tg.sends))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,36 +214,7 @@ func (h *Holder) History(limit int) map[string]any {
|
||||
for i := len(h.refusals) - 1; i >= 0 && len(refusals) < limit; i-- {
|
||||
refusals = append(refusals, h.refusals[i])
|
||||
}
|
||||
return map[string]any{"sends": sends, "refusals": refusals, "waiting": h.waitingNow()}
|
||||
}
|
||||
|
||||
// Waiting is one message held in a channel's outbox, not sent yet: history says it, so a message held for its
|
||||
// burst window reads as waiting and not as never sent (issue 1507, its reading of 2026-10-28).
|
||||
type Waiting struct {
|
||||
Channel string `json:"channel"`
|
||||
Key string `json:"key"`
|
||||
What string `json:"what"`
|
||||
Since string `json:"since"`
|
||||
// Due is when its burst window ends: it goes out at the first flush from then, unless a rule holds it longer.
|
||||
Due string `json:"window_ends"`
|
||||
}
|
||||
|
||||
// waitingNow is every message held in an outbox, oldest first. Called with mu held.
|
||||
func (h *Holder) waitingNow() []Waiting {
|
||||
out := []Waiting{}
|
||||
for ch, list := range h.outbox {
|
||||
for _, it := range list {
|
||||
out = append(out, Waiting{Channel: ch, Key: it.Key, What: it.What,
|
||||
Since: it.At.Format(time.RFC3339Nano), Due: it.At.Add(BurstWindow).Format(time.RFC3339Nano)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Since != out[j].Since {
|
||||
return out[i].Since < out[j].Since
|
||||
}
|
||||
return out[i].Channel < out[j].Channel
|
||||
})
|
||||
return out
|
||||
return map[string]any{"sends": sends, "refusals": refusals}
|
||||
}
|
||||
|
||||
// Test sends a test message on one channel or both, through the content rule and the cap.
|
||||
|
||||
+1113
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "g14",
|
||||
"decided": {
|
||||
"address": "10.10.0.4",
|
||||
"account": "jochen",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"mtu": 1380,
|
||||
"address": "10.10.0.4/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "mq76YPW0FrExhAvt/ZG1SB2BK6/cPISKgXO53SEMazU="
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "adwaita"
|
||||
},
|
||||
{
|
||||
"module": "asus-zephyrus-g14"
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "blueman"
|
||||
},
|
||||
{
|
||||
"module": "bluetooth"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "clipmenu"
|
||||
},
|
||||
{
|
||||
"module": "cups"
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "dmenu"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "docker-compose"
|
||||
},
|
||||
{
|
||||
"module": "dunst",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "feh"
|
||||
},
|
||||
{
|
||||
"module": "flatpak"
|
||||
},
|
||||
{
|
||||
"module": "fonts"
|
||||
},
|
||||
{
|
||||
"module": "forticlient"
|
||||
},
|
||||
{
|
||||
"module": "gnome-keyring"
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "i3"
|
||||
},
|
||||
{
|
||||
"module": "i3status-rust"
|
||||
},
|
||||
{
|
||||
"module": "kernel"
|
||||
},
|
||||
{
|
||||
"module": "lemurs"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "memory-pressure"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-notifier"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "networkmanager"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud-client"
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "nm-applet"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "okioki",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "openrazer"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "picom"
|
||||
},
|
||||
{
|
||||
"module": "polychromatic"
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "restic"
|
||||
},
|
||||
{
|
||||
"module": "rofi"
|
||||
},
|
||||
{
|
||||
"module": "screen-lock"
|
||||
},
|
||||
{
|
||||
"module": "sensors"
|
||||
},
|
||||
{
|
||||
"module": "slack"
|
||||
},
|
||||
{
|
||||
"module": "snapd"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-resolved"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "triggerhappy"
|
||||
},
|
||||
{
|
||||
"module": "xclip"
|
||||
},
|
||||
{
|
||||
"module": "xdg",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "xorg"
|
||||
},
|
||||
{
|
||||
"module": "xterm"
|
||||
},
|
||||
{
|
||||
"module": "zsh"
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"pins": {
|
||||
"wildcard-resolution": {
|
||||
"node": "novox",
|
||||
"module": "dnsmasq"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "novox",
|
||||
"decided": {
|
||||
"endpoint": "142.132.152.141:51820",
|
||||
"site": "hosting",
|
||||
"hub": true,
|
||||
"address": "10.10.0.1",
|
||||
"public-domain": "novox.be",
|
||||
"account": "jochens",
|
||||
"agent-account": "nox",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"address": "10.10.0.1/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "xW0ETQwyR9rHi2XSfOJrKkairE+Fp70FPNNcirqMixY="
|
||||
},
|
||||
"peer-names": {
|
||||
"XT1l25Z0/YhRNQ8FQM+9XsAp7chx5MI0eLOOg1SUEXA=": "shanks",
|
||||
"mq76YPW0FrExhAvt/ZG1SB2BK6/cPISKgXO53SEMazU=": "g14",
|
||||
"zTKYP6Sz1hDwnwxIHgiGXYAeGcWbqti3tcSvKEA0Ew4=": "ace"
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "artifact-store-tools",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "claude-licence-manager",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "de-spiegel",
|
||||
"ports": {
|
||||
"35621": 20005
|
||||
},
|
||||
"bindings": {
|
||||
"smtp": {
|
||||
"node": "novox",
|
||||
"module": "mailu"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "desk-channel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "distribution",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"5000": 5100
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "dnsmasq"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "gitea",
|
||||
"ports": {
|
||||
"3000": 20000
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"expose": {
|
||||
"22": "anywhere"
|
||||
},
|
||||
"ports": {
|
||||
"22": 222
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "invoicing",
|
||||
"ports": {
|
||||
"80": 20007,
|
||||
"9000": 20008
|
||||
},
|
||||
"bindings": {
|
||||
"mongodb-database": {
|
||||
"node": "novox",
|
||||
"module": "mongodb"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "kernel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "keycloak",
|
||||
"ports": {
|
||||
"8080": 20009
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "link2pay"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "mailu",
|
||||
"ports": {
|
||||
"110": 110,
|
||||
"143": 143,
|
||||
"25": 25,
|
||||
"4243": 20004,
|
||||
"465": 465,
|
||||
"587": 587,
|
||||
"8080": 20010,
|
||||
"993": 993,
|
||||
"995": 995
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-catalog",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-config"
|
||||
},
|
||||
{
|
||||
"module": "mesh-controller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-checker"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-forge-bridge"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-gate",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-indexer"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-teller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-watchman"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-grants"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-issues"
|
||||
},
|
||||
{
|
||||
"module": "mesh-knowledge",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-reader",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-web",
|
||||
"bindings": {
|
||||
"oidc-client": {
|
||||
"node": "novox",
|
||||
"module": "keycloak"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"endpoints": {
|
||||
"web": {
|
||||
"reach": "internal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-vault"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "messenger",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "minio",
|
||||
"ports": {
|
||||
"9000": 20001,
|
||||
"9001": 20002
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mongodb",
|
||||
"ports": {
|
||||
"27017": 20006
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mssql",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"1433": 4848
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "nats"
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud",
|
||||
"ports": {
|
||||
"80": 20003
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "only-office"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "photos",
|
||||
"ports": {
|
||||
"9000": 20011
|
||||
},
|
||||
"bindings": {
|
||||
"mongodb-database": {
|
||||
"node": "novox",
|
||||
"module": "mongodb"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "postgres",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"5432": 6852
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "records",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "restic",
|
||||
"layer": {
|
||||
"places": {
|
||||
"repository": "/home/restic"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "route-proxy"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "step-ca"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-networkd"
|
||||
},
|
||||
{
|
||||
"module": "telegram"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "umami",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"expose": {
|
||||
"3000": "mesh"
|
||||
},
|
||||
"ports": {
|
||||
"3000": 1212
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "website",
|
||||
"ports": {
|
||||
"8080": 20012
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "zsh",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"holds": [
|
||||
{
|
||||
"claim": "git",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-artifact-store",
|
||||
"scope": "mesh",
|
||||
"module": "distribution",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-broker",
|
||||
"scope": "mesh",
|
||||
"module": "nats",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-catalog",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-catalog",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-config",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-config",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-controller",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-controller",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-delivery",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-delivery-gate",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-dns-resolver",
|
||||
"scope": "mesh",
|
||||
"module": "dnsmasq",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-provisioner",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-provisioner",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-store",
|
||||
"scope": "mesh",
|
||||
"module": "postgres",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-vault",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-vault",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "npm-package-registry",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "shanks",
|
||||
"decided": {
|
||||
"site": "home",
|
||||
"address": "10.10.0.3",
|
||||
"account": "jochen",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"address": "10.10.0.3/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "XT1l25Z0/YhRNQ8FQM+9XsAp7chx5MI0eLOOg1SUEXA="
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "adwaita"
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "blueman"
|
||||
},
|
||||
{
|
||||
"module": "bluetooth"
|
||||
},
|
||||
{
|
||||
"module": "brother",
|
||||
"bindings": {
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "buildah"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "clipmenu"
|
||||
},
|
||||
{
|
||||
"module": "cups"
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "dmenu"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "docker-compose"
|
||||
},
|
||||
{
|
||||
"module": "dunst"
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "feh"
|
||||
},
|
||||
{
|
||||
"module": "flatpak"
|
||||
},
|
||||
{
|
||||
"module": "fonts"
|
||||
},
|
||||
{
|
||||
"module": "forticlient"
|
||||
},
|
||||
{
|
||||
"module": "gnome-keyring"
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "i3"
|
||||
},
|
||||
{
|
||||
"module": "i3status-rust"
|
||||
},
|
||||
{
|
||||
"module": "kernel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "lab",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "lemurs"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-image"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-builder"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-view"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-notifier"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "mounts",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "networkmanager"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud-client"
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "nm-applet"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "openrazer"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "picom"
|
||||
},
|
||||
{
|
||||
"module": "polychromatic"
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "restic"
|
||||
},
|
||||
{
|
||||
"module": "rofi"
|
||||
},
|
||||
{
|
||||
"module": "screen-lock"
|
||||
},
|
||||
{
|
||||
"module": "slack"
|
||||
},
|
||||
{
|
||||
"module": "snapd"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-resolved"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "xclip"
|
||||
},
|
||||
{
|
||||
"module": "xdg",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "xorg"
|
||||
},
|
||||
{
|
||||
"module": "xterm"
|
||||
},
|
||||
{
|
||||
"module": "zsh"
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"pins": {
|
||||
"wildcard-resolution": {
|
||||
"node": "novox",
|
||||
"module": "dnsmasq"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
# *Not:* words with no scope or with (tools). A copy, so a catalogue merge needs nothing else; novox/hq's
|
||||
# words.py compares it with the glossary. Regenerate with: python3 00-META/checks/words.py --list tools
|
||||
build machine
|
||||
catalogue
|
||||
change plan
|
||||
control plane
|
||||
flavor
|
||||
|
||||
Reference in New Issue
Block a user