Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1319432e55 | ||
|
|
34b310b9f8 | ||
|
|
33d8b71cf7 | ||
|
|
aa0eaa6c52 | ||
|
|
d5804927b3 | ||
|
|
cdbdb47474 | ||
|
|
db5e6c5b48 | ||
|
|
b9dcf19c3f | ||
|
|
e3a714d5f0 | ||
|
|
08cec8cb1c | ||
|
|
c3564c1acc | ||
|
|
e4cbffbaa9 | ||
|
|
0f159a1662 | ||
|
|
f0beea153e | ||
|
|
02a8837348 | ||
|
|
0fa427dad3 | ||
|
|
37628f4bce | ||
|
|
56c68be7fa | ||
|
|
f178aab641 |
@@ -476,10 +476,13 @@ type PluginFile struct {
|
||||
// what it is given. Outside hooks/, so no registered hook's name can be it.
|
||||
const GuardDir = "guard"
|
||||
|
||||
// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with.
|
||||
// GuardFiles are the guard as the plugin carries it: the binary to copy, what it judges with, and — the same
|
||||
// binary again — the waits directory the SessionStart and Stop hooks are given (novox/hq ADR 0489, issues
|
||||
// 1744, 1743). Empty WaitsDir places no such hook.
|
||||
type GuardFiles struct {
|
||||
Exe string
|
||||
Data GuardData
|
||||
Exe string
|
||||
Data GuardData
|
||||
WaitsDir string
|
||||
}
|
||||
|
||||
// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file
|
||||
@@ -495,6 +498,20 @@ func GuardCommand() string {
|
||||
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
|
||||
}
|
||||
|
||||
// SessionStartCommand is the plugin's SessionStart hook: the guard's own binary, told the session's open
|
||||
// waits live under waitsDir (novox/hq ADR 0489 §3, issue 1744).
|
||||
func SessionStartCommand(waitsDir string) string {
|
||||
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
||||
return `"` + filepath.Join(dir, "guard") + `" session-start "` + waitsDir + `"`
|
||||
}
|
||||
|
||||
// StopCommand is the plugin's Stop hook: the guard's own binary, refusing to end the turn while a wait under
|
||||
// waitsDir is open and unwatched, or past its bound and untold (novox/hq ADR 0489 §3-§6, issue 1743).
|
||||
func StopCommand(waitsDir string) string {
|
||||
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
||||
return `"` + filepath.Join(dir, "guard") + `" stop "` + waitsDir + `"`
|
||||
}
|
||||
|
||||
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||
// over a mesh item of the same kind and name: the node's wins.
|
||||
func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) }
|
||||
@@ -533,6 +550,14 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
|
||||
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
|
||||
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
|
||||
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
|
||||
if guard.WaitsDir != "" {
|
||||
// A session's wait list (novox/hq ADR 0489): the SessionStart hook names the session's id and its open
|
||||
// waits (issue 1744); the Stop hook keeps the turn open while one is unwatched or untold (issue 1743).
|
||||
hooks["SessionStart"] = append(hooks["SessionStart"], map[string]any{"matcher": "startup|resume|clear|compact",
|
||||
"hooks": []any{map[string]any{"type": "command", "command": SessionStartCommand(guard.WaitsDir), "timeout": 10}}})
|
||||
hooks["Stop"] = append(hooks["Stop"], map[string]any{
|
||||
"hooks": []any{map[string]any{"type": "command", "command": StopCommand(guard.WaitsDir), "timeout": 5}}})
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
it := chosen[k]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
|
||||
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
|
||||
// adopt call.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
|
||||
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
|
||||
func suggestedCall(t *testing.T, msg string) map[string]any {
|
||||
t.Helper()
|
||||
i := strings.Index(msg, "claude_code_wait ")
|
||||
if i < 0 {
|
||||
t.Fatalf("no suggested call in: %s", msg)
|
||||
}
|
||||
rest := msg[i+len("claude_code_wait "):]
|
||||
start := strings.Index(rest, "{")
|
||||
if start < 0 {
|
||||
t.Fatalf("no JSON object in: %s", msg)
|
||||
}
|
||||
depth, end := 0, -1
|
||||
for j, c := range rest[start:] {
|
||||
switch c {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = start + j + 1
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
t.Fatalf("unbalanced JSON object in: %s", msg)
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
|
||||
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
|
||||
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
|
||||
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
clock := t0
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
|
||||
// regression here is caught the same way as the Stop hook's.
|
||||
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
||||
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
|
||||
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
||||
}
|
||||
args := suggestedCall(t, msg)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
// Shared by the plugin's hooks (novox/hq ADR 0489 §5): a notice on exit 0 — the stop hook failing open or
|
||||
// letting a wait through, the session-start hook unable to use a session id — reaches the operator's screen,
|
||||
// not only the log. Claude Code shows a hook's top-level "systemMessage" field to the person; stderr alone,
|
||||
// on an allowed (exit 0) outcome, is never shown there.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// writeSystemMessage prints message as the one JSON object Claude Code shows the operator, falling back to
|
||||
// the plain words on the one error that cannot happen (a string always marshals).
|
||||
func writeSystemMessage(w io.Writer, message string) {
|
||||
raw, err := json.Marshal(map[string]string{"systemMessage": message})
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, message)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(raw))
|
||||
}
|
||||
@@ -388,6 +388,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
|
||||
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
|
||||
out = append(out, proposalTools(p)...)
|
||||
out = append(out, waitTools(p)...)
|
||||
judge := NewJudge(p)
|
||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||
return append(out, configTools(p, config, configView)...)
|
||||
@@ -416,6 +417,15 @@ func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "guard" {
|
||||
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
|
||||
}
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): names the session's id and its open waits.
|
||||
if len(os.Args) == 3 && os.Args[1] == "session-start" {
|
||||
os.Exit(runSessionStart(os.Args[2], os.Stdin, os.Stdout))
|
||||
}
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): keeps the turn open while a wait is unwatched
|
||||
// or untold.
|
||||
if len(os.Args) == 3 && os.Args[1] == "stop" {
|
||||
os.Exit(runStop(os.Args[2], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
// The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this
|
||||
// module through sudo: one answer per request on stdin, until it ends.
|
||||
if len(os.Args) == 2 && os.Args[1] == HelperArg {
|
||||
@@ -463,6 +473,7 @@ func main() {
|
||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||
Now: time.Now, Write: writeManaged}
|
||||
go proposing.watch(p)
|
||||
waiting = &Waiting{Store: keptWaits{stdio.State(WaitsState)}, Now: time.Now}
|
||||
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
|
||||
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
|
||||
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
|
||||
|
||||
@@ -278,7 +278,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
|
||||
}
|
||||
|
||||
// MeshToolsEvery is how often the controller is asked again.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, issue 1744): `claude-code session-start
|
||||
// <waits-dir>`, run by the agent's own account on every start, resume, /clear and /compact. The tool runner
|
||||
// never tells a session its own id (only a hook's stdin carries session_id), so a session cannot name itself
|
||||
// to claude_code_wait until this hook has told it. It also lists this session id's own still-open waits (if
|
||||
// any name is already bound to it) and, always, every name on this node that still holds an open wait, so a
|
||||
// session with a brand new id (a /clear gives one) can see a name it may want to adopt.
|
||||
//
|
||||
// It reads the session's mirrored waits file and the node-wide index directly — the same local reads the
|
||||
// Stop hook and claude_code_wait make (readWaitsFile, readNodeIndex, waits.go) — never calling back into
|
||||
// this module: this hook runs on every start, and must not wait on the bus to do it. A missing or malformed
|
||||
// file is not an error here: there is simply nothing open to report.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStartInput is what the hook is given on stdin.
|
||||
type SessionStartInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// SessionStartMessage is the line the hook prints: the session's id and how to give it to claude_code_wait,
|
||||
// then each still-open wait already bound to this session id (1744/AC1, AC2), then every name on this node
|
||||
// that still holds an open wait, with how to adopt it (ADR 0490 §2). Pure of IO beyond the two file reads.
|
||||
func SessionStartMessage(sessionID, waitsDir string, now time.Time) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "This session's id is %s; give it as `session` to claude_code_wait.\n", sessionID)
|
||||
if f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)); err == nil && len(f.Waits) > 0 {
|
||||
b.WriteString("Waits already bound to this session's own id:\n")
|
||||
for _, w := range f.Waits {
|
||||
age := now.Sub(w.At).Round(time.Second)
|
||||
state := "no watcher yet"
|
||||
if w.Watcher != "" {
|
||||
state = "watched"
|
||||
}
|
||||
if w.PastBound(now) {
|
||||
state += ", past its bound"
|
||||
if w.Said {
|
||||
state += ", said"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state)
|
||||
}
|
||||
}
|
||||
if idx, err := readNodeIndex(nodeIndexPathIn(waitsDir)); err == nil && len(idx.Names) > 0 {
|
||||
b.WriteString("Open waits on this node, by name:\n")
|
||||
for _, n := range idx.Names {
|
||||
fmt.Fprintf(&b, " %s: %d open wait(s), last called %s. If this is you: claude_code_wait "+
|
||||
"{\"op\":\"adopt\",\"by\":%q,\"session\":%q}\n", n.Name, n.Open, n.LastCall.Round(time.Second).Format(time.RFC3339),
|
||||
n.Name+"@"+idx.Node, sessionID)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runSessionStart is the hook: the session's id and its open waits out, exit 0 always (1744/AC3).
|
||||
func runSessionStart(waitsDir string, stdin io.Reader, stdout io.Writer) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0 // never fails the session's start
|
||||
}
|
||||
}()
|
||||
var in SessionStartInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
|
||||
return 0
|
||||
}
|
||||
sessionID := strings.TrimSpace(in.SessionID)
|
||||
if sessionID == "" {
|
||||
return 0
|
||||
}
|
||||
// Review round 1, item 1: a session id is never used to build a path until it is checked against
|
||||
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
|
||||
if !waitSessionID.MatchString(sessionID) {
|
||||
writeSystemMessage(stdout, fmt.Sprintf("this session's id (%q) could not be used to find its waits "+
|
||||
"(letters, digits, '.', '_' and '-' only)", sessionID))
|
||||
return 0
|
||||
}
|
||||
fmt.Fprint(stdout, SessionStartMessage(sessionID, waitsDir, time.Now()))
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1744/AC1: the hook prints the session's id and how to give it to claude_code_wait.
|
||||
func TestSessionStartPrintsTheSessionsIDAndHowToGiveIt(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
stdin := strings.NewReader(`{"session_id":"sess-9","hook_event_name":"SessionStart","source":"startup"}`)
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
||||
t.Fatalf("code %d, stdout %s", code, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "sess-9") || !strings.Contains(out.String(), "claude_code_wait") {
|
||||
t.Fatalf("stdout does not say how to name the session: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC2: when the waits file holds open waits, it prints each (what, age, past bound or not).
|
||||
func TestSessionStartListsOpenWaitsAfterAClear(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
at := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
|
||||
f := WaitsFile{Session: "sess-10", At: at, Waits: []Wait{
|
||||
{ID: "w-open", What: "the operator's Approve", Bound: 10, At: at},
|
||||
{ID: "w-past", What: "a delivery to finish", Bound: 1, At: at},
|
||||
}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-10.json"), f)
|
||||
|
||||
now := at.Add(5 * time.Minute)
|
||||
msg := SessionStartMessage("sess-10", waitsDir, now)
|
||||
if !strings.Contains(msg, "w-open") || !strings.Contains(msg, "the operator's Approve") || !strings.Contains(msg, "no watcher") {
|
||||
t.Fatalf("the open, unwatched wait is not described:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "w-past") || !strings.Contains(msg, "past its bound") {
|
||||
t.Fatalf("the past-bound wait is not described:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "5m0s ago") {
|
||||
t.Fatalf("the age is not said:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC3: a missing or malformed file prints only the id line, and the hook never fails the session start.
|
||||
func TestSessionStartNeverFailsOnAMissingOrBadFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
if msg := SessionStartMessage("sess-11", waitsDir, time.Now()); !strings.Contains(msg, "sess-11") || strings.Contains(msg, "open") {
|
||||
t.Fatalf("a missing file printed more than the id line: %q", msg)
|
||||
}
|
||||
writeFile(t, filepath.Join(waitsDir, "sess-12.json"), "{not json")
|
||||
if msg := SessionStartMessage("sess-12", waitsDir, time.Now()); !strings.Contains(msg, "sess-12") || strings.Contains(msg, "open") {
|
||||
t.Fatalf("a malformed file printed more than the id line: %q", msg)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, strings.NewReader("not json at all"), &out); code != 0 {
|
||||
t.Fatalf("unreadable stdin failed the session start: code %d", code)
|
||||
}
|
||||
var out2 bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, panicReader{}, &out2); code != 0 {
|
||||
t.Fatalf("a panic was not recovered: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// panicReader panics on Read, as a stuck pipe or a bug reading stdin might.
|
||||
type panicReader struct{}
|
||||
|
||||
func (panicReader) Read([]byte) (int, error) { panic("boom") }
|
||||
|
||||
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one is never used to
|
||||
// build a file path — reading outside the waits dir never happens — and the hook prints only a line saying
|
||||
// the id could not be used.
|
||||
func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
waitsDir := filepath.Join(root, "agent", "waits")
|
||||
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What "../../x" resolves to from waitsDir: root/x.json, outside waitsDir, with content that must never be
|
||||
// read through it.
|
||||
outside := filepath.Join(waitsDir, "../../x.json")
|
||||
writeWaitsFixture(t, outside, WaitsFile{Session: "x", At: time.Now(),
|
||||
Waits: []Wait{{ID: "w-secret", What: "SECRET-DATA", Bound: 10, At: time.Now()}}})
|
||||
t.Cleanup(func() { _ = os.Remove(outside) })
|
||||
|
||||
for _, id := range []string{"../../x", "a/b"} {
|
||||
var out bytes.Buffer
|
||||
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q}`, id))
|
||||
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
||||
t.Fatalf("id %q: code %d", id, code)
|
||||
}
|
||||
if strings.Contains(out.String(), "SECRET-DATA") {
|
||||
t.Fatalf("id %q: read a file outside the waits dir: %s", id, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "could not be used") {
|
||||
t.Fatalf("id %q: did not say the id could not be used: %s", id, out.String())
|
||||
}
|
||||
// Item (a): the notice reaches the screen as the one JSON object Claude Code shows.
|
||||
if !strings.Contains(out.String(), "systemMessage") {
|
||||
t.Fatalf("id %q: the notice is not a systemMessage: %s", id, out.String())
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, strings.NewReader(`{"session_id":""}`), &out); code != 0 || out.Len() != 0 {
|
||||
t.Fatalf("blank id: code %d, out %q", code, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's
|
||||
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook.
|
||||
|
||||
// ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide
|
||||
// index, with the exact adopt call to make if it is yours — read directly, never by calling back into this
|
||||
// module.
|
||||
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
|
||||
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
|
||||
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
|
||||
}}
|
||||
raw, err := indented(idx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw))
|
||||
|
||||
msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute))
|
||||
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
|
||||
t.Fatalf("the name and its count are not listed:\n%s", msg)
|
||||
}
|
||||
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWaitsFixture(t *testing.T, path string, f WaitsFile) {
|
||||
t.Helper()
|
||||
raw, err := indented(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, path, string(raw))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop <waits-dir>`, run by the
|
||||
// agent's own account before a session's turn is let end. It reads the session's mirrored waits file
|
||||
// directly (readWaitsFile, waits.go) — never the bus, never calling back into this module — so the decision
|
||||
// is local, under 5 s, and needs no network.
|
||||
//
|
||||
// Within its bound, a wait needs a watcher: refused while one is open and nothing follows it yet. Past its
|
||||
// bound, a wait needs a word to the operator, watched or not: refused until it is marked said. Refused at
|
||||
// most three times running for the same wait (a counter file beside the waits file, reset once the blocking
|
||||
// wait changes or clears); the fourth time it is let through, saying the wait was left unwatched. A missing,
|
||||
// unreadable or malformed waits file, or a panic, always allows.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StopInput is what the hook is given on stdin.
|
||||
type StopInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
StopHookActive bool `json:"stop_hook_active"`
|
||||
}
|
||||
|
||||
// MostStopRefusals is how many times in a row the Stop hook refuses for the same wait before it lets the
|
||||
// turn end anyway (1743/AC5).
|
||||
const MostStopRefusals = 3
|
||||
|
||||
// StopDecision is the Stop hook's verdict, and the refusal counters it leaves behind for the next call.
|
||||
type StopDecision struct {
|
||||
Refuse bool
|
||||
Reason string
|
||||
Counters map[string]int
|
||||
}
|
||||
|
||||
// waitBlocksStop says whether w, on its own, keeps the turn from ending, and why (ADR 0489 §3-§4).
|
||||
func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
if w.Said {
|
||||
return false, ""
|
||||
}
|
||||
if !w.PastBound(now) {
|
||||
if w.Watcher != "" {
|
||||
return false, ""
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
|
||||
"%s, and mark it watched: claude_code_wait "+
|
||||
"{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address,
|
||||
watcherAdvice(w), session, w.By, w.ID)
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
|
||||
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
|
||||
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
|
||||
}
|
||||
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
|
||||
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
|
||||
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
|
||||
func watcherAdvice(w Wait) string {
|
||||
if w.Family != "" {
|
||||
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
|
||||
"that rereads %s", w.Family, w.Read.Address)
|
||||
}
|
||||
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
|
||||
}
|
||||
|
||||
// DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits
|
||||
// file gave, previous the refusal counters read beside it, and now the time. Only the first wait that still
|
||||
// blocks the stop is named; the others are read again next time.
|
||||
//
|
||||
// A missing file (review round 2, item 1) is the normal state for a session with no waits — every stop of
|
||||
// every such session, not an exceptional one — so it says nothing and simply allows, the same as a file that
|
||||
// reads back with no open waits. Only a file that exists but cannot be read, or does not parse, says so.
|
||||
func DecideStop(session string, f WaitsFile, readErr error, previous map[string]int, now time.Time) StopDecision {
|
||||
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
|
||||
return StopDecision{Reason: fmt.Sprintf("the wait list could not be read (%v); allowed", readErr), Counters: map[string]int{}}
|
||||
}
|
||||
for _, w := range f.Waits {
|
||||
blocks, reason := waitBlocksStop(session, w, now)
|
||||
if !blocks {
|
||||
continue
|
||||
}
|
||||
count := previous[w.ID] + 1
|
||||
if count > MostStopRefusals {
|
||||
return StopDecision{Reason: fmt.Sprintf("wait %s was refused %d times running and is let through: it was left "+
|
||||
"unwatched (%s)", w.ID, MostStopRefusals, reason), Counters: map[string]int{}}
|
||||
}
|
||||
return StopDecision{Refuse: true, Reason: reason, Counters: map[string]int{w.ID: count}}
|
||||
}
|
||||
return StopDecision{Counters: map[string]int{}}
|
||||
}
|
||||
|
||||
func refusalCountersPath(waitsDir, session string) string {
|
||||
return filepath.Join(waitsDir, session+".refusals.json")
|
||||
}
|
||||
|
||||
func readRefusalCounters(waitsDir, session string) map[string]int {
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, session))
|
||||
if err != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every
|
||||
// other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which
|
||||
// already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as
|
||||
// before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined
|
||||
// node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an
|
||||
// error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a
|
||||
// cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1
|
||||
// item 2).
|
||||
//
|
||||
// Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record —
|
||||
// every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first
|
||||
// time, the waits folder itself; removing a file that was never there is not an error.
|
||||
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
|
||||
path := refusalCountersPath(waitsDir, session)
|
||||
if len(m) == 0 {
|
||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fsys.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.WriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
|
||||
// hook's "systemMessage" on stdout, but not stderr, when the hook allows (review round 1, item a).
|
||||
func notice(stdout, stderr io.Writer, reason string) {
|
||||
fmt.Fprintln(stderr, reason)
|
||||
writeSystemMessage(stdout, reason)
|
||||
}
|
||||
|
||||
// runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743).
|
||||
func runStop(waitsDir string, stdin io.Reader, stdout, stderr io.Writer) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0 // a panic always allows (1743/AC6)
|
||||
}
|
||||
}()
|
||||
var in StopInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
|
||||
return 0
|
||||
}
|
||||
sessionID := strings.TrimSpace(in.SessionID)
|
||||
if sessionID == "" {
|
||||
return 0
|
||||
}
|
||||
// Review round 1, item 1: a session id is never used to build a path until it is checked against
|
||||
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
|
||||
if !waitSessionID.MatchString(sessionID) {
|
||||
notice(stdout, stderr, fmt.Sprintf("the session id %q could not be used to find its waits (letters, digits, "+
|
||||
"'.', '_' and '-' only); this stop is allowed", sessionID))
|
||||
return 0
|
||||
}
|
||||
f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID))
|
||||
counters := readRefusalCounters(waitsDir, sessionID)
|
||||
d := DecideStop(sessionID, f, err, counters, time.Now())
|
||||
if werr := writeRefusalCounters(waitsDir, sessionID, d.Counters); werr != nil && d.Refuse {
|
||||
// Item 2: writing the counter failed on the very call that would have incremented it toward the cap;
|
||||
// refusing anyway could hold the turn forever, so this call is let through instead.
|
||||
notice(stdout, stderr, fmt.Sprintf("this wait's refusal counter could not be written (%v); allowed so it "+
|
||||
"cannot hold the turn forever", werr))
|
||||
return 0
|
||||
}
|
||||
if d.Reason != "" {
|
||||
fmt.Fprintln(stderr, d.Reason)
|
||||
if !d.Refuse {
|
||||
writeSystemMessage(stdout, d.Reason) // item a: a fail-open or let-through notice also reaches the screen
|
||||
}
|
||||
}
|
||||
if d.Refuse {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var stopT0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func openWait(id string, bound int) Wait {
|
||||
return Wait{ID: id, What: "the operator's Approve", Bound: bound, At: stopT0}
|
||||
}
|
||||
|
||||
// 1743/AC1: a wait within its bound and unwatched refuses the stop, naming the wait, its read address and
|
||||
// what the session does about it (mesh_wait on its family, conditionally — review round 1 item 3 — or a
|
||||
// watcher that rereads the address).
|
||||
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
|
||||
w := openWait("w-1", 10)
|
||||
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
w.Family = "mesh-delivery.decided"
|
||||
d := DecideStop("sess-1", WaitsFile{Waits: []Wait{w}}, nil, map[string]int{}, stopT0.Add(time.Minute))
|
||||
if !d.Refuse || !strings.Contains(d.Reason, "w-1") || !strings.Contains(d.Reason, "mesh-delivery.recheck") ||
|
||||
!strings.Contains(d.Reason, "mesh_wait") || !strings.Contains(d.Reason, "watcher") {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC2: a watched wait within its bound, a done wait (absent from the file), or no file entry: allowed.
|
||||
func TestStopAllowsAWatchedOrDoneOrAbsentWait(t *testing.T) {
|
||||
watched := openWait("w-2", 10)
|
||||
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
||||
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(time.Minute)); d.Refuse {
|
||||
t.Fatalf("a watched wait within its bound was refused: %+v", d)
|
||||
}
|
||||
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{}}, nil, map[string]int{}, stopT0); d.Refuse {
|
||||
t.Fatalf("no open waits was refused: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC3: a wait past its bound, watched or not, is refused until said, then allowed.
|
||||
func TestStopRefusesPastBoundUntilSaidWatchedOrNot(t *testing.T) {
|
||||
unwatched := openWait("w-3", 5)
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{unwatched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse ||
|
||||
!strings.Contains(d.Reason, "w-3") || !strings.Contains(d.Reason, "said") {
|
||||
t.Fatalf("unwatched past bound: %+v", d)
|
||||
}
|
||||
watched := unwatched
|
||||
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse {
|
||||
t.Fatalf("watched past bound was allowed: %+v", d)
|
||||
}
|
||||
said := watched
|
||||
said.Said = true
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{said}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); d.Refuse {
|
||||
t.Fatalf("said past bound was still refused: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC4: an unreadable or malformed waits file is allowed, saying so on stderr and as a systemMessage on
|
||||
// stdout; the decision does not touch the network and answers at once.
|
||||
func TestStopAllowsAndSaysSoWhenTheWaitsFileIsUnreadableOrMalformed(t *testing.T) {
|
||||
d := DecideStop("sess-4", WaitsFile{}, errors.New("no such file"), map[string]int{}, stopT0)
|
||||
if d.Refuse || !strings.Contains(d.Reason, "could not be read") {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
waitsDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(waitsDir, "sess-4b.json"), []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
|
||||
start := time.Now()
|
||||
code := runStop(waitsDir, stdin, &stdout, &stderr)
|
||||
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): a fail-open notice also reaches the screen, as the one JSON object Claude Code shows.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "could not be read") {
|
||||
t.Fatalf("the fail-open notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
if took := time.Since(start); took > 5*time.Second {
|
||||
t.Fatalf("took %s; the Stop hook has no network and is bounded under 5s", took)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item 1 (1743/AC2, ADR 0490 §2): a missing waits file is the normal state for a session
|
||||
// with no waits — every stop of every such session — so it must print nothing, on either stream, and
|
||||
// simply allow; a notice there would fire constantly for nothing wrong.
|
||||
func TestStopPrintsNothingWhenTheWaitsFileIsSimplyMissing(t *testing.T) {
|
||||
d := DecideStop("sess-4c", WaitsFile{}, fs.ErrNotExist, map[string]int{}, stopT0)
|
||||
if d.Refuse || d.Reason != "" {
|
||||
t.Fatalf("a missing file (fs.ErrNotExist) said something: %+v", d)
|
||||
}
|
||||
waitsDir := t.TempDir() // no file at all for sess-4d
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4d","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("a missing waits file printed something: stdout %q, stderr %q", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC5: the same stop refused three times running for the same wait is allowed the fourth time, saying
|
||||
// it was left unwatched; the counter resets when the wait changes.
|
||||
func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
|
||||
w := openWait("w-5", 10)
|
||||
counters := map[string]int{}
|
||||
for i := 1; i <= MostStopRefusals; i++ {
|
||||
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if !d.Refuse {
|
||||
t.Fatalf("refusal %d was allowed: %+v", i, d)
|
||||
}
|
||||
counters = d.Counters
|
||||
}
|
||||
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if d.Refuse || !strings.Contains(d.Reason, "unwatched") {
|
||||
t.Fatalf("the fourth refusal was not let through: %+v", d)
|
||||
}
|
||||
if len(d.Counters) != 0 {
|
||||
t.Fatalf("the counter was not reset once let through: %+v", d.Counters)
|
||||
}
|
||||
|
||||
// A different wait starts its own count from zero.
|
||||
w2 := openWait("w-5b", 10)
|
||||
d2 := DecideStop("sess-5", WaitsFile{Waits: []Wait{w2}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if !d2.Refuse || d2.Counters["w-5b"] != 1 {
|
||||
t.Fatalf("a different wait did not start its own count: %+v", d2)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC6: a panic answers allow.
|
||||
func TestStopAPanicAllows(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("a panic was not recovered: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// runStop through its counter file: three refusals in a row for the one open wait, then let through.
|
||||
func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-6", At: stopT0, Waits: []Wait{openWait("w-6", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-6.json"), f)
|
||||
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
|
||||
|
||||
for i := 1; i <= MostStopRefusals; i++ {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
|
||||
}
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
|
||||
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): the let-through notice reaches the screen too, not only the log.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
|
||||
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
|
||||
// 1749, follow-up).
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
|
||||
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
|
||||
// every session.
|
||||
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
|
||||
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("a stop with no waits created the waits folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
|
||||
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var counters map[string]int
|
||||
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
|
||||
t.Fatalf("the counter file: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
|
||||
// counter file rather than leaving a stale "{}" behind.
|
||||
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
path := filepath.Join(waitsDir, "sess-8.json")
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
|
||||
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
|
||||
t.Fatalf("the counter file was not written: %v", err)
|
||||
}
|
||||
|
||||
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
|
||||
var stdout2, stderr2 bytes.Buffer
|
||||
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
|
||||
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one (path separators,
|
||||
// ".."), is never used to build a file path — the counter file stays inside the waits directory — and the
|
||||
// Stop hook allows, saying why.
|
||||
func TestStopRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
for _, id := range []string{"../../x", "a/b"} {
|
||||
root := t.TempDir()
|
||||
waitsDir := filepath.Join(root, "agent", "waits")
|
||||
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q,"stop_hook_active":false}`, id))
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("id %q: code %d, stderr %s", id, code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "could not be used") {
|
||||
t.Fatalf("id %q: did not say why it allowed: %s", id, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("id %q: the notice did not reach stdout: %s", id, stdout.String())
|
||||
}
|
||||
escaped := filepath.Join(waitsDir, id+".refusals.json")
|
||||
if _, err := os.Stat(escaped); !os.IsNotExist(err) {
|
||||
t.Fatalf("id %q: a counter file was written outside the waits dir at %s", id, escaped)
|
||||
}
|
||||
}
|
||||
// A blank id still just allows, quietly: it never reaches the point of building a path at all.
|
||||
waitsDir := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, strings.NewReader(`{"session_id":""}`), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("blank id: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 2 (ADR 0489 §5 "never loops", 1743/AC5): when the decision is to refuse and the
|
||||
// refusal counter cannot be written, the hook allows rather than risk refusing forever with a cap that
|
||||
// never takes effect.
|
||||
func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("running as root: a directory's write bit cannot be taken away")
|
||||
}
|
||||
waitsDir := t.TempDir()
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"),
|
||||
WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}})
|
||||
if err := os.Chmod(waitsDir, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(waitsDir, 0o700) })
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "counter") {
|
||||
t.Fatalf("did not say the counter could not be written: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("the notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
|
||||
// runner may not be granted it); without one, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
|
||||
withFamily := openWait("w-f", 10)
|
||||
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
withFamily.Family = "mesh-delivery.decided"
|
||||
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
|
||||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason)
|
||||
}
|
||||
|
||||
noFamily := openWait("w-nf", 10)
|
||||
noFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
_, reason2 := waitBlocksStop("sess-nf", noFamily, stopT0.Add(time.Minute))
|
||||
if strings.Contains(reason2, "mesh_wait") || !strings.Contains(reason2, "start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason2)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC7's rendering half (hooks.json holds the guard's PreToolUse entry and the Stop entry) is checked
|
||||
// together with 1744/AC4 in terminal_only_test.go's
|
||||
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. claude_code_hook_register still
|
||||
// refuses a Stop hook (or any other) at every scope.
|
||||
func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
text, isError := s.call(t, "claude_code_hook_register", map[string]any{"name": "x", "event": "Stop", "command": "true"})
|
||||
if !isError || !strings.Contains(text, "issue 340") {
|
||||
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
|
||||
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
|
||||
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
|
||||
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
|
||||
// filesystem only through the helper's request/response protocol bumps its count.
|
||||
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
was := fsys
|
||||
fsys = f
|
||||
t.Cleanup(func() { fsys = was })
|
||||
|
||||
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
|
||||
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counted.asked.Load() == 0 {
|
||||
t.Fatal("the counter file was written directly, never through the helper")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
|
||||
t.Fatalf("the written counters: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -261,12 +261,12 @@ func TestTheOperatorsManagedSettingsAreWhatSessionsObey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin's hooks are the mesh's own guard on the shell (novox/hq ADR 0245) and nothing else, whatever the
|
||||
// configuration given holds (novox/hq issue 340).
|
||||
func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
|
||||
// The plugin's only hooks are the guard, the session-start hook and the Stop hook (novox/hq ADR 0245, ADR
|
||||
// 0489) — never a hook a registration named, whatever the configuration given holds (novox/hq issue 340).
|
||||
func TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook(t *testing.T) {
|
||||
c := Config{Mesh: []Item{{Kind: KindHook, Name: "planted", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
|
||||
Command: "${HOOK_DIR}/x.sh", Files: map[string]string{"x.sh": "#!/bin/sh\n"}}}}
|
||||
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true"})
|
||||
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true", WaitsDir: "/home/agent/.claude/nox-mesh/waits"})
|
||||
root := "plugins/" + Plugin + "/"
|
||||
var hooks struct {
|
||||
Hooks map[string][]struct {
|
||||
@@ -279,9 +279,16 @@ func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hooks.Hooks) != 1 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
|
||||
if len(hooks.Hooks) != 3 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
|
||||
hooks.Hooks["PreToolUse"][0].Hooks[0].Command != GuardCommand() {
|
||||
t.Fatalf("the plugin's hooks: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
t.Fatalf("the guard's hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if len(hooks.Hooks["SessionStart"]) != 1 || hooks.Hooks["SessionStart"][0].Matcher != "startup|resume|clear|compact" ||
|
||||
hooks.Hooks["SessionStart"][0].Hooks[0].Command != SessionStartCommand("/home/agent/.claude/nox-mesh/waits") {
|
||||
t.Fatalf("the session-start hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if len(hooks.Hooks["Stop"]) != 1 || hooks.Hooks["Stop"][0].Hooks[0].Command != StopCommand("/home/agent/.claude/nox-mesh/waits") {
|
||||
t.Fatalf("the Stop hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
for path := range plugin {
|
||||
if strings.HasPrefix(path, root+"hooks/planted") {
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
package main
|
||||
|
||||
// A session's wait list (novox/hq ADR 0489, as changed by ADR 0490): what a session still expects an answer
|
||||
// to — an Approve, a delivery's end, anything it started and must act on when it ends
|
||||
// (follow-what-you-start) — so the plugin's Stop hook can refuse to end its turn while one is open and
|
||||
// nothing watches it.
|
||||
//
|
||||
// Kept in the module's `waits` state under the session's name and this node (ADR 0490 §1) — `by`, as
|
||||
// claude_code_ask takes it, never the session id, which Claude Code hands a session a new one of at a
|
||||
// /clear. Every call also gives `session`, the caller's current id; the module keeps the binding of a name
|
||||
// to the session that last called for it, with that call's time, so the same name's waits are found again
|
||||
// across a /clear once the new session has adopted it.
|
||||
//
|
||||
// The waits file a hook reads is kept per session id, not per name, and holds the waits of whichever name is
|
||||
// currently bound to that id (empty for an id nothing is bound to): written whole, atomically, on every
|
||||
// change, and rewritten for both ids when a binding moves (AdoptName). A second file, the node-wide index,
|
||||
// lists every name on this node that still holds an open wait, so the SessionStart hook can tell a session
|
||||
// with a brand new id what it might want to adopt, without calling back into this module.
|
||||
//
|
||||
// Every file lives under the agent's home (the manifest's `agent-home` resource), which this process —
|
||||
// launched as the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent
|
||||
// account of its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already
|
||||
// carries such a write through the sudo helper the guard's binary also serves, exactly as grant.go's
|
||||
// WriteCredentials does for the agent's own credentials file. Where the node has no agent account of its
|
||||
// own, fsys is the plain filesystem and the write reaches the operator's own home directly.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// WaitsState is the module's state the wait lists are kept in.
|
||||
const WaitsState = "waits"
|
||||
|
||||
// WaitsDir is where the mirrored files live under the agent's home.
|
||||
const WaitsDir = "nox-mesh/waits"
|
||||
|
||||
// WaitPrefix starts every wait's id.
|
||||
const WaitPrefix = "w-"
|
||||
|
||||
// MostWaits is how many open waits one name may hold at once (novox/hq ADR 0489, 1629/AC5).
|
||||
const MostWaits = 50
|
||||
|
||||
// The bounds of a wait's bound, in minutes (1629/AC5).
|
||||
const (
|
||||
ShortestWaitBound = 1
|
||||
LongestWaitBound = 1440
|
||||
)
|
||||
|
||||
// mostWaitAttempts bounds the compare-and-set retry loop over the `waits` state.
|
||||
const mostWaitAttempts = 20
|
||||
|
||||
var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
||||
|
||||
// requireSessionID trims session and refuses it unless it is a plain id matching waitSessionID — every op
|
||||
// holds to this (review round 1, item b), so a session id is never used to build a path from anything but a
|
||||
// safe name (no "/", so it cannot name a file outside the waits directory).
|
||||
func requireSessionID(session string) (string, error) {
|
||||
session = strings.TrimSpace(session)
|
||||
if !waitSessionID.MatchString(session) {
|
||||
return "", errors.New("session is required: the id the SessionStart hook named")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// WaitRead is the verb a session reads to see where one wait stands.
|
||||
type WaitRead struct {
|
||||
Address string `json:"address"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). By is the name it was
|
||||
// added under.
|
||||
type Wait struct {
|
||||
ID string `json:"id"`
|
||||
By string `json:"by"`
|
||||
What string `json:"what"`
|
||||
Read WaitRead `json:"read"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Bound int `json:"bound"` // minutes
|
||||
At time.Time `json:"at"`
|
||||
Watcher string `json:"watcher,omitempty"`
|
||||
Said bool `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// PastBound says whether now is past this wait's bound.
|
||||
func (w Wait) PastBound(now time.Time) bool {
|
||||
return now.Sub(w.At) > time.Duration(w.Bound)*time.Minute
|
||||
}
|
||||
|
||||
// NamedWaits is what the `waits` state keeps for one name on this node (novox/hq ADR 0490 §1): its open
|
||||
// waits, and the binding to whichever session currently answers to that name — empty until the first call
|
||||
// names it, by add or by adopt.
|
||||
type NamedWaits struct {
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
CurrentSession string `json:"currentSession,omitempty"`
|
||||
LastCall time.Time `json:"lastCall,omitempty"`
|
||||
Waits []Wait `json:"waits"`
|
||||
}
|
||||
|
||||
// WaitsFile is what one session id's mirrored file holds, whole, on every change (1629/AC3): the open waits
|
||||
// of whichever name is currently bound to this session, and the time they were written.
|
||||
type WaitsFile struct {
|
||||
Session string `json:"session"`
|
||||
At time.Time `json:"at"`
|
||||
Waits []Wait `json:"waits"`
|
||||
}
|
||||
|
||||
// NodeIndexEntry is one name's open waits, as the node-wide index lists it for the SessionStart hook
|
||||
// (novox/hq ADR 0490 §2).
|
||||
type NodeIndexEntry struct {
|
||||
Name string `json:"name"`
|
||||
Open int `json:"open"`
|
||||
CurrentSession string `json:"currentSession,omitempty"`
|
||||
LastCall time.Time `json:"lastCall"`
|
||||
}
|
||||
|
||||
// NodeIndex is the node-wide index file's whole content (novox/hq ADR 0490 §2): every name on this node that
|
||||
// still holds an open wait, written by the tool on every change a name's waits or binding takes, so the
|
||||
// SessionStart hook can say "if this is you, adopt" without calling back into this module.
|
||||
type NodeIndex struct {
|
||||
Node string `json:"node"`
|
||||
At time.Time `json:"at"`
|
||||
Names []NodeIndexEntry `json:"names"`
|
||||
}
|
||||
|
||||
// WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the
|
||||
// revision read; Keys lists every name kept, for the node-wide index.
|
||||
type WaitStore interface {
|
||||
Get(key string) (json.RawMessage, uint64, bool, error)
|
||||
Create(key string, value any) error
|
||||
Update(key string, value any, revision uint64) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// keptWaits is the `waits` state on the bus.
|
||||
type keptWaits struct{ s stdio.KeptState }
|
||||
|
||||
func (k keptWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
e, err := k.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value, e.Revision, true, nil
|
||||
}
|
||||
func (k keptWaits) Create(key string, value any) error {
|
||||
_, err := k.s.Create(key, value)
|
||||
return err
|
||||
}
|
||||
func (k keptWaits) Update(key string, value any, revision uint64) error {
|
||||
_, err := k.s.Update(key, value, revision)
|
||||
return err
|
||||
}
|
||||
func (k keptWaits) Keys() ([]string, error) { return k.s.Keys() }
|
||||
|
||||
// Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch.
|
||||
type Waiting struct {
|
||||
Store WaitStore
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
var waiting *Waiting
|
||||
|
||||
// waitsKey is the state's key for one name's waits: its name and this node (novox/hq ADR 0490 §1), so two
|
||||
// nodes never collide over the same name.
|
||||
func waitsKey(p Paths, name string) string { return p.Node + "/" + name }
|
||||
|
||||
// WaitsDirPath is where this node's sessions' waits are mirrored, under the agent's home: the directory the
|
||||
// plugin's SessionStart and Stop hooks (1744, 1743) are given on their command line.
|
||||
func WaitsDirPath(p Paths) string {
|
||||
return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir))
|
||||
}
|
||||
|
||||
// waitsFilePath is where one session id's mirrored waits are.
|
||||
func waitsFilePath(p Paths, session string) string {
|
||||
return filepath.Join(WaitsDirPath(p), session+".json")
|
||||
}
|
||||
|
||||
// nodeIndexPath is the node-wide index's file, beside the per-session ones; its name can never collide with
|
||||
// a session id, since waitSessionID never allows a leading "_".
|
||||
func nodeIndexPath(p Paths) string { return filepath.Join(WaitsDirPath(p), "_index.json") }
|
||||
|
||||
func newWaitID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return WaitPrefix + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// addressShaped says whether s could be an address `mesh_call` takes: not blank, no whitespace, and a dot
|
||||
// between two names (`<seat>.<verb>`, `<node>/<seat>.<verb>`, `<node>/<module>.<tool>`).
|
||||
func addressShaped(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || strings.ContainsAny(s, " \t\n\r") {
|
||||
return false
|
||||
}
|
||||
dot := strings.LastIndex(s, ".")
|
||||
return dot > 0 && dot < len(s)-1
|
||||
}
|
||||
|
||||
func readNamedWaits(store WaitStore, key string) (NamedWaits, uint64, bool, error) {
|
||||
raw, rev, found, err := store.Get(key)
|
||||
if err != nil || !found {
|
||||
return NamedWaits{}, 0, found, err
|
||||
}
|
||||
var nw NamedWaits
|
||||
_ = json.Unmarshal(raw, &nw)
|
||||
return nw, rev, true, nil
|
||||
}
|
||||
|
||||
// updateNamedWaits reads one name's waits, lets mutate change them, and writes the result back at the
|
||||
// revision read — created when there was none yet — retried when another write won the race.
|
||||
func updateNamedWaits(store WaitStore, key string, mutate func(NamedWaits) (NamedWaits, error)) (NamedWaits, error) {
|
||||
for i := 0; i < mostWaitAttempts; i++ {
|
||||
nw, rev, found, err := readNamedWaits(store, key)
|
||||
if err != nil {
|
||||
return NamedWaits{}, fmt.Errorf("the wait list could not be read: %w", err)
|
||||
}
|
||||
next, err := mutate(nw)
|
||||
if err != nil {
|
||||
return NamedWaits{}, err
|
||||
}
|
||||
if !found {
|
||||
err = store.Create(key, next)
|
||||
} else {
|
||||
err = store.Update(key, next, rev)
|
||||
}
|
||||
if err == nil {
|
||||
return next, nil
|
||||
}
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return NamedWaits{}, fmt.Errorf("the wait list could not be written: %w", err)
|
||||
}
|
||||
return NamedWaits{}, errors.New("the wait list kept changing elsewhere; try again")
|
||||
}
|
||||
|
||||
// tempSuffix is a short random name for a temp file, so two writes racing for the same path never share one
|
||||
// (review round 2, item a): each gets its own, and only the rename is a single atomic step.
|
||||
func tempSuffix() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// writeSessionFile rewrites one session id's mirrored file whole, atomically (its own temp file, then
|
||||
// rename): it holds exactly the open waits given and the time of this write (1629/AC3). waits is always the
|
||||
// union of every name currently bound to sessionID (sessionWaitsUnion), never only the name an op acted on:
|
||||
// a second name bound to the same id must stay enforced (ADR 0490 §1, review round 2 item 2).
|
||||
func writeSessionFile(p Paths, sessionID string, waits []Wait, now time.Time) error {
|
||||
path := waitsFilePath(p, sessionID)
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if waits == nil {
|
||||
waits = []Wait{}
|
||||
}
|
||||
raw, err := indented(WaitsFile{Session: sessionID, At: now.UTC(), Waits: waits})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp-" + tempSuffix()
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// sessionWaitsUnion is every open wait of every name on this node currently bound to session (ADR 0490 §1):
|
||||
// what that session id's mirrored file must hold, whole — never only the one name an op just acted on, or a
|
||||
// second name bound to the same id would stop being enforced the moment any op touched the first.
|
||||
func sessionWaitsUnion(store WaitStore, node, session string) ([]Wait, error) {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := node + "/"
|
||||
var out []Wait
|
||||
names := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
names = append(names, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(names) // deterministic order: tests, and a session reading its own file, see the same thing
|
||||
for _, k := range names {
|
||||
nw, _, found, err := readNamedWaits(store, k)
|
||||
if err != nil || !found || nw.CurrentSession != session {
|
||||
continue
|
||||
}
|
||||
out = append(out, nw.Waits...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeSessionWaits rewrites session's mirrored file with sessionWaitsUnion: every call that changes a
|
||||
// name's waits or its binding goes through this, never writeSessionFile directly with one name's waits
|
||||
// alone.
|
||||
func writeSessionWaits(p Paths, store WaitStore, session string, now time.Time) error {
|
||||
union, err := sessionWaitsUnion(store, p.Node, session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the session's other names could not be read: %w", err)
|
||||
}
|
||||
return writeSessionFile(p, session, union, now)
|
||||
}
|
||||
|
||||
// writeNodeIndex rewrites the node-wide index whole, atomically, from every name's current state on this
|
||||
// node (novox/hq ADR 0490 §2): a full scan of the `waits` state, bounded by how many names are ever used on
|
||||
// one node at once. Best-effort for its caller: a name's own file is already correct by the time this runs.
|
||||
func writeNodeIndex(p Paths, store WaitStore, now time.Time) error {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the node-wide wait index could not be read: %w", err)
|
||||
}
|
||||
prefix := p.Node + "/"
|
||||
idx := NodeIndex{Node: p.Node, At: now.UTC(), Names: []NodeIndexEntry{}}
|
||||
for _, k := range keys {
|
||||
name, ok := strings.CutPrefix(k, prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
nw, _, found, err := readNamedWaits(store, k)
|
||||
if err != nil || !found || len(nw.Waits) == 0 {
|
||||
continue
|
||||
}
|
||||
idx.Names = append(idx.Names, NodeIndexEntry{Name: name, Open: len(nw.Waits),
|
||||
CurrentSession: nw.CurrentSession, LastCall: nw.LastCall.UTC()})
|
||||
}
|
||||
sort.Slice(idx.Names, func(i, j int) bool { return idx.Names[i].Name < idx.Names[j].Name })
|
||||
path := nodeIndexPath(p)
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := indented(idx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp-" + tempSuffix()
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// callerName reads `by` as claude_code_ask does: who@node, where node is this one — the name a wait list is
|
||||
// kept under (novox/hq ADR 0490 §1), never the session id.
|
||||
func callerName(p Paths, by string) (string, error) {
|
||||
by = strings.TrimSpace(by)
|
||||
if who, node, found := strings.Cut(by, "@"); found {
|
||||
if node != p.Node {
|
||||
return "", fmt.Errorf("by names the node %q, and this is %s: call on the node the session runs on", node, p.Node)
|
||||
}
|
||||
return who, nil
|
||||
}
|
||||
if by == "" {
|
||||
return "", errors.New("by is required: your session's name, as who@node or who")
|
||||
}
|
||||
return by, nil
|
||||
}
|
||||
|
||||
// bindCaller checks the binding for name against session, refusing when another, more recently bound
|
||||
// session holds it (novox/hq ADR 0490 §3) — naming that session and when it last called, and how to take the
|
||||
// name back with adopt — and otherwise (re)binds it to session at now, so a name nothing has bound yet is
|
||||
// claimed by the first session that calls for it, add included.
|
||||
func bindCaller(nw *NamedWaits, name, node, by, session string, now time.Time) error {
|
||||
if nw.CurrentSession != "" && nw.CurrentSession != session {
|
||||
return fmt.Errorf("the name %q now answers to session %s (it last called at %s); adopt it again if this "+
|
||||
"is you: claude_code_wait {\"op\":\"adopt\",\"by\":%q,\"session\":%q}", name, nw.CurrentSession,
|
||||
nw.LastCall.UTC().Format(time.RFC3339), by, session)
|
||||
}
|
||||
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, node, session, now.UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// findWait is the index of id in waits, or -1.
|
||||
func findWait(waits []Wait, id string) int {
|
||||
for i, w := range waits {
|
||||
if w.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// WaitInput is what claude_code_wait's add takes.
|
||||
type WaitInput struct {
|
||||
Session string
|
||||
By string
|
||||
What string
|
||||
Read WaitRead
|
||||
Family string
|
||||
Bound int
|
||||
}
|
||||
|
||||
// AddWait keeps one more wait for a name, refusing outside ADR 0489's bounds (1629/AC5) or when another
|
||||
// session has since taken the name over (ADR 0490 §3), and rewrites the caller's mirrored file (1629/AC3).
|
||||
func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(in.Session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, in.By)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
what := strings.TrimSpace(in.What)
|
||||
if what == "" {
|
||||
return nil, errors.New("what is required: say what you are waiting for")
|
||||
}
|
||||
if in.Bound < ShortestWaitBound || in.Bound > LongestWaitBound {
|
||||
return nil, fmt.Errorf("bound_minutes is %d; it is %d to %d", in.Bound, ShortestWaitBound, LongestWaitBound)
|
||||
}
|
||||
address := strings.TrimSpace(in.Read.Address)
|
||||
if !addressShaped(address) {
|
||||
return nil, fmt.Errorf("read.address %q is not an address: the verb that reads where this wait stands, as mesh_call takes it", in.Read.Address)
|
||||
}
|
||||
w := Wait{ID: newWaitID(), By: name, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments},
|
||||
Family: strings.TrimSpace(in.Family), Bound: in.Bound, At: now.UTC()}
|
||||
key := waitsKey(p, name)
|
||||
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, in.By, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
if len(nw.Waits) >= MostWaits {
|
||||
return nw, fmt.Errorf("%d waits already wait for %s; at most %d open waits per name", len(nw.Waits), name, MostWaits)
|
||||
}
|
||||
nw.Waits = append(append([]Wait(nil), nw.Waits...), w)
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was kept, but its file could not be written: %w", err)
|
||||
}
|
||||
if err := writeNodeIndex(p, store, now); err != nil {
|
||||
say("writing the node-wide wait index after %s added for %s: %v", w.ID, name, err)
|
||||
}
|
||||
say("wait %s added for %s (session %s): %s", w.ID, name, session, what)
|
||||
return map[string]any{"id": w.ID, "name": name, "session": session, "open": len(nw.Waits)}, nil
|
||||
}
|
||||
|
||||
// WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2).
|
||||
func WatchedWait(p Paths, session, by, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, watcher = strings.TrimSpace(id), strings.TrimSpace(watcher)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
if watcher == "" {
|
||||
return nil, errors.New("watcher is required: what follows this wait (mesh_wait on its family, or a watcher agent)")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
nw.Waits[i].Watcher = watcher
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
return map[string]any{"id": id, "watcher": watcher}, nil
|
||||
}
|
||||
|
||||
// DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2).
|
||||
func DoneWait(p Paths, session, by, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, outcome = strings.TrimSpace(id), strings.TrimSpace(outcome)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
nw.Waits = append(append([]Wait(nil), nw.Waits[:i]...), nw.Waits[i+1:]...)
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was done, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
say("wait %s for %s (session %s) done: %s", id, name, session, outcome)
|
||||
return map[string]any{"id": id, "done": true, "outcome": outcome}, nil
|
||||
}
|
||||
|
||||
// SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound
|
||||
// (1629/AC2).
|
||||
func SaidWait(p Paths, session, by, id string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
if !nw.Waits[i].PastBound(now) {
|
||||
return nw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id)
|
||||
}
|
||||
nw.Waits[i].Said = true
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
return map[string]any{"id": id, "said": true}, nil
|
||||
}
|
||||
|
||||
// ListWaits answers a name's open waits, with their age and whether they are past their bound (1629/AC1),
|
||||
// and who currently holds the name. Read-only (review round 2, item c): unlike every other op it never
|
||||
// claims an unbound name, and never refuses a session that is not (or not yet) the one bound to it — a
|
||||
// session may want to look before adopting.
|
||||
func ListWaits(p Paths, session, by string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nw, _, _, err := readNamedWaits(store, waitsKey(p, name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the wait list could not be read: %w", err)
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, w := range nw.Waits {
|
||||
entry := map[string]any{"id": w.ID, "what": w.What, "by": w.By, "address": w.Read.Address,
|
||||
"bound_minutes": w.Bound, "at": w.At.Format(time.RFC3339), "age_seconds": int(now.Sub(w.At).Seconds()),
|
||||
"past_bound": w.PastBound(now), "watcher": w.Watcher, "said": w.Said}
|
||||
if len(w.Read.Arguments) > 0 {
|
||||
entry["arguments"] = w.Read.Arguments
|
||||
}
|
||||
if w.Family != "" {
|
||||
entry["family"] = w.Family
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
answer := map[string]any{"session": session, "name": name, "open": out}
|
||||
if nw.CurrentSession != "" {
|
||||
answer["held-by"] = map[string]any{"session": nw.CurrentSession, "last-called": nw.LastCall.UTC().Format(time.RFC3339)}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// AdoptName binds name to session, always taking it over from whoever held it (novox/hq ADR 0490 §3): the
|
||||
// mirrored files of both the session it is taken from and the one adopting it are rewritten at once, so a
|
||||
// session that no longer owns a name never finds its old waits in its own file, and the new one does right
|
||||
// away — no waiting on a bound, unlike the Stop and SessionStart hooks' own.
|
||||
func AdoptName(p Paths, by, session string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
var oldSession string
|
||||
var oldLastCall time.Time
|
||||
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
oldSession, oldLastCall = nw.CurrentSession, nw.LastCall
|
||||
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, p.Node, session, now.UTC()
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tookOver := oldSession != "" && oldSession != session
|
||||
if tookOver {
|
||||
// oldSession's file is rewritten from the union of whatever names remain bound to it (ADR 0490 §1,
|
||||
// review round 2 item 2): name no longer is one of them, but another might still be — never simply
|
||||
// emptied.
|
||||
if err := writeSessionWaits(p, store, oldSession, now); err != nil {
|
||||
return nil, fmt.Errorf("%s was adopted, but the session it was taken from could not be told: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the name was adopted, but its file could not be written: %w", err)
|
||||
}
|
||||
if err := writeNodeIndex(p, store, now); err != nil {
|
||||
say("writing the node-wide wait index after %s was adopted: %v", name, err)
|
||||
}
|
||||
// Review round 2, item b: a fresh three-strikes count starts under the new binding, on both ids.
|
||||
waitsDir := WaitsDirPath(p)
|
||||
_ = writeRefusalCounters(waitsDir, session, map[string]int{})
|
||||
if tookOver {
|
||||
_ = writeRefusalCounters(waitsDir, oldSession, map[string]int{})
|
||||
}
|
||||
answer := map[string]any{"name": name, "session": session, "open": len(nw.Waits)}
|
||||
if tookOver {
|
||||
answer["took-over-from"] = map[string]any{"session": oldSession, "last-called": oldLastCall.UTC().Format(time.RFC3339)}
|
||||
say("%s adopted by session %s, taken over from %s (last called %s)", name, session, oldSession,
|
||||
oldLastCall.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
say("%s adopted by session %s", name, session)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// waitsFileIn is where one session's mirrored waits are, given a hook's own waits-dir argument: the hooks
|
||||
// have no launch environment, only their command line, so they never recompute this from Paths.
|
||||
func waitsFileIn(waitsDir, session string) string { return filepath.Join(waitsDir, session+".json") }
|
||||
|
||||
// nodeIndexPathIn is the node-wide index file, given a hook's own waits-dir argument.
|
||||
func nodeIndexPathIn(waitsDir string) string { return filepath.Join(waitsDir, "_index.json") }
|
||||
|
||||
// readWaitsFile reads one session's mirrored file, as the hooks do: a plain local read, as the session's own
|
||||
// account (never through fsys or the module's state), and never calling back into this module.
|
||||
func readWaitsFile(path string) (WaitsFile, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return WaitsFile{}, err
|
||||
}
|
||||
var f WaitsFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return WaitsFile{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// readNodeIndex reads the node-wide index, as the SessionStart hook does: a plain local read, never through
|
||||
// fsys or the module's state, and never calling back into this module.
|
||||
func readNodeIndex(path string) (NodeIndex, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return NodeIndex{}, err
|
||||
}
|
||||
var idx NodeIndex
|
||||
if err := json.Unmarshal(raw, &idx); err != nil {
|
||||
return NodeIndex{}, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// waitTools is claude_code_wait: add, watched, done, said, list and adopt, over a name's wait list (novox/hq
|
||||
// ADR 0489, ADR 0490).
|
||||
func waitTools(p Paths) []stdio.Tool {
|
||||
notLaunched := errors.New("this module was not launched with the bus; no wait can be kept")
|
||||
return []stdio.Tool{
|
||||
{Name: "claude_code_wait",
|
||||
Description: "Your wait list, kept under your session's name (novox/hq ADR 0489, ADR 0490): what you " +
|
||||
"still expect an answer to — an Approve, a delivery's end, anything you started and must act on when " +
|
||||
"it ends. The plugin's Stop hook refuses to end your turn while a wait here is open and nothing " +
|
||||
"follows it. op is add, watched, done, said, list or adopt; session (your current session id, named " +
|
||||
"by the SessionStart hook) and by (your session's name, as who@node or who) are always required. A " +
|
||||
"name is bound to whichever session last called for it; if your own id changed (a /clear) you must " +
|
||||
"adopt your name again before it answers to you — the SessionStart hook lists every name on this " +
|
||||
"node with an open wait and how. adopt takes the name over at once, however recently another " +
|
||||
"session called for it; that session is told so, naming yours, the next time it calls. add takes " +
|
||||
"what (what you are waiting for), read (the verb that answers where it stands: {address, " +
|
||||
"arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes (1 to 1440), and " +
|
||||
"answers an id; at most 50 waits are open per name. watched takes id and watcher (what follows it: " +
|
||||
"mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes it " +
|
||||
"from the open list. said takes id and marks a wait past its bound as told to the operator — " +
|
||||
"refused while it is still within its bound. list answers the open waits, with their age and " +
|
||||
"whether each is past its bound.",
|
||||
Input: map[string]any{
|
||||
"op": str("add, watched, done, said, list or adopt"),
|
||||
"session": str("required: your session's current id"),
|
||||
"by": str("required: your session's name, as who@node or who"),
|
||||
"what": str("add: what you are waiting for, in plain words"),
|
||||
"read": map[string]any{"type": "object", "description": "add: the verb that reads where this wait stands",
|
||||
"properties": map[string]any{
|
||||
"address": str("the address, as mesh_call takes it"),
|
||||
"arguments": map[string]any{"type": "object", "description": "its arguments"},
|
||||
}},
|
||||
"family": str("add: the event family mesh_wait can wait on, when there is one"),
|
||||
"bound_minutes": map[string]any{"type": "integer", "description": "add: minutes until this wait's bound, 1 to 1440"},
|
||||
"id": str("watched, done or said: the id add answered"),
|
||||
"watcher": str("watched: what follows this wait"),
|
||||
"outcome": str("done: what the wait ended with"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if waiting == nil {
|
||||
return nil, notLaunched
|
||||
}
|
||||
session, by := strArg(a, "session"), strArg(a, "by")
|
||||
now := time.Now()
|
||||
if waiting.Now != nil {
|
||||
now = waiting.Now()
|
||||
}
|
||||
switch op := strArg(a, "op"); op {
|
||||
case "add":
|
||||
read := WaitRead{}
|
||||
if m, ok := a["read"].(map[string]any); ok {
|
||||
read.Address = strArg(m, "address")
|
||||
if args, ok := m["arguments"].(map[string]any); ok {
|
||||
read.Arguments = args
|
||||
}
|
||||
}
|
||||
bound := 0
|
||||
if n, ok := a["bound_minutes"].(float64); ok {
|
||||
bound = int(n)
|
||||
}
|
||||
return AddWait(p, WaitInput{Session: session, By: by, What: strArg(a, "what"), Read: read,
|
||||
Family: strArg(a, "family"), Bound: bound}, waiting.Store, now)
|
||||
case "watched":
|
||||
return WatchedWait(p, session, by, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now)
|
||||
case "done":
|
||||
return DoneWait(p, session, by, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now)
|
||||
case "said":
|
||||
return SaidWait(p, session, by, strArg(a, "id"), waiting.Store, now)
|
||||
case "list":
|
||||
return ListWaits(p, session, by, waiting.Store, now)
|
||||
case "adopt":
|
||||
return AdoptName(p, by, session, waiting.Store, now)
|
||||
default:
|
||||
return nil, fmt.Errorf("op is add, watched, done, said, list or adopt, not %q", op)
|
||||
}
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package main
|
||||
|
||||
// A session's wait list (novox/hq ADR 0489, ADR 0490, issues 1629, 1744).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// memWaits is the `waits` state: values with revisions, create-only and compare-and-set, as the bus keeps it.
|
||||
type memWaits struct {
|
||||
mu sync.Mutex
|
||||
m map[string]json.RawMessage
|
||||
rev map[string]uint64
|
||||
n uint64
|
||||
}
|
||||
|
||||
func newMemWaits() *memWaits {
|
||||
return &memWaits{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
|
||||
}
|
||||
|
||||
func (s *memWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
raw, ok := s.m[key]
|
||||
return raw, s.rev[key], ok, nil
|
||||
}
|
||||
func (s *memWaits) put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
s.n++
|
||||
s.m[key], s.rev[key] = raw, s.n
|
||||
return err
|
||||
}
|
||||
func (s *memWaits) Create(key string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.m[key]; ok {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memWaits) Update(key string, value any, revision uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.rev[key] != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memWaits) Keys() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.m))
|
||||
for k := range s.m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addApproveWait adds the wait fixture the operator's Approve is kept under, for name on session.
|
||||
func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string, at time.Time) string {
|
||||
t.Helper()
|
||||
out, err := AddWait(p, WaitInput{Session: session, By: name + "@" + p.Node, What: "the operator's Approve of pull request 42",
|
||||
Read: WaitRead{Address: "mesh-delivery.recheck", Arguments: map[string]any{"pr": 42}}, Family: "mesh-delivery.decided",
|
||||
Bound: 10}, store, at)
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
return out["id"].(string)
|
||||
}
|
||||
|
||||
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
|
||||
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
|
||||
if !strings.HasPrefix(id, WaitPrefix) {
|
||||
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
|
||||
}
|
||||
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := out["open"].([]map[string]any)
|
||||
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
|
||||
t.Fatalf("%+v", open)
|
||||
}
|
||||
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open = out["open"].([]map[string]any)
|
||||
if open[0]["past_bound"] != true {
|
||||
t.Fatalf("past its 10-minute bound after 11 minutes: %+v", open[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
|
||||
// it from the open list.
|
||||
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
|
||||
|
||||
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
|
||||
t.Fatalf("said before the bound: %v", err)
|
||||
}
|
||||
if _, err := WatchedWait(p, "sess-2", by, id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := ListWaits(p, "sess-2", by, store, t0.Add(2*time.Minute))
|
||||
open := out["open"].([]map[string]any)
|
||||
if open[0]["watcher"] != "mesh_wait on mesh-delivery.decided" {
|
||||
t.Fatalf("watcher not kept: %+v", open[0])
|
||||
}
|
||||
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(11*time.Minute)); err != nil {
|
||||
t.Fatalf("said past the bound: %v", err)
|
||||
}
|
||||
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(12*time.Minute))
|
||||
open = out["open"].([]map[string]any)
|
||||
if open[0]["said"] != true {
|
||||
t.Fatalf("said not kept: %+v", open[0])
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(13*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(14*time.Minute))
|
||||
if len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("done did not remove it from the open list: %v", out)
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(15*time.Minute)); err == nil {
|
||||
t.Fatal("done on an already-done wait was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
|
||||
// and the write time.
|
||||
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
|
||||
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Session != "sess-3" || len(f.Waits) != 1 || f.Waits[0].ID != id1 || !f.At.Equal(t0.UTC()) {
|
||||
t.Fatalf("%+v", f)
|
||||
}
|
||||
// Written whole: no .mesh-tmp left beside it.
|
||||
if _, err := os.Stat(waitsFilePath(p, "sess-3") + ".mesh-tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("a temp file was left beside it: %v", err)
|
||||
}
|
||||
id2, err := AddWait(p, WaitInput{Session: "sess-3", By: by, What: "a second wait",
|
||||
Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil || len(f.Waits) != 2 || !f.At.Equal(t0.Add(time.Minute).UTC()) {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-3", by, id1, "approved", store, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil || len(f.Waits) != 1 || f.Waits[0].ID != id2["id"] {
|
||||
t.Fatalf("the file does not hold exactly the open waits: %+v %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name
|
||||
// after a /clear that keeps calling with the same session id; a different name lists none of them.
|
||||
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
|
||||
|
||||
// A restart: a fresh Waiting over the same store reads the same waits.
|
||||
restarted := &Waiting{Store: store, Now: func() time.Time { return t0.Add(time.Minute) }}
|
||||
_ = restarted
|
||||
out, err := ListWaits(p, "sess-4", by, store, t0.Add(time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("not read again after a restart: %v %v", out, err)
|
||||
}
|
||||
|
||||
// The same session id, called again: still lists it.
|
||||
out, err = ListWaits(p, "sess-4", by, store, t0.Add(2*time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("not read again for the same name: %v %v", out, err)
|
||||
}
|
||||
|
||||
// A different name, same node: none of them.
|
||||
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("another name saw them: %v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
|
||||
// address, and more than 50 open waits per name, naming the argument.
|
||||
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
base := func() WaitInput {
|
||||
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
|
||||
}
|
||||
for name, change := range map[string]struct {
|
||||
mutate func(*WaitInput)
|
||||
want string
|
||||
}{
|
||||
"empty what": {func(in *WaitInput) { in.What = "" }, "what is required"},
|
||||
"bound zero": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
||||
"bound under 1": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
||||
"bound over 1440": {func(in *WaitInput) { in.Bound = 1441 }, "bound_minutes"},
|
||||
"empty address": {func(in *WaitInput) { in.Read.Address = "" }, "read.address"},
|
||||
"address no dot": {func(in *WaitInput) { in.Read.Address = "notanaddress" }, "read.address"},
|
||||
"address has spaces": {func(in *WaitInput) { in.Read.Address = "not an address" }, "read.address"},
|
||||
} {
|
||||
in := base()
|
||||
change.mutate(&in)
|
||||
store := newMemWaits()
|
||||
if _, err := AddWait(p, in, store, t0); err == nil || !strings.Contains(err.Error(), change.want) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// More than 50 open waits per name.
|
||||
store := newMemWaits()
|
||||
for i := 0; i < MostWaits; i++ {
|
||||
if _, err := AddWait(p, base(), store, t0); err != nil {
|
||||
t.Fatalf("wait %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := AddWait(p, base(), store, t0); err == nil || !strings.Contains(err.Error(), "at most 50 open waits") {
|
||||
t.Fatalf("the 51st wait was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
|
||||
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
|
||||
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
by := "fix-1629@g14"
|
||||
for _, bad := range []string{"", "a/b", "../x"} {
|
||||
store := newMemWaits()
|
||||
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("list %q: %v", bad, err)
|
||||
}
|
||||
if _, err := WatchedWait(p, bad, by, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("watched %q: %v", bad, err)
|
||||
}
|
||||
if _, err := DoneWait(p, bad, by, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("done %q: %v", bad, err)
|
||||
}
|
||||
if _, err := SaidWait(p, bad, by, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("said %q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC6: the tool is served through the module's own dispatch.
|
||||
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
var added map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &added); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := added["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("no id answered: %s", text)
|
||||
}
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@g14"})
|
||||
if isErr || !strings.Contains(text, id) {
|
||||
t.Fatalf("list: %v %s", isErr, text)
|
||||
}
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"id": id, "outcome": "approved"})
|
||||
if isErr {
|
||||
t.Fatalf("done: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADR 0490: a name's waits survive a session's id changing, by adopting -----------------------------
|
||||
|
||||
// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without
|
||||
// a separate adopt first.
|
||||
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
|
||||
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same
|
||||
// name (any op) is refused, naming the session that holds it and when it last called.
|
||||
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "sess-old") {
|
||||
t.Fatalf("add from a different session was not refused: %v", err)
|
||||
}
|
||||
|
||||
// list, read-only (review round 2 item c), never refuses: it just says who holds the name.
|
||||
out, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("list from a different session was refused: %v", err)
|
||||
}
|
||||
held, _ := out["held-by"].(map[string]any)
|
||||
if held == nil || held["session"] != "sess-old" {
|
||||
t.Fatalf("list did not say who holds the name: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the
|
||||
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
|
||||
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["session"] != "sess-new" || out["open"] != 1 {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
took, _ := out["took-over-from"].(map[string]any)
|
||||
if took == nil || took["session"] != "sess-old" {
|
||||
t.Fatalf("the answer did not name the earlier id: %v", out)
|
||||
}
|
||||
|
||||
oldFile, err := readWaitsFile(waitsFilePath(p, "sess-old"))
|
||||
if err != nil || len(oldFile.Waits) != 0 {
|
||||
t.Fatalf("the old session's file still holds waits: %+v %v", oldFile, err)
|
||||
}
|
||||
newFile, err := readWaitsFile(waitsFilePath(p, "sess-new"))
|
||||
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != id {
|
||||
t.Fatalf("the new session's file does not hold the waits: %+v %v", newFile, err)
|
||||
}
|
||||
|
||||
// The new session can act on the name at once: no 10-minute rule.
|
||||
if _, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("the new session could not act right after adopting: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
|
||||
// no bound protects it.
|
||||
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
|
||||
// sess-fresh calls again right before being adopted away from.
|
||||
if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := AdoptName(p, by, "sess-taker", store, t0.Add(11*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("adopt was refused even though it always takes over: %v", err)
|
||||
}
|
||||
if out["session"] != "sess-taker" {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to
|
||||
// another session, naming it.
|
||||
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := AddWait(p, WaitInput{Session: "sess-old", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0.Add(2*time.Minute))
|
||||
if err == nil || !strings.Contains(err.Error(), "sess-new") || !strings.Contains(err.Error(), "adopt") {
|
||||
t.Fatalf("the earlier id's next call did not name the new session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
|
||||
// hook to read directly.
|
||||
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
|
||||
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
|
||||
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
|
||||
store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx, err := readNodeIndex(nodeIndexPath(p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
|
||||
idx.Names[0].CurrentSession != "sess-y" {
|
||||
t.Fatalf("%+v", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// wantsOnlyWait is the id of the one wait a fixture added, read back through list.
|
||||
func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string) string {
|
||||
t.Helper()
|
||||
out, err := ListWaits(p, session, name+"@"+p.Node, store, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := out["open"].([]map[string]any)
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("%v", open)
|
||||
}
|
||||
return open[0]["id"].(string)
|
||||
}
|
||||
|
||||
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
|
||||
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
|
||||
if isErr || !strings.Contains(text, "sess-old") {
|
||||
t.Fatalf("adopt: %v %s", isErr, text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Review round 2: a session id's mirrored file is the union of every name bound to it -----------------
|
||||
|
||||
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
|
||||
// names' waits in its one mirrored file, not only the name an op last acted on.
|
||||
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, w := range f.Waits {
|
||||
ids[w.ID] = true
|
||||
}
|
||||
if len(f.Waits) != 2 || !ids[idX] || !ids[idY] {
|
||||
t.Fatalf("the session's file does not hold both names' waits: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
|
||||
// file holding the other name's waits — it is never emptied wholesale.
|
||||
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
|
||||
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sharedFile, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
||||
if err != nil || len(sharedFile.Waits) != 1 || sharedFile.Waits[0].ID != idX {
|
||||
t.Fatalf("sess-shared should still hold fix-x's wait: %+v %v", sharedFile, err)
|
||||
}
|
||||
newFile, err := readWaitsFile(waitsFilePath(p, "sess-y-new"))
|
||||
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != idY {
|
||||
t.Fatalf("sess-y-new should hold fix-y's wait: %+v %v", newFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
|
||||
// fresh three-strikes count starts under the new binding.
|
||||
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
waitsDir := WaitsDirPath(p)
|
||||
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeRefusalCounters(waitsDir, "sess-new", map[string]int{"w-2": 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := readRefusalCounters(waitsDir, "sess-old"); len(c) != 0 {
|
||||
t.Errorf("sess-old's counter was not cleared: %v", c)
|
||||
}
|
||||
if c := readRefusalCounters(waitsDir, "sess-new"); len(c) != 0 {
|
||||
t.Errorf("sess-new's counter was not cleared: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
|
||||
// mismatched session; it answers the name's waits and who currently holds it.
|
||||
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
|
||||
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
|
||||
// succeeds, as the first caller for an unbound name).
|
||||
out, err := ListWaits(p, "sess-reader", by, store, t0)
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("an unbound name: %v %v", out, err)
|
||||
}
|
||||
if _, found, _ := readSessionBinding(store, waitsKey(p, "fix-1629")); found {
|
||||
t.Fatal("list bound an unbound name")
|
||||
}
|
||||
if _, err := AddWait(p, WaitInput{Session: "sess-first", By: by, What: "x", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0); err != nil {
|
||||
t.Fatalf("add after a read-only list: %v", err)
|
||||
}
|
||||
|
||||
// A name bound to another session: list still answers (never refused), and says who holds it.
|
||||
out, err = ListWaits(p, "sess-other", by, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("list for a name held by another session was refused: %v", err)
|
||||
}
|
||||
if len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("list did not answer the name's waits: %v", out)
|
||||
}
|
||||
held, _ := out["held-by"].(map[string]any)
|
||||
if held == nil || held["session"] != "sess-first" {
|
||||
t.Fatalf("list did not say who holds the name: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// readSessionBinding is a thin test helper reading the raw NamedWaits at key, without claiming it.
|
||||
func readSessionBinding(store WaitStore, key string) (NamedWaits, bool, error) {
|
||||
nw, _, found, err := readNamedWaits(store, key)
|
||||
return nw, found, err
|
||||
}
|
||||
@@ -28,7 +28,8 @@
|
||||
"config",
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory"
|
||||
"memory",
|
||||
"waits"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
@@ -67,6 +68,7 @@
|
||||
"claude_code_proposals",
|
||||
"claude_code_ask",
|
||||
"claude_code_answer",
|
||||
"claude_code_wait",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
// ForgeAlias is the ssh name the forge-account provision sets up in the account's ssh files (ADR 0409 §1); the
|
||||
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
|
||||
const ForgeAlias = "mesh-forge"
|
||||
@@ -37,6 +39,18 @@ type FetchResult struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Commit string `json:"commit"`
|
||||
Modules []string `json:"modules"`
|
||||
// The merge preview (ADR 0484 clause 3), only when the call gave a base: the trunk commit, the merge commit of the
|
||||
// head onto it, the paths the merge changes, and the tree of each module directory (a directory holding a
|
||||
// module.json) in the merge. A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the
|
||||
// files, and the workspace is left at the trunk commit.
|
||||
Base string `json:"base,omitempty"`
|
||||
Merge string `json:"merge,omitempty"`
|
||||
Conflicts []string `json:"conflicts,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
|
||||
// Beside are the sibling repositories placed as plain directories under the workspace's beside directory, which
|
||||
// build_go_test hands to the tests as MESH_CHECK_BESIDE (ADR 0484 clause 4).
|
||||
Beside []Sibling `json:"beside,omitempty"`
|
||||
}
|
||||
|
||||
// fetchTool is the fetch step tool.
|
||||
@@ -52,6 +66,12 @@ func (m *Module) fetchTool() StepTool {
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
||||
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
||||
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
|
||||
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
|
||||
"beside": map[string]any{"type": "array", "description": "optional sibling repositories the tests read, each placed as a plain directory (no symlink) under the workspace's beside/<name>, which build_go_test gives the tests as MESH_CHECK_BESIDE",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
||||
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
||||
}, "required": []string{"repository", "commit"}}},
|
||||
},
|
||||
"required": []string{"repository", "commit"},
|
||||
},
|
||||
@@ -64,6 +84,14 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
repository, _ := args["repository"].(string)
|
||||
commit, _ := args["commit"].(string)
|
||||
ref, _ := args["ref"].(string)
|
||||
base, _ := args["base"].(string)
|
||||
if base != "" && !commitOK.MatchString(base) {
|
||||
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
|
||||
}
|
||||
siblings, err := siblingsArg(args["beside"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
||||
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
|
||||
}
|
||||
@@ -74,14 +102,27 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote := "ssh://" + ForgeAlias + "/" + repository + ".git"
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref); err != nil {
|
||||
remote := m.remote(repository)
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.FetchOptions...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := FetchResult{Workspace: id, Commit: commit}
|
||||
if base != "" {
|
||||
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirs, err := m.goModDirs(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Modules = dirs
|
||||
if base != "" && res.Merge == "" {
|
||||
return res, nil // a conflict: the tree is at the trunk, nothing of the head is built or tested
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if _, err := os.Stat(filepath.Join(tree, d, "vendor", "modules.txt")); err == nil {
|
||||
continue // a module that vendors resolves nothing
|
||||
@@ -93,7 +134,7 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return FetchResult{Workspace: id, Commit: commit, Modules: dirs}, nil
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// goModDirs are the directories of the tree that hold a go.mod, relative to it, in order.
|
||||
|
||||
@@ -30,7 +30,8 @@ type fakeStep struct {
|
||||
gomods string
|
||||
gate chan struct{} // when set, the first git command waits for it
|
||||
goRule func(line string) forgekey.Ran
|
||||
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
|
||||
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
|
||||
rule func(line string) (forgekey.Ran, bool) // answers a command before the defaults do
|
||||
}
|
||||
|
||||
func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
@@ -41,6 +42,11 @@ func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...s
|
||||
if f.gate != nil && strings.Contains(line, " init ") {
|
||||
<-f.gate
|
||||
}
|
||||
if f.rule != nil {
|
||||
if r, ok := f.rule(line); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case name == "git" && strings.Contains(line, " rev-parse HEAD"):
|
||||
return forgekey.Ran{Stdout: commitSHA + "\n"}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (m *Module) integration(ctx context.Context, workspace, tree string, packag
|
||||
return nil, fmt.Errorf("the integration level's bus: %w", err)
|
||||
}
|
||||
defer stopBus()
|
||||
env := []string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}
|
||||
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
|
||||
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
|
||||
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
|
||||
for _, d := range dirs {
|
||||
|
||||
@@ -37,6 +37,10 @@ type Module struct {
|
||||
// both outside the account's home (ADR 0466 §1); Home is the account's home.
|
||||
Run forgekey.Runner
|
||||
Home, Work, Cache string
|
||||
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
|
||||
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
|
||||
RemoteBase string
|
||||
FetchOptions []buildstep.Option
|
||||
// HTTP fetches the files an archive artifact is pinned to (url and sha256); a client of its own with a fifteen-minute
|
||||
// timeout when nil. A test serves its own.
|
||||
HTTP *http.Client
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
// The merge preview and the siblings of a fetch (novox/hq ADR 0484 clauses 3 and 4; issues 1675 and 1677). The merge
|
||||
// itself is the SDK's (buildstep.MergeInto), the one every build module's fetch step makes; what is here is the answer
|
||||
// built from it and the placing of the sibling repositories the tests read.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// Sibling is a repository placed beside the fetched tree: its name (the directory under beside/) and its commit.
|
||||
type Sibling struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
|
||||
func (m *Module) remote(repository string) string {
|
||||
base := m.RemoteBase
|
||||
if base == "" {
|
||||
base = "ssh://" + ForgeAlias + "/"
|
||||
}
|
||||
return base + repository + ".git"
|
||||
}
|
||||
|
||||
// merge makes the merge preview of the head onto base and writes the answer into res.
|
||||
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
|
||||
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Base = mr.Base
|
||||
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
|
||||
if res.Merge == "" {
|
||||
return nil
|
||||
}
|
||||
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, nil, tree, res.Merge)
|
||||
return err
|
||||
}
|
||||
|
||||
type siblingArg struct{ repository, commit string }
|
||||
|
||||
func siblingsArg(v any) ([]siblingArg, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("beside is a list of {repository, commit}")
|
||||
}
|
||||
var out []siblingArg
|
||||
seen := map[string]bool{}
|
||||
for _, e := range list {
|
||||
o, _ := e.(map[string]any)
|
||||
repository, _ := o["repository"].(string)
|
||||
commit, _ := o["commit"].(string)
|
||||
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
||||
return nil, fmt.Errorf("beside: %q is not a repository, <owner>/<name>", repository)
|
||||
}
|
||||
if !commitOK.MatchString(commit) {
|
||||
return nil, fmt.Errorf("beside: %q is not a full commit (40 hex digits)", commit)
|
||||
}
|
||||
name := repository[strings.Index(repository, "/")+1:]
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("beside: two repositories are named %s", name)
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, siblingArg{repository, commit})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BesideDir is where a workspace's siblings are placed, and what MESH_CHECK_BESIDE names.
|
||||
func (m *Module) besideDir(workspace string) string {
|
||||
return filepath.Join(m.Work, workspace, "beside")
|
||||
}
|
||||
|
||||
// placeSiblings fetches each sibling into beside/<name> of the workspace root, as a plain directory. A symlink anywhere
|
||||
// in a placed tree fails the step naming it: the mesh creates none, and a test must not be led out of its sibling.
|
||||
func (m *Module) placeSiblings(ctx context.Context, root string, siblings []siblingArg) ([]Sibling, error) {
|
||||
var placed []Sibling
|
||||
for _, s := range siblings {
|
||||
name := s.repository[strings.Index(s.repository, "/")+1:]
|
||||
dir := filepath.Join(root, "beside", name)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, "", m.FetchOptions...); err != nil {
|
||||
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
|
||||
}
|
||||
if link := firstSymlink(dir); link != "" {
|
||||
return nil, fmt.Errorf("beside %s holds a symlink, %s", s.repository, link)
|
||||
}
|
||||
placed = append(placed, Sibling{Repository: s.repository, Commit: s.commit, Dir: name})
|
||||
}
|
||||
sort.Slice(placed, func(i, j int) bool { return placed[i].Dir < placed[j].Dir })
|
||||
return placed, nil
|
||||
}
|
||||
|
||||
func firstSymlink(dir string) string {
|
||||
var found string
|
||||
filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err == nil && d.Type()&fs.ModeSymlink != 0 && found == "" {
|
||||
found, _ = filepath.Rel(dir, p)
|
||||
return fs.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The fetch step's merge and siblings on real git repositories and the real go (issues 1675 and 1677): they start
|
||||
// processes, so they carry the tag; preview_test.go proves the commands with a fake runner.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func put(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// forge is a directory of bare repositories <owner>/<name>.git standing in for the forge, and a module fetching from it.
|
||||
type forge struct {
|
||||
t *testing.T
|
||||
root string
|
||||
}
|
||||
|
||||
func newForge(t *testing.T) *forge { return &forge{t: t, root: t.TempDir()} }
|
||||
|
||||
// repo makes the repository owner/name with the files on main and answers its work directory and the commit.
|
||||
func (f *forge) repo(name string, files map[string]string) (work, commit string) {
|
||||
bare := filepath.Join(f.root, "forge", name+".git")
|
||||
work = filepath.Join(f.root, "work", name)
|
||||
os.MkdirAll(bare, 0o755)
|
||||
os.MkdirAll(work, 0o755)
|
||||
git(f.t, bare, "init", "-q", "--bare")
|
||||
git(f.t, work, "init", "-q", "-b", "main")
|
||||
for n, c := range files {
|
||||
put(f.t, work, n, c)
|
||||
}
|
||||
git(f.t, work, "add", ".")
|
||||
git(f.t, work, "commit", "-q", "-m", "one")
|
||||
git(f.t, work, "push", "-q", bare, "HEAD:refs/heads/main")
|
||||
return work, git(f.t, work, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// commit makes a commit off `from` in the repository's work directory and publishes it as the pull request's head.
|
||||
func (f *forge) commit(name, work, from string, files map[string]string, ref string) string {
|
||||
git(f.t, work, "checkout", "-q", "-B", "b"+ref[len(ref)-3:], from)
|
||||
for n, c := range files {
|
||||
put(f.t, work, n, c)
|
||||
}
|
||||
git(f.t, work, "add", ".")
|
||||
git(f.t, work, "commit", "-q", "-m", "change")
|
||||
sha := git(f.t, work, "rev-parse", "HEAD")
|
||||
git(f.t, work, "push", "-q", "-f", filepath.Join(f.root, "forge", name+".git"), "HEAD:"+ref)
|
||||
return sha
|
||||
}
|
||||
|
||||
func (f *forge) module(t *testing.T) (*Module, *keptStates) {
|
||||
m, states := fetchModule(t, &fakeStep{})
|
||||
m.Run = StepRunner(m.Home)
|
||||
m.RemoteBase = "file://" + filepath.Join(f.root, "forge") + "/"
|
||||
m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()}
|
||||
return m, states
|
||||
}
|
||||
|
||||
// waitLong is waitEnd for a step that runs the real go, whose first build in a cold cache takes longer than 3 s.
|
||||
func waitLong(t *testing.T, s *keptStates) delivery.Step {
|
||||
t.Helper()
|
||||
select {
|
||||
case key := <-s.ended:
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v := s.steps[key]
|
||||
return v[len(v)-1]
|
||||
case <-time.After(2 * time.Minute):
|
||||
t.Fatal("the step never ended on its key")
|
||||
return delivery.Step{}
|
||||
}
|
||||
}
|
||||
|
||||
func fetched(t *testing.T, m *Module, states *keptStates, args map[string]any) (FetchResult, delivery.Step) {
|
||||
t.Helper()
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
var res FetchResult
|
||||
if st.State == delivery.StepPassed {
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return res, st
|
||||
}
|
||||
|
||||
var baseFiles = map[string]string{
|
||||
"go.mod": "module fixture/m\n\ngo 1.26\n",
|
||||
"a.txt": "one\ntwo\nthree\n",
|
||||
"modules/m/module.json": "{\"name\":\"m\"}\n",
|
||||
"modules/m/go.sum": "",
|
||||
"modules/m/m.txt": "m\n",
|
||||
"modules/other/other.txt": "o\n",
|
||||
}
|
||||
|
||||
// 1675/AC1 and AC2 on real git: a clean merge answers the merge commit, the changed paths (the trunk's change is not
|
||||
// among them) and the tree of each module directory, which equals git's own; a conflict is a passed step naming the files,
|
||||
// leaving the workspace at the trunk commit; and no base is today's fetch.
|
||||
func TestRealFetchMergesConflictsAndPlainlyFetches(t *testing.T) {
|
||||
f := newForge(t)
|
||||
work, fork := f.repo("o/r", baseFiles)
|
||||
trunk := f.commit("o/r", work, fork, map[string]string{"modules/other/other.txt": "trunk\n", "a.txt": "one\ntwo\nTRUNK\n"}, "refs/heads/main")
|
||||
clean := f.commit("o/r", work, fork, map[string]string{"modules/m/m.txt": "head\n"}, "refs/pull/1/head")
|
||||
clash := f.commit("o/r", work, fork, map[string]string{"a.txt": "one\ntwo\nHEAD\n"}, "refs/pull/2/head")
|
||||
m, states := f.module(t)
|
||||
|
||||
res, st := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head", "base": trunk})
|
||||
if st.State != delivery.StepPassed || res.Merge == "" || res.Base != trunk || res.Commit != clean || len(res.Conflicts) != 0 {
|
||||
t.Fatalf("clean: %s %s", st.State, st.Result)
|
||||
}
|
||||
if strings.Join(res.Changed, ",") != "modules/m/m.txt" {
|
||||
t.Errorf("changed = %v, want the head's own change alone", res.Changed)
|
||||
}
|
||||
tree := res.Trees["modules/m"]
|
||||
if want := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", res.Merge+":modules/m"); tree.Tree != want {
|
||||
t.Errorf("tree = %s, want %s", tree.Tree, want)
|
||||
}
|
||||
if tree.Manifest != sha256Hex([]byte("{\"name\":\"m\"}\n")) || tree.Locks["go.sum"] != sha256Hex(nil) {
|
||||
t.Errorf("manifest and locks = %+v", tree)
|
||||
}
|
||||
if got := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", "HEAD"); got != res.Merge {
|
||||
t.Errorf("the workspace is at %s, want the merge %s", got, res.Merge)
|
||||
}
|
||||
|
||||
res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clash, "ref": "refs/pull/2/head", "base": trunk})
|
||||
if st.State != delivery.StepPassed || res.Merge != "" || strings.Join(res.Conflicts, ",") != "a.txt" {
|
||||
t.Fatalf("conflict: %s %s", st.State, st.Result)
|
||||
}
|
||||
if got := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", "HEAD"); got != trunk {
|
||||
t.Errorf("after a conflict the workspace is at %s, want the trunk %s", got, trunk)
|
||||
}
|
||||
|
||||
res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head"})
|
||||
if st.State != delivery.StepPassed || res.Merge != "" || res.Base != "" || res.Trees != nil || len(res.Modules) != 1 {
|
||||
t.Errorf("no base: %s %s", st.State, st.Result)
|
||||
}
|
||||
}
|
||||
|
||||
// 1677/AC1 on the real go: the unit level of a repository whose test reads MESH_CHECK_BESIDE fails loudly without the
|
||||
// sibling and passes with it; the sibling is a plain directory with no symlink in it; and a sibling that holds a
|
||||
// symlink fails the fetch naming it.
|
||||
func TestRealTestsReadTheSiblingBesideTheTree(t *testing.T) {
|
||||
f := newForge(t)
|
||||
files := map[string]string{}
|
||||
for _, n := range []string{"go.mod", "beside.go", "beside_test.go"} {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "repos", "beside", n))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files[n] = string(raw)
|
||||
}
|
||||
_, head := f.repo("o/r", files)
|
||||
_, sibling := f.repo("o/sibling", map[string]string{"data.txt": "from the sibling\n"})
|
||||
m, states := f.module(t)
|
||||
|
||||
without, _ := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head})
|
||||
if _, err := m.testToolRun(t, without.Workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res TestResult
|
||||
st := waitLong(t, states)
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if st.State != delivery.StepPassed || res.Passed || len(res.Failed) != 1 || !strings.Contains(res.Failed[0].Output, "MESH_CHECK_BESIDE is not set") {
|
||||
t.Fatalf("without the sibling: %s %s", st.State, st.Result)
|
||||
}
|
||||
|
||||
with, st2 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head,
|
||||
"beside": []any{map[string]any{"repository": "o/sibling", "commit": sibling}}})
|
||||
if st2.State != delivery.StepPassed || len(with.Beside) != 1 {
|
||||
t.Fatalf("with the sibling: %s %s", st2.State, st2.Result)
|
||||
}
|
||||
m.testToolRun(t, with.Workspace)
|
||||
res = TestResult{}
|
||||
st = waitLong(t, states)
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if !res.Passed {
|
||||
t.Errorf("with the sibling the level failed: %s", st.Result)
|
||||
}
|
||||
if link := firstSymlink(filepath.Join(m.Work, with.Workspace, "beside")); link != "" {
|
||||
t.Errorf("the placed tree holds a symlink, %s", link)
|
||||
}
|
||||
|
||||
_, bad := f.repo("o/linked", map[string]string{"data.txt": "x\n"})
|
||||
work := filepath.Join(f.root, "work", "o/linked")
|
||||
if err := os.Symlink("/etc", filepath.Join(work, "etc-link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git(t, work, "add", ".")
|
||||
git(t, work, "commit", "-q", "-m", "link")
|
||||
bad = git(t, work, "rev-parse", "HEAD")
|
||||
git(t, work, "push", "-q", "-f", filepath.Join(f.root, "forge", "o/linked.git"), "HEAD:refs/heads/main")
|
||||
_, st3 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head,
|
||||
"beside": []any{map[string]any{"repository": "o/linked", "commit": bad}}})
|
||||
if st3.State != delivery.StepFailed || !strings.Contains(st3.Reason, "etc-link") {
|
||||
t.Errorf("a sibling with a symlink: %s %s", st3.State, st3.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const (
|
||||
baseSHA = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
mergeSHA = "fedcba9876543210fedcba9876543210fedcba98"
|
||||
treeSHA = "1111111111111111111111111111111111111111"
|
||||
)
|
||||
|
||||
// previewRules answers the merge's commands as git does: the merge commit, the changed paths, the manifests, the trees.
|
||||
func previewRules(conflict bool) func(string) (forgekey.Ran, bool) {
|
||||
merged := false
|
||||
return func(line string) (forgekey.Ran, bool) {
|
||||
switch {
|
||||
case strings.Contains(line, "--is-shallow-repository"):
|
||||
return forgekey.Ran{Stdout: "true\n"}, true
|
||||
case strings.Contains(line, " merge --no-ff"):
|
||||
if conflict {
|
||||
return forgekey.Ran{Status: 1, Stderr: "CONFLICT (content): Merge conflict in a.go\n"}, true
|
||||
}
|
||||
merged = true
|
||||
return forgekey.Ran{}, true
|
||||
case strings.Contains(line, "diff --name-only --diff-filter=U"):
|
||||
return forgekey.Ran{Stdout: "a.go\nsub/b.go\n"}, true
|
||||
case strings.Contains(line, "diff --name-only --no-renames"):
|
||||
return forgekey.Ran{Stdout: "a.go\nmodules/m/main.go\n"}, true
|
||||
case strings.Contains(line, " rev-parse HEAD") && merged:
|
||||
return forgekey.Ran{Stdout: mergeSHA + "\n"}, true
|
||||
case strings.Contains(line, " ls-files ") && strings.Contains(line, "module.json"):
|
||||
return forgekey.Ran{Stdout: "modules/m/module.json\n"}, true
|
||||
case strings.Contains(line, " rev-parse "+mergeSHA+":modules/m"):
|
||||
return forgekey.Ran{Stdout: treeSHA + "\n"}, true
|
||||
}
|
||||
return forgekey.Ran{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// 1675/AC1: with a base the fetch merges the head onto it with the SDK's merge (the identity and flags of the check
|
||||
// merge, hooks off), and answers base, merge commit, changed paths and, per module directory, tree, manifest hash and lock hashes.
|
||||
func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(false)}
|
||||
m, states := fetchModule(t, f)
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
// the workspace's module.json and go.sum are on disk once the fake checks the head out
|
||||
for i, a := range args {
|
||||
if a == "-C" && len(args) > i+1 && strings.Contains(strings.Join(args, " "), " checkout ") {
|
||||
os.MkdirAll(filepath.Join(args[i+1], "modules", "m"), 0o755)
|
||||
os.WriteFile(filepath.Join(args[i+1], "modules", "m", "module.json"), []byte("{}\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(args[i+1], "modules", "m", "go.sum"), []byte("sum\n"), 0o644)
|
||||
}
|
||||
}
|
||||
return f.run(ctx, env, name, args...)
|
||||
}
|
||||
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA}
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
var res FetchResult
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Base != baseSHA || res.Merge != mergeSHA || res.Commit != commitSHA || len(res.Conflicts) != 0 ||
|
||||
strings.Join(res.Changed, ",") != "a.go,modules/m/main.go" {
|
||||
t.Errorf("result = %s", st.Result)
|
||||
}
|
||||
mt, ok := res.Trees["modules/m"]
|
||||
if !ok || mt.Tree != treeSHA || mt.Manifest != sha256Hex([]byte("{}\n")) || mt.Locks["go.sum"] != sha256Hex([]byte("sum\n")) {
|
||||
t.Errorf("trees = %+v", res.Trees)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
for _, want := range []string{"fetch --no-tags --unshallow origin " + baseSHA, "checkout -q --detach " + baseSHA,
|
||||
"core.hooksPath=/dev/null", "merge --no-ff --no-edit --no-verify"} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Errorf("git was not asked %q:\n%s", want, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1675/AC2: a conflict is an answer: the step ends passed, the files are named, no merge commit, nothing downloaded.
|
||||
func TestFetchWithAConflictEndsPassedAndNamesTheFiles(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(true)}
|
||||
m, states := fetchModule(t, f)
|
||||
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA}
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("a conflict ended the step %s: %s", st.State, st.Reason)
|
||||
}
|
||||
var res FetchResult
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if res.Merge != "" || res.Base != baseSHA || strings.Join(res.Conflicts, ",") != "a.go,sub/b.go" || len(res.Changed) != 0 {
|
||||
t.Errorf("result = %s", st.Result)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
if !strings.Contains(lines, "merge --abort") || strings.Contains(lines, "mod download") {
|
||||
t.Errorf("the merge was not undone, or modules were downloaded:\n%s", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1675/AC3: no base is today's fetch: no merge, no extra command, and the answer carries none of the preview.
|
||||
func TestFetchWithoutABaseIsTodaysFetch(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n"}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(fetchArgs)
|
||||
st := states.waitEnd(t)
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
if strings.Contains(lines, "merge") || strings.Contains(lines, "unshallow") {
|
||||
t.Errorf("a fetch without a base merged:\n%s", lines)
|
||||
}
|
||||
for _, key := range []string{"base", "merge", "conflicts", "changed", "trees", "beside"} {
|
||||
if strings.Contains(string(st.Result), `"`+key+`"`) {
|
||||
t.Errorf("the answer carries %q without a base: %s", key, st.Result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABaseThatIsNotACommitIsRefusedBeforeAnythingRuns(t *testing.T) {
|
||||
f := &fakeStep{}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": "main"})
|
||||
if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 {
|
||||
t.Errorf("the step ended %s after %d commands", st.State, len(f.lines()))
|
||||
}
|
||||
}
|
||||
|
||||
// 1677/AC1: each sibling is fetched into beside/<name> of the workspace, and the tests of the workspace are given
|
||||
// MESH_CHECK_BESIDE naming that directory, at the unit level; a workspace without siblings is given none.
|
||||
func TestSiblingsArePlacedBesideAndTheUnitLevelIsGivenTheirDirectory(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n"}
|
||||
m, states := fetchModule(t, f)
|
||||
args := map[string]any{"repository": "novox/mesh-controller", "commit": commitSHA, "beside": []any{
|
||||
map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA},
|
||||
map[string]any{"repository": "novox/mesh-host", "commit": commitSHA}}}
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
var res FetchResult
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
if len(res.Beside) != 2 || res.Beside[0].Dir != "mesh-catalog" || res.Beside[1].Dir != "mesh-host" {
|
||||
t.Fatalf("result = %s", st.Result)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
for _, d := range []string{"beside/mesh-catalog", "beside/mesh-host"} {
|
||||
if !strings.Contains(lines, filepath.Join(m.Work, res.Workspace, d)+" init") && !strings.Contains(lines, "-C "+filepath.Join(m.Work, res.Workspace, d)) {
|
||||
t.Errorf("nothing was fetched into %s:\n%s", d, lines)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(lines, "remote add origin ssh://mesh-forge/novox/mesh-catalog.git") {
|
||||
t.Errorf("the sibling was not fetched over ssh:\n%s", lines)
|
||||
}
|
||||
|
||||
calls := &[]cmdRecord{}
|
||||
m.Run = capturedRunner(t, "failing", calls)
|
||||
if err := os.MkdirAll(filepath.Join(m.Work, res.Workspace, "tree"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.testToolRun(t, res.Workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states.waitEnd(t)
|
||||
want := "MESH_CHECK_BESIDE=" + filepath.Join(m.Work, res.Workspace, "beside")
|
||||
if !envHas(*calls, "go", want) {
|
||||
t.Errorf("no go command was given %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) testToolRun(t *testing.T, ws string) (any, error) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name == "build_go_test" {
|
||||
return tool.Run(map[string]any{"workspace": ws, "level": "unit"})
|
||||
}
|
||||
}
|
||||
t.Fatal("no tool build_go_test")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func envHas(calls []cmdRecord, name, want string) bool {
|
||||
for _, c := range calls {
|
||||
if strings.HasPrefix(c.line, name+" ") {
|
||||
for _, e := range c.env {
|
||||
if e == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAWorkspaceWithoutSiblingsIsGivenNoBesideVariable(t *testing.T) {
|
||||
m, states, ws, calls := workspaceFor(t, "failing")
|
||||
runUnit(t, m, states, ws)
|
||||
for _, c := range *calls {
|
||||
for _, e := range c.env {
|
||||
if strings.HasPrefix(e, "MESH_CHECK_BESIDE=") {
|
||||
t.Errorf("%s was given %s", c.line, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiblingsThatAreNotRepositoriesOrCommitsAreRefused(t *testing.T) {
|
||||
for name, beside := range map[string]any{
|
||||
"not a list": "x",
|
||||
"bad repository": []any{map[string]any{"repository": "../x/y", "commit": baseSHA}},
|
||||
"bad commit": []any{map[string]any{"repository": "novox/a", "commit": "main"}},
|
||||
"twice": []any{map[string]any{"repository": "novox/a", "commit": baseSHA}, map[string]any{"repository": "other/a", "commit": baseSHA}},
|
||||
} {
|
||||
f := &fakeStep{}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "beside": beside})
|
||||
if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 {
|
||||
t.Errorf("%s: the step ended %s after %d commands", name, st.State, len(f.lines()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1288/AC5 (the checker's contract): an import path runs only in the go.mod directory whose module holds it; a
|
||||
// relative pattern runs in every directory; no pattern is ./... everywhere.
|
||||
func TestPackagesRunOnlyInTheModuleThatHoldsThem(t *testing.T) {
|
||||
tree := t.TempDir()
|
||||
for dir, mod := range map[string]string{".": "example.org/root", "tools": "example.org/root/tools", "other": "example.org/other"} {
|
||||
os.MkdirAll(filepath.Join(tree, dir), 0o755)
|
||||
os.WriteFile(filepath.Join(tree, dir, "go.mod"), []byte("module "+mod+"\n\ngo 1.26\n"), 0o644)
|
||||
}
|
||||
dirs := []string{".", "other", "tools"}
|
||||
got, err := packagesByDir(tree, dirs, []string{"example.org/root/a", "example.org/root/tools/b", "example.org/other"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{".": "example.org/root/a", "tools": "example.org/root/tools/b", "other": "example.org/other"}
|
||||
for d, w := range want {
|
||||
if strings.Join(got[d], ",") != w {
|
||||
t.Errorf("%s runs %v, want %s", d, got[d], w)
|
||||
}
|
||||
}
|
||||
got, _ = packagesByDir(tree, dirs, []string{"./x/..."})
|
||||
if len(got["."]) != 1 || len(got["tools"]) != 1 || len(got["other"]) != 1 {
|
||||
t.Errorf("a relative pattern = %v", got)
|
||||
}
|
||||
got, _ = packagesByDir(tree, dirs, nil)
|
||||
if got["tools"][0] != "./..." {
|
||||
t.Errorf("no pattern = %v", got)
|
||||
}
|
||||
if _, err := packagesByDir(tree, dirs, []string{"example.org/nowhere/x"}); err == nil || !strings.Contains(err.Error(), "example.org/nowhere/x") {
|
||||
t.Errorf("a package no module holds: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
|
||||
|
||||
// 1677/AC (integration level): the integration level's go test is given MESH_CHECK_BESIDE when the workspace has siblings
|
||||
// beside it, with the store's and the bus's addresses, and is given none when it has not.
|
||||
func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced(t *testing.T) {
|
||||
for _, siblings := range []bool{true, false} {
|
||||
f := newIntegFake(t, "slow")
|
||||
m, states := integModule(t, f)
|
||||
beside := filepath.Join(m.Work, "ws1", "beside")
|
||||
if siblings {
|
||||
if err := os.MkdirAll(filepath.Join(beside, "mesh-catalog"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, st := runLevelTool(t, m, states, "integration"); st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
tested := 0
|
||||
for _, c := range f.calls {
|
||||
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") {
|
||||
continue
|
||||
}
|
||||
tested++
|
||||
got := ""
|
||||
hasNATS := false
|
||||
for _, e := range c.env {
|
||||
if strings.HasPrefix(e, "MESH_CHECK_BESIDE=") {
|
||||
got = strings.TrimPrefix(e, "MESH_CHECK_BESIDE=")
|
||||
}
|
||||
hasNATS = hasNATS || strings.HasPrefix(e, "MESH_TEST_NATS=")
|
||||
}
|
||||
want := ""
|
||||
if siblings {
|
||||
want = beside
|
||||
}
|
||||
if got != want || !hasNATS {
|
||||
t.Errorf("siblings %v: %s was given MESH_CHECK_BESIDE=%q (want %q), MESH_TEST_NATS present %v", siblings, c.line, got, want, hasNATS)
|
||||
}
|
||||
}
|
||||
if tested == 0 {
|
||||
t.Errorf("siblings %v: no integration go test ran", siblings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,13 +65,23 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
switch level {
|
||||
case LevelUnit:
|
||||
return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}})
|
||||
return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(workspace)})
|
||||
case LevelIntegration:
|
||||
return m.integration(ctx, workspace, tree, packages)
|
||||
}
|
||||
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
|
||||
}
|
||||
|
||||
// besideEnv is MESH_CHECK_BESIDE, naming the directory build_go_fetch placed the workspace's siblings in (ADR 0484
|
||||
// clause 4), when it placed any; the tests that read other repositories look there (mesh-sdk go/internal/beside).
|
||||
func (m *Module) besideEnv(workspace string) []string {
|
||||
dir := m.besideDir(workspace)
|
||||
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||
return nil
|
||||
}
|
||||
return []string{"MESH_CHECK_BESIDE=" + dir}
|
||||
}
|
||||
|
||||
func packagesArg(v any) ([]string, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
@@ -115,12 +125,16 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
return nil, err
|
||||
}
|
||||
res := &TestResult{Level: o.level, Packages: []PackageResult{}, Setup: o.setup.Seconds(), Store: o.store, Bus: o.bus}
|
||||
packages := o.packages
|
||||
if len(packages) == 0 {
|
||||
packages = []string{"./..."}
|
||||
byDir, err := packagesByDir(tree, dirs, o.packages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
began := m.clock()
|
||||
for _, d := range dirs {
|
||||
packages := byDir[d]
|
||||
if len(packages) == 0 {
|
||||
continue // no package asked for lies in this module
|
||||
}
|
||||
args := []string{"-C", filepath.Join(tree, d), "test", "-json", "-count=1", "-timeout", testTimeout}
|
||||
if len(o.tags) > 0 {
|
||||
args = append(args, "-tags", strings.Join(o.tags, ","))
|
||||
@@ -141,3 +155,50 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
res.judge(o.bounds)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// packagesByDir says which patterns run in which go.mod directory. A relative pattern (./...) runs in every directory, as
|
||||
// it always did; an import path, as build_go_digest keys its packages, runs only in the directory whose module holds it
|
||||
// (the longest module path that is the path or its prefix), as another module's path would fail the step. No pattern
|
||||
// means ./... everywhere. An import path no module of the tree holds is an error that names it.
|
||||
func packagesByDir(tree string, dirs, patterns []string) (map[string][]string, error) {
|
||||
out := map[string][]string{}
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{"./..."}
|
||||
}
|
||||
modules := map[string]string{}
|
||||
for _, d := range dirs {
|
||||
if raw, err := os.ReadFile(filepath.Join(tree, d, "go.mod")); err == nil {
|
||||
modules[d] = modulePath(string(raw))
|
||||
}
|
||||
}
|
||||
for _, p := range patterns {
|
||||
if strings.HasPrefix(p, "./") || p == "." {
|
||||
for _, d := range dirs {
|
||||
out[d] = append(out[d], p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
owner, best := "", -1
|
||||
for _, d := range dirs {
|
||||
mp := modules[d]
|
||||
if mp != "" && (p == mp || strings.HasPrefix(p, mp+"/")) && len(mp) > best {
|
||||
owner, best = d, len(mp)
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return nil, fmt.Errorf("no module of the workspace holds the package %s", p)
|
||||
}
|
||||
out[owner] = append(out[owner], p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// modulePath is the module path a go.mod declares, "" when it declares none.
|
||||
func modulePath(gomod string) string {
|
||||
for _, l := range strings.Split(gomod, "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "module" {
|
||||
return strings.Trim(f[1], `"`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package beside
|
||||
@@ -0,0 +1,22 @@
|
||||
package beside
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A test of a repository that reads another one, as the controller's, the node-engine's, the SDK's and the catalogue's do.
|
||||
func TestReadsTheSiblingBesideIt(t *testing.T) {
|
||||
root := os.Getenv("MESH_CHECK_BESIDE")
|
||||
if root == "" {
|
||||
t.Fatal("the sibling is not beside this check: MESH_CHECK_BESIDE is not set")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "sibling", "data.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "from the sibling\n" {
|
||||
t.Fatalf("data.txt = %q", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module fixture/beside
|
||||
|
||||
go 1.26
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5 h1:1BiPKRPTYpgex8rSHWT9az0BteOafGWRDzd/Ba6GAcA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/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=
|
||||
|
||||
@@ -36,6 +36,8 @@ const sdkPackage = "@novox/mesh-sdk"
|
||||
// WorkspaceAge is how long a workspace is kept.
|
||||
const WorkspaceAge = 24 * time.Hour
|
||||
|
||||
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
var repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
// FetchResult is what a passed fetch step says.
|
||||
@@ -48,6 +50,15 @@ type FetchResult struct {
|
||||
// Refused are the bundles whose packages were not installed, each with why (a module naming packages beyond the SDK
|
||||
// with no package-lock.json). The fetch passes; a build of such a bundle fails naming it (issue 1459).
|
||||
Refused []Refusal `json:"refused"`
|
||||
// The merge preview (ADR 0484 clause 3), only when the call gave a base, as build_go_fetch answers it: the trunk
|
||||
// commit, the merge commit of the head onto it, the paths the merge changes and the tree of each module directory.
|
||||
// A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is
|
||||
// left at the trunk commit with nothing installed.
|
||||
Base string `json:"base,omitempty"`
|
||||
Merge string `json:"merge,omitempty"`
|
||||
Conflicts []string `json:"conflicts,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
|
||||
}
|
||||
|
||||
// Refusal is a module a fetch did not install the packages of.
|
||||
@@ -68,6 +79,7 @@ func (m *Module) fetchTool() StepTool {
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
||||
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
||||
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
|
||||
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
|
||||
},
|
||||
"required": []string{"repository", "commit"},
|
||||
},
|
||||
@@ -80,6 +92,10 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
repository, _ := args["repository"].(string)
|
||||
commit, _ := args["commit"].(string)
|
||||
ref, _ := args["ref"].(string)
|
||||
base, _ := args["base"].(string)
|
||||
if base != "" && !commitOK.MatchString(base) {
|
||||
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
|
||||
}
|
||||
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
||||
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
|
||||
}
|
||||
@@ -94,15 +110,23 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
gitRun := func(ctx context.Context, env []string, name string, a ...string) forgekey.Ran {
|
||||
return m.Run(ctx, append(m.baseEnv(), env...), name, a...)
|
||||
}
|
||||
remote := "ssh://" + ForgeAlias + "/" + repository + ".git"
|
||||
if err := buildstep.FetchCommit(ctx, gitRun, tree, remote, commit, ref); err != nil {
|
||||
remote := m.remote(repository)
|
||||
if err := buildstep.FetchCommit(ctx, gitRun, tree, remote, commit, ref, m.FetchOptions...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := FetchResult{Workspace: id, Commit: commit, Modules: []string{}, Installed: []string{}, Refused: []Refusal{}}
|
||||
if base != "" {
|
||||
if err := m.merge(ctx, gitRun, &res, tree, remote, base, commit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Merge == "" {
|
||||
return res, nil // a conflict: the tree is at the trunk, nothing of the head is installed
|
||||
}
|
||||
}
|
||||
dirs, err := m.typeScriptModules(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := FetchResult{Workspace: id, Commit: commit, Modules: []string{}, Installed: []string{}, Refused: []Refusal{}}
|
||||
for _, d := range dirs {
|
||||
res.Modules = append(res.Modules, d)
|
||||
did, refused, err := m.installOwn(ctx, root, filepath.Join(tree, filepath.FromSlash(d)), d)
|
||||
|
||||
@@ -48,7 +48,7 @@ func (f *fakeMachine) run(_ context.Context, env []string, name string, args ...
|
||||
}
|
||||
} else if name == "git" {
|
||||
for _, a := range args {
|
||||
if slices.Contains([]string{"init", "remote", "fetch", "checkout", "rev-parse", "ls-files"}, a) {
|
||||
if slices.Contains([]string{"init", "remote", "fetch", "checkout", "rev-parse", "ls-files", "merge", "diff"}, a) {
|
||||
key = "git " + a
|
||||
break
|
||||
}
|
||||
|
||||
@@ -31,9 +31,13 @@ type Module struct {
|
||||
Toolchain *Toolchain
|
||||
// Work is the work directory, outside the home: a workspace is <Work>/<step id>/tree. Run runs the commands of a
|
||||
// step (a fake in a test). Clock is the time the steps are timed by.
|
||||
Work string
|
||||
Run forgekey.Runner
|
||||
Clock func() time.Time
|
||||
Work string
|
||||
Run forgekey.Runner
|
||||
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
|
||||
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
|
||||
RemoteBase string
|
||||
FetchOptions []buildstep.Option
|
||||
Clock func() time.Time
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
// The merge preview of a fetch (novox/hq ADR 0484 clause 3; issue 1676), as build_go_fetch answers it. The merge itself
|
||||
// is the SDK's (buildstep.MergeInto), the one every build module's fetch step makes.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
|
||||
func (m *Module) remote(repository string) string {
|
||||
base := m.RemoteBase
|
||||
if base == "" {
|
||||
base = "ssh://" + ForgeAlias + "/"
|
||||
}
|
||||
return base + repository + ".git"
|
||||
}
|
||||
|
||||
// merge makes the merge preview of the head onto base and writes the answer into res.
|
||||
func (m *Module) merge(ctx context.Context, run forgekey.Runner, res *FetchResult, tree, remote, base, commit string) error {
|
||||
mr, err := buildstep.MergeInto(ctx, run, tree, remote, base, commit, m.FetchOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Base = mr.Base
|
||||
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
|
||||
if res.Merge == "" {
|
||||
return nil
|
||||
}
|
||||
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, m.baseEnv(), tree, res.Merge)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The fetch step's merge on real git repositories (issue 1676); preview_test.go proves the commands with a fake runner.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func TestRealFetchMergesAConflictAndAPlainFetch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bare, work := filepath.Join(root, "forge", "o", "r.git"), filepath.Join(root, "work")
|
||||
os.MkdirAll(bare, 0o755)
|
||||
os.MkdirAll(work, 0o755)
|
||||
git(t, bare, "init", "-q", "--bare")
|
||||
git(t, work, "init", "-q", "-b", "main")
|
||||
files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"}}`)
|
||||
files["a.txt"] = "one\ntwo\nthree\n"
|
||||
for n, c := range files {
|
||||
write(t, filepath.Join(work, n), c, 0o644)
|
||||
}
|
||||
git(t, work, "add", ".")
|
||||
git(t, work, "commit", "-q", "-m", "fork")
|
||||
fork := git(t, work, "rev-parse", "HEAD")
|
||||
change := func(from, ref string, f map[string]string) string {
|
||||
git(t, work, "checkout", "-q", "-B", "tmp", from)
|
||||
for n, c := range f {
|
||||
write(t, filepath.Join(work, n), c, 0o644)
|
||||
}
|
||||
git(t, work, "add", ".")
|
||||
git(t, work, "commit", "-q", "-m", "c")
|
||||
git(t, work, "push", "-q", "-f", bare, "HEAD:"+ref)
|
||||
return git(t, work, "rev-parse", "HEAD")
|
||||
}
|
||||
trunk := change(fork, "refs/heads/main", map[string]string{"a.txt": "one\ntwo\nTRUNK\n"})
|
||||
clean := change(fork, "refs/pull/1/head", map[string]string{"modules/m/extra.ts": "export {}\n"})
|
||||
clash := change(fork, "refs/pull/2/head", map[string]string{"a.txt": "one\ntwo\nHEAD\n"})
|
||||
|
||||
m, _, _ := newMachine(t)
|
||||
m.Run = forgekey.ExecRunner
|
||||
m.RemoteBase = "file://" + filepath.Join(root, "forge") + "/"
|
||||
m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()}
|
||||
|
||||
out, err := m.fetch(context.Background(), map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head", "base": trunk})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
tree := filepath.Join(m.Work, r.Workspace, "tree")
|
||||
if r.Merge == "" || strings.Join(r.Changed, ",") != "modules/m/extra.ts" || len(r.Conflicts) != 0 || len(r.Modules) != 1 {
|
||||
t.Fatalf("clean: %+v", r)
|
||||
}
|
||||
if want := git(t, tree, "rev-parse", r.Merge+":modules/m"); r.Trees["modules/m"].Tree != want {
|
||||
t.Errorf("tree = %s, want %s", r.Trees["modules/m"].Tree, want)
|
||||
}
|
||||
if got := git(t, tree, "rev-parse", "HEAD"); got != r.Merge {
|
||||
t.Errorf("the workspace is at %s, want the merge %s", got, r.Merge)
|
||||
}
|
||||
|
||||
out, err = m.fetch(context.Background(), map[string]any{"repository": "o/r", "commit": clash, "ref": "refs/pull/2/head", "base": trunk})
|
||||
if err != nil {
|
||||
t.Fatalf("a conflict failed the step: %v", err)
|
||||
}
|
||||
r = out.(FetchResult)
|
||||
if r.Merge != "" || strings.Join(r.Conflicts, ",") != "a.txt" {
|
||||
t.Fatalf("conflict: %+v", r)
|
||||
}
|
||||
if got := git(t, filepath.Join(m.Work, r.Workspace, "tree"), "rev-parse", "HEAD"); got != trunk {
|
||||
t.Errorf("after a conflict the workspace is at %s, want the trunk %s", got, trunk)
|
||||
}
|
||||
|
||||
out, err = m.fetch(context.Background(), map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head"})
|
||||
if err != nil || out.(FetchResult).Merge != "" || out.(FetchResult).Trees != nil {
|
||||
t.Errorf("no base: %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const baseSHA = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
|
||||
func commandLines(f *fakeMachine) string {
|
||||
var out []string
|
||||
for _, c := range f.calls {
|
||||
out = append(out, joined(c))
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// 1676/AC1: with a base the fetch merges the head onto it through the SDK's merge (the check merge's identity and flags,
|
||||
// hooks off), and answers the base, the merge commit, the changed paths and the tree, manifest and locks of each module
|
||||
// directory; the TypeScript modules are installed from the merged tree.
|
||||
func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
|
||||
m, f := fetchMachine(t, typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"}}`), nil)
|
||||
f.answer["git diff"] = forgekey.Ran{Stdout: "modules/m/package.json\n"}
|
||||
args := map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "base": baseSHA}
|
||||
out, err := m.fetch(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
if r.Base != baseSHA || r.Merge != commitSHA || strings.Join(r.Changed, ",") != "modules/m/package.json" || len(r.Conflicts) != 0 {
|
||||
t.Errorf("result = %+v", r)
|
||||
}
|
||||
if mt, ok := r.Trees["modules/m"]; !ok || mt.Tree != commitSHA || mt.Manifest == "" {
|
||||
t.Errorf("trees = %+v", r.Trees)
|
||||
}
|
||||
if len(r.Modules) != 1 || r.Modules[0] != "modules/m" {
|
||||
t.Errorf("modules = %v", r.Modules)
|
||||
}
|
||||
for _, want := range []string{"fetch --no-tags origin " + baseSHA, "core.hooksPath=/dev/null", "merge --no-ff --no-edit --no-verify"} {
|
||||
if !strings.Contains(commandLines(f), want) {
|
||||
t.Errorf("git was not asked %q:\n%s", want, commandLines(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1676/AC2: a conflict is an answer: the step passes with the files named, no merge commit, and nothing installed.
|
||||
func TestFetchWithAConflictAnswersTheFilesAndInstallsNothing(t *testing.T) {
|
||||
files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6","lodash":"^4"}}`)
|
||||
files["modules/m/package-lock.json"] = `{"lockfileVersion":3}`
|
||||
m, f := fetchMachine(t, files, nil)
|
||||
f.answer["git merge"] = forgekey.Ran{Status: 1, Stderr: "CONFLICT\n"}
|
||||
f.answer["git diff"] = forgekey.Ran{Stdout: "a.ts\nsub/b.ts\n"}
|
||||
out, err := m.fetch(context.Background(), map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "base": baseSHA})
|
||||
if err != nil {
|
||||
t.Fatalf("a conflict failed the step: %v", err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
if r.Merge != "" || strings.Join(r.Conflicts, ",") != "a.ts,sub/b.ts" || len(r.Changed) != 0 || len(npmCalls(f)) != 0 {
|
||||
t.Errorf("result = %+v, npm calls %d", r, len(npmCalls(f)))
|
||||
}
|
||||
if !strings.Contains(commandLines(f), "merge --abort") {
|
||||
t.Errorf("the merge was not undone:\n%s", commandLines(f))
|
||||
}
|
||||
}
|
||||
|
||||
// 1676/AC3: no base is today's fetch: no merge command, and none of the preview in the answer.
|
||||
func TestFetchWithoutABaseMakesNoMerge(t *testing.T) {
|
||||
m, f := fetchMachine(t, typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"}}`), nil)
|
||||
out, err := m.fetch(context.Background(), fetchArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
if strings.Contains(commandLines(f), "merge") || strings.Contains(commandLines(f), "unshallow") || r.Base != "" || r.Merge != "" || r.Trees != nil {
|
||||
t.Errorf("a fetch without a base merged: %+v\n%s", r, commandLines(f))
|
||||
}
|
||||
}
|
||||
|
||||
func TestABaseThatIsNotACommitIsRefusedBeforeAnythingRuns(t *testing.T) {
|
||||
m, f := fetchMachine(t, nil, nil)
|
||||
if _, err := m.fetch(context.Background(), map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "base": "main"}); err == nil || len(f.calls) != 0 {
|
||||
t.Errorf("err = %v after %d commands", err, len(f.calls))
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ module meshbuildnode
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5 h1:1BiPKRPTYpgex8rSHWT9az0BteOafGWRDzd/Ba6GAcA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
+12
-5
@@ -21,17 +21,24 @@ They are those both workstations ran, with three changes:
|
||||
- **scrollback:** `saveLines` 10000 instead of 2000.
|
||||
|
||||
The palette (background, foreground and the sixteen colours) is today's, unchanged. So are the
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it. The bundle's
|
||||
`Resources()` is the one source of these values. The manifest test holds the manifest to it, and
|
||||
`xterm_colours` compares against it.
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it.
|
||||
|
||||
Every one of these values is a **config value** (ADR 0348; issue 1656) with today's value as its default:
|
||||
`face`, `size` (the predefined `font-size`, 6 to 72), `save-lines`, `term-name`, `render-font`, `bold-mode`,
|
||||
`always-bold-mode`, `allow-bold-fonts`, `background`, `foreground`, `color0` to `color15` (each `#rrggbb`),
|
||||
and the two clipboard bindings `copy-binding` and `paste-binding`. `files/xresources-normal` holds only
|
||||
`${config:<key>}` placeholders, so a machine changes its font size through mesh-config. The manifest test holds
|
||||
the file to the keys, and the file filled with the defaults to the text every machine had before.
|
||||
The tools keep no copy of the values: `xterm_font` and `xterm_colours` read the `XTerm*` lines of the rendered
|
||||
resources file `xorg` writes (`~/.config/xorg/xresources`) as the module's value, and say so when it is not there.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-terminal-emulator.open` | d | a terminal in the session: the login shell, or a command run through it (`$SHELL -lc`), in a directory (the home by default), with an optional title, and `hold` to keep it open. It starts as a transient unit of the account's service manager (`systemd-run --user`), so it outlives the call and every runtime restart |
|
||||
| `xterm_font` | r/a | the face and size in force and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's default |
|
||||
| `xterm_font` | r/a | the face and size in force beside the module's value (from the rendered resources), and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's value from the rendered resources |
|
||||
|
||||
## What it leaves found
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
// todaysResources is the file every machine had before the module took config values (novox/hq issue 1656).
|
||||
var todaysResources = strings.Join([]string{
|
||||
"! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they",
|
||||
"! outrank any `*name` resource of yours and touch no other X program.",
|
||||
"XTerm*termName: xterm-256color",
|
||||
"XTerm*saveLines: 10000",
|
||||
"XTerm*renderFont: true",
|
||||
"XTerm*faceName: JetBrainsMono Nerd Font",
|
||||
"XTerm*faceSize: 12",
|
||||
"XTerm*boldMode: false",
|
||||
"XTerm*alwaysBoldMode: false",
|
||||
"XTerm*allowBoldFonts: true",
|
||||
"XTerm*background: #000000",
|
||||
"XTerm*foreground: #a8a8a8",
|
||||
"XTerm*color0: #000000",
|
||||
"XTerm*color8: #666666",
|
||||
"XTerm*color1: #9e1828",
|
||||
"XTerm*color9: #bc5766",
|
||||
"XTerm*color2: #008800",
|
||||
"XTerm*color10: #61a171",
|
||||
"XTerm*color3: #d2bb4b",
|
||||
"XTerm*color11: #e7db52",
|
||||
"XTerm*color4: #414171",
|
||||
"XTerm*color12: #5085af",
|
||||
"XTerm*color5: #963c59",
|
||||
"XTerm*color13: #a97a99",
|
||||
"XTerm*color6: #418179",
|
||||
"XTerm*color14: #6ba4a4",
|
||||
"XTerm*color7: #bebebe",
|
||||
"XTerm*color15: #ffffff",
|
||||
"XTerm*VT100.Translations: #override \\n\\",
|
||||
" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\",
|
||||
" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
var placeholder = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
|
||||
// asText is a default as the controller writes a value into a file: a string as it is, a number without a
|
||||
// needless fraction, a boolean as true or false.
|
||||
func asText(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
}
|
||||
t.Fatalf("a default of no type: %v", v)
|
||||
return ""
|
||||
}
|
||||
|
||||
func template(t *testing.T) string {
|
||||
t.Helper()
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
return m.Shell[0].Code
|
||||
}
|
||||
|
||||
// 1656/AC1: every value the resources set is a config value with today's value as its default.
|
||||
func TestEveryResourceValueIsAConfigValueWithTodaysDefault(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
want := map[string]any{
|
||||
"face": "JetBrainsMono Nerd Font", "size": 12.0, "save-lines": 10000.0, "term-name": "xterm-256color",
|
||||
"render-font": true, "bold-mode": false, "always-bold-mode": false, "allow-bold-fonts": true,
|
||||
"background": "#000000", "foreground": "#a8a8a8",
|
||||
"color0": "#000000", "color1": "#9e1828", "color2": "#008800", "color3": "#d2bb4b",
|
||||
"color4": "#414171", "color5": "#963c59", "color6": "#418179", "color7": "#bebebe",
|
||||
"color8": "#666666", "color9": "#bc5766", "color10": "#61a171", "color11": "#e7db52",
|
||||
"color12": "#5085af", "color13": "#a97a99", "color14": "#6ba4a4", "color15": "#ffffff",
|
||||
"copy-binding": "Ctrl Shift ~Alt <Key> C", "paste-binding": "Ctrl Shift ~Alt <Key> V",
|
||||
}
|
||||
if len(m.Config) != len(want) {
|
||||
t.Errorf("declares %d config values, want %d", len(m.Config), len(want))
|
||||
}
|
||||
for key, def := range want {
|
||||
c, ok := m.Config[key]
|
||||
if !ok {
|
||||
t.Errorf("%s: not declared", key)
|
||||
continue
|
||||
}
|
||||
if c.Default != def || c.Required || strings.TrimSpace(c.Description) == "" {
|
||||
t.Errorf("%s: %+v, want default %v and a description", key, c, def)
|
||||
}
|
||||
wantType := map[bool]string{true: "string"}[true]
|
||||
switch def.(type) {
|
||||
case float64:
|
||||
wantType = "number"
|
||||
case bool:
|
||||
wantType = "boolean"
|
||||
}
|
||||
if c.Type != wantType {
|
||||
t.Errorf("%s: type %q, want %q", key, c.Type, wantType)
|
||||
}
|
||||
}
|
||||
size := m.Config["size"]
|
||||
if size.Predefined != "font-size" || size.Minimum == nil || *size.Minimum != 6 || size.Maximum == nil || *size.Maximum != 72 {
|
||||
t.Errorf("size: %+v, want the predefined font-size from 6 to 72", size)
|
||||
}
|
||||
names := []string{"background", "foreground"}
|
||||
for i := 0; i < 16; i++ {
|
||||
names = append(names, "color"+strconv.Itoa(i))
|
||||
}
|
||||
for _, key := range names {
|
||||
c := m.Config[key]
|
||||
re, err := regexp.Compile("^(?:" + c.Pattern + ")$")
|
||||
if err != nil || c.Pattern == "" {
|
||||
t.Errorf("%s: pattern %q", key, c.Pattern)
|
||||
continue
|
||||
}
|
||||
if !re.MatchString("#0a1B2c") || re.MatchString("red") || re.MatchString("#12345") || re.MatchString("#1234567") {
|
||||
t.Errorf("%s: pattern %q is not #rrggbb", key, c.Pattern)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"copy-binding", "paste-binding"} {
|
||||
re := regexp.MustCompile("^(?:" + m.Config[key].Pattern + ")$")
|
||||
if !re.MatchString("Ctrl Shift ~Alt <Key> C") || re.MatchString("a: b") || re.MatchString("a\nb") || re.MatchString(`a\`) {
|
||||
t.Errorf("%s: pattern %q lets a value end the resource", key, m.Config[key].Pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC2: no literal value is left in the resources, and every declared key is used.
|
||||
func TestEveryResourceValueIsAPlaceholderOfADeclaredKeyAndEveryKeyIsUsed(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
text := template(t)
|
||||
used := map[string]bool{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
switch {
|
||||
case l == "", strings.HasPrefix(l, "!"):
|
||||
continue
|
||||
case strings.HasPrefix(l, "XTerm*VT100.Translations: #override"):
|
||||
continue
|
||||
}
|
||||
_, value, ok := strings.Cut(l, ": ")
|
||||
if strings.HasPrefix(l, " ") {
|
||||
// a translation line: `<binding>: <action> \n\`
|
||||
value, _, _ = strings.Cut(strings.TrimSpace(l), ": ")
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("not a resource: %q", l)
|
||||
continue
|
||||
}
|
||||
f := placeholder.FindStringSubmatch(value)
|
||||
if f == nil || f[0] != value {
|
||||
t.Errorf("a literal value, not a ${config:<key>}: %q", l)
|
||||
continue
|
||||
}
|
||||
if _, declared := m.Config[f[1]]; !declared {
|
||||
t.Errorf("%q names a key the module does not declare", l)
|
||||
}
|
||||
used[f[1]] = true
|
||||
}
|
||||
for key := range m.Config {
|
||||
if !used[key] {
|
||||
t.Errorf("%s is declared and no resource uses it", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC3: filled with the defaults, the resources are the file every machine had.
|
||||
func TestFilledWithTheDefaultsTheResourcesAreTodaysFileByteForByte(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
got := placeholder.ReplaceAllStringFunc(template(t), func(ref string) string {
|
||||
c, ok := m.Config[placeholder.FindStringSubmatch(ref)[1]]
|
||||
if !ok {
|
||||
t.Fatalf("%s: no such key", ref)
|
||||
}
|
||||
return asText(t, c.Default)
|
||||
})
|
||||
if got != todaysResources {
|
||||
t.Fatalf("filled with the defaults:\n%s\nwant:\n%s", got, todaysResources)
|
||||
}
|
||||
}
|
||||
|
||||
// renderedWith is the resources file xorg writes with this module's piece filled from the defaults, and
|
||||
// the given values changed.
|
||||
func renderedWith(t *testing.T, changed map[string]string) string {
|
||||
t.Helper()
|
||||
m := readManifest(t)
|
||||
text := placeholder.ReplaceAllStringFunc(template(t), func(ref string) string {
|
||||
key := placeholder.FindStringSubmatch(ref)[1]
|
||||
if v, ok := changed[key]; ok {
|
||||
return v
|
||||
}
|
||||
return asText(t, m.Config[key].Default)
|
||||
})
|
||||
return "! The mesh's X resources (module xorg)\nXft.dpi: 96\n# xterm\n" + text + "Xft.rgba: rgb\n"
|
||||
}
|
||||
|
||||
func deskWithResources(t *testing.T, rendered string, f *fake) xterm {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "xresources")
|
||||
if rendered != "" {
|
||||
if err := os.WriteFile(path, []byte(rendered), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return xterm{d: f.desk(true), resources: path}
|
||||
}
|
||||
|
||||
// 1656/AC4: the tools' defaults are what the module's resources were rendered with, not constants.
|
||||
func TestFontReportsTheDefaultTheModuleRenderedNotAConstant(t *testing.T) {
|
||||
f := &fake{query: "XTerm*faceName:\tInter\nXTerm*faceSize:\t13\n"}
|
||||
x := deskWithResources(t, renderedWith(t, map[string]string{"size": "10", "face": "Fira Code"}), f)
|
||||
got, err := x.font(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j := asJSON(got); !strings.Contains(j, `"default":{"face":"Fira Code","size":10}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4
|
||||
func TestColoursReportTheDefaultTheModuleRenderedNotAConstant(t *testing.T) {
|
||||
f := &fake{query: "XTerm*color1:\t#aa0000\nXTerm*foreground:\t#112233\nXTerm*color2:\trgb:00/88/01\n"}
|
||||
x := deskWithResources(t, renderedWith(t, map[string]string{"color1": "#aa0000", "foreground": "#112233"}), f)
|
||||
got, err := x.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := asJSON(got)
|
||||
for _, want := range []string{`{"name":"color1","value":"#aa0000","default":"#aa0000"}`,
|
||||
`{"name":"foreground","value":"#112233","default":"#112233"}`,
|
||||
`{"name":"color2","value":"#008801","default":"#008800","differs":true}`} {
|
||||
if !strings.Contains(j, want) {
|
||||
t.Errorf("lacks %s in %s", want, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4: without the rendered file there is no default to report, said loudly.
|
||||
func TestWithoutTheRenderedResourcesTheToolsSayWhy(t *testing.T) {
|
||||
x := deskWithResources(t, "", &fake{})
|
||||
if _, err := x.font(context.Background(), desktop.Args{}); err == nil || !strings.Contains(err.Error(), "xresources") {
|
||||
t.Fatalf("font: %v", err)
|
||||
}
|
||||
if _, err := x.colours(context.Background(), desktop.Args{}); err == nil || !strings.Contains(err.Error(), "xresources") {
|
||||
t.Fatalf("colours: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4: tools.go holds no copy of the defaults.
|
||||
func TestTheToolsHoldNoCopyOfTheDefaults(t *testing.T) {
|
||||
src, err := os.ReadFile("tools.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, lit := range []string{"JetBrainsMono", "#a8a8a8", "#9e1828", "xterm-256color", "Face =", "Size =", "func Resources"} {
|
||||
if strings.Contains(string(src), lit) {
|
||||
t.Errorf("tools.go holds %q", lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway")})); err != nil {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway"), resources: filepath.Join(desktop.Home(), ResourcesFile)})); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func tools(x xterm) []stdio.Tool {
|
||||
"resources set the default again at the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"face": desktop.Str("a font family, as fc-list names it"),
|
||||
"size": desktop.Num("points, 6 to 48"),
|
||||
"size": desktop.Num("points, 6 to 72"),
|
||||
}),
|
||||
Run: call(x.font),
|
||||
},
|
||||
|
||||
@@ -11,47 +11,50 @@ import (
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type xterm struct{ d desktop.Desk }
|
||||
|
||||
// The module's defaults: the face research 026/04 chose and both workstations' palette.
|
||||
const (
|
||||
Face = "JetBrainsMono Nerd Font"
|
||||
Size = 12
|
||||
)
|
||||
|
||||
// Palette is the colours both workstations ran, by resource name, in the order they are written.
|
||||
var Palette = [][2]string{
|
||||
{"background", "#000000"}, {"foreground", "#a8a8a8"},
|
||||
{"color0", "#000000"}, {"color8", "#666666"},
|
||||
{"color1", "#9e1828"}, {"color9", "#bc5766"},
|
||||
{"color2", "#008800"}, {"color10", "#61a171"},
|
||||
{"color3", "#d2bb4b"}, {"color11", "#e7db52"},
|
||||
{"color4", "#414171"}, {"color12", "#5085af"},
|
||||
{"color5", "#963c59"}, {"color13", "#a97a99"},
|
||||
{"color6", "#418179"}, {"color14", "#6ba4a4"},
|
||||
{"color7", "#bebebe"}, {"color15", "#ffffff"},
|
||||
type xterm struct {
|
||||
d desktop.Desk
|
||||
resources string
|
||||
}
|
||||
|
||||
// Resources is the module's contribution to the X resources (the xresources slot `normal`), as the
|
||||
// manifest carries it; the manifest test holds the two to one text.
|
||||
func Resources() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n")
|
||||
b.WriteString("! outrank any `*name` resource of yours and touch no other X program.\n")
|
||||
for _, kv := range [][2]string{
|
||||
{"termName", "xterm-256color"}, {"saveLines", "10000"}, {"renderFont", "true"},
|
||||
{"faceName", Face}, {"faceSize", strconv.Itoa(Size)},
|
||||
{"boldMode", "false"}, {"alwaysBoldMode", "false"}, {"allowBoldFonts", "true"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
// PaletteNames are the colour resources xterm_colours reports, in the order the resources write them.
|
||||
// Their values are the module's config values, read from the rendered resources (Rendered), never held here.
|
||||
var PaletteNames = []string{
|
||||
"background", "foreground",
|
||||
"color0", "color8", "color1", "color9", "color2", "color10", "color3", "color11",
|
||||
"color4", "color12", "color5", "color13", "color6", "color14", "color7", "color15",
|
||||
}
|
||||
|
||||
// ResourcesFile is where xorg writes the X resources, under the account's home: this module's resources are
|
||||
// its contribution to the `normal` slot, filled with the module's config values.
|
||||
const ResourcesFile = ".config/xorg/xresources"
|
||||
|
||||
// Rendered is the XTerm* resources of a rendered resources file, by name: `XTerm*faceSize: 12` is faceSize 12.
|
||||
// The translations, which span lines, and every resource of another program are left out.
|
||||
func Rendered(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
name, scoped := strings.CutPrefix(strings.TrimSpace(k), "XTerm*")
|
||||
if ok && scoped && !strings.Contains(name, ".") {
|
||||
out[name] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
for _, kv := range Palette {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
return out
|
||||
}
|
||||
|
||||
// defaults are the resources the module's config values rendered, as xorg placed them: what the module sets, so
|
||||
// what a terminal has unless the session's own resources say otherwise.
|
||||
func (x xterm) defaults() (map[string]string, error) {
|
||||
text, err := os.ReadFile(x.resources)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the module's resources are not where xorg places them (%s): is xorg assigned to this machine, "+
|
||||
"and has its xresources file been written? %w", x.resources, err)
|
||||
}
|
||||
b.WriteString("XTerm*VT100.Translations: #override \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n")
|
||||
return b.String()
|
||||
got := Rendered(string(text))
|
||||
if len(got) == 0 {
|
||||
return nil, fmt.Errorf("%s holds no XTerm* resource: the xterm module's resources are not in it", x.resources)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// openScript runs in the new terminal: into the directory, then the login shell, or a command
|
||||
@@ -124,6 +127,10 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
def, err := x.defaults()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
face := a.Opt("face", "")
|
||||
size, sized, err := a.Number("size")
|
||||
@@ -142,8 +149,8 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
res = append(res, "XTerm*faceName: "+face)
|
||||
}
|
||||
if sized {
|
||||
if size < 6 || size > 48 {
|
||||
return nil, fmt.Errorf("size is from 6 to 48 points")
|
||||
if size < 6 || size > 72 {
|
||||
return nil, fmt.Errorf("size is from 6 to 72 points")
|
||||
}
|
||||
res = append(res, "XTerm*faceSize: "+strconv.FormatFloat(size, 'f', -1, 64))
|
||||
}
|
||||
@@ -156,9 +163,13 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
return nil, q.Err()
|
||||
}
|
||||
inForce := Effective(q.Stdout, "faceName")
|
||||
var points any = def["faceSize"]
|
||||
if n, err := strconv.ParseFloat(def["faceSize"], 64); err == nil {
|
||||
points = n
|
||||
}
|
||||
answer := map[string]any{
|
||||
"session": s.Display, "face": inForce, "size": Effective(q.Stdout, "faceSize"),
|
||||
"default": map[string]any{"face": Face, "size": Size},
|
||||
"default": map[string]any{"face": def["faceName"], "size": points},
|
||||
}
|
||||
if inForce != "" {
|
||||
m := x.d.Run(ctx, env, nil, "fc-match", "-f", "%{family[0]}|%{style[0]}|%{file}", inForce)
|
||||
@@ -175,7 +186,7 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
|
||||
var rgbSpec = regexp.MustCompile(`^rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})$`)
|
||||
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#9e1828`) as #rrggbb; anything else (a colour name) as it is.
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#rrggbb`) as #rrggbb; anything else (a colour name) as it is.
|
||||
func Hex(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if m := rgbSpec.FindStringSubmatch(v); m != nil {
|
||||
@@ -191,6 +202,10 @@ func Hex(v string) string {
|
||||
}
|
||||
|
||||
func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
def, err := x.defaults()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, s, err := x.d.InSession(ctx, "xrdb", "-query")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -205,9 +220,9 @@ func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
Differs bool `json:"differs,omitempty"`
|
||||
}
|
||||
var out []colour
|
||||
for _, kv := range Palette {
|
||||
v := Hex(Effective(res.Stdout, kv[0]))
|
||||
out = append(out, colour{kv[0], v, kv[1], v != kv[1]})
|
||||
for _, name := range PaletteNames {
|
||||
v, d := Hex(Effective(res.Stdout, name)), Hex(def[name])
|
||||
out = append(out, colour{name, v, d, v != d})
|
||||
}
|
||||
cursor := Hex(Effective(res.Stdout, "cursorColor"))
|
||||
return map[string]any{"session": s.Display, "colours": out, "cursor": cursor}, nil
|
||||
|
||||
@@ -19,7 +19,8 @@ type manifest struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Tools []string `json:"tools"`
|
||||
Config map[string]configKey `json:"config"`
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
@@ -37,6 +38,18 @@ type manifest struct {
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type configKey struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Pattern string `json:"pattern"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
Predefined string `json:"predefined"`
|
||||
Default any `json:"default"`
|
||||
Required bool `json:"required"`
|
||||
Trusted bool `json:"trusted"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
@@ -99,10 +112,8 @@ func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xresources" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
if m.Shell[0].Code != Resources() {
|
||||
t.Fatal("the manifest's resources are not the ones the bundle describes: regenerate the manifest from Resources()")
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(Resources()), "\n") {
|
||||
code := m.Shell[0].Code
|
||||
for _, l := range strings.Split(strings.TrimSpace(code), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "!"), strings.HasPrefix(l, " "):
|
||||
case strings.HasPrefix(l, "#"):
|
||||
@@ -111,8 +122,8 @@ func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T
|
||||
t.Errorf("a resource of another program, or of every one: %q", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Resources(), "XTerm*faceName: JetBrainsMono Nerd Font") || !strings.Contains(Resources(), "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the chosen face and the clipboard keys")
|
||||
if !strings.Contains(code, "XTerm*faceName: ${config:face}") || !strings.Contains(code, "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the face and the clipboard keys")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +213,11 @@ func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
f := &fake{query: q}
|
||||
got, err := xterm{d: f.desk(true)}.colours(context.Background(), desktop.Args{})
|
||||
rendered := filepath.Join(t.TempDir(), "xresources")
|
||||
if err := os.WriteFile(rendered, []byte(renderedWith(t, nil)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := xterm{d: f.desk(true), resources: rendered}.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,14 +225,14 @@ func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
if !strings.Contains(j, `{"name":"color1","value":"#ff0000","default":"#9e1828","differs":true}`) || !strings.Contains(j, `{"name":"foreground","value":"#a8a8a8","default":"#a8a8a8"}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
if _, err := (xterm{d: f.desk(true), resources: rendered}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
t.Fatal("a face that is not installed")
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
if _, err := (xterm{d: f.desk(true), resources: rendered}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
t.Fatal("size out of range")
|
||||
}
|
||||
f.ran, f.stdin = nil, nil
|
||||
got, err = xterm{d: f.desk(true)}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
got, err = xterm{d: f.desk(true), resources: rendered}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they
|
||||
! outrank any `*name` resource of yours and touch no other X program.
|
||||
XTerm*termName: xterm-256color
|
||||
XTerm*saveLines: 10000
|
||||
XTerm*renderFont: true
|
||||
XTerm*faceName: JetBrainsMono Nerd Font
|
||||
XTerm*faceSize: 12
|
||||
XTerm*boldMode: false
|
||||
XTerm*alwaysBoldMode: false
|
||||
XTerm*allowBoldFonts: true
|
||||
XTerm*background: #000000
|
||||
XTerm*foreground: #a8a8a8
|
||||
XTerm*color0: #000000
|
||||
XTerm*color8: #666666
|
||||
XTerm*color1: #9e1828
|
||||
XTerm*color9: #bc5766
|
||||
XTerm*color2: #008800
|
||||
XTerm*color10: #61a171
|
||||
XTerm*color3: #d2bb4b
|
||||
XTerm*color11: #e7db52
|
||||
XTerm*color4: #414171
|
||||
XTerm*color12: #5085af
|
||||
XTerm*color5: #963c59
|
||||
XTerm*color13: #a97a99
|
||||
XTerm*color6: #418179
|
||||
XTerm*color14: #6ba4a4
|
||||
XTerm*color7: #bebebe
|
||||
XTerm*color15: #ffffff
|
||||
XTerm*termName: ${config:term-name}
|
||||
XTerm*saveLines: ${config:save-lines}
|
||||
XTerm*renderFont: ${config:render-font}
|
||||
XTerm*faceName: ${config:face}
|
||||
XTerm*faceSize: ${config:size}
|
||||
XTerm*boldMode: ${config:bold-mode}
|
||||
XTerm*alwaysBoldMode: ${config:always-bold-mode}
|
||||
XTerm*allowBoldFonts: ${config:allow-bold-fonts}
|
||||
XTerm*background: ${config:background}
|
||||
XTerm*foreground: ${config:foreground}
|
||||
XTerm*color0: ${config:color0}
|
||||
XTerm*color8: ${config:color8}
|
||||
XTerm*color1: ${config:color1}
|
||||
XTerm*color9: ${config:color9}
|
||||
XTerm*color2: ${config:color2}
|
||||
XTerm*color10: ${config:color10}
|
||||
XTerm*color3: ${config:color3}
|
||||
XTerm*color11: ${config:color11}
|
||||
XTerm*color4: ${config:color4}
|
||||
XTerm*color12: ${config:color12}
|
||||
XTerm*color5: ${config:color5}
|
||||
XTerm*color13: ${config:color13}
|
||||
XTerm*color6: ${config:color6}
|
||||
XTerm*color14: ${config:color14}
|
||||
XTerm*color7: ${config:color7}
|
||||
XTerm*color15: ${config:color15}
|
||||
XTerm*VT100.Translations: #override \n\
|
||||
Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \n\
|
||||
Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)
|
||||
${config:copy-binding}: copy-selection(CLIPBOARD) \n\
|
||||
${config:paste-binding}: insert-selection(CLIPBOARD)
|
||||
|
||||
@@ -21,6 +21,175 @@
|
||||
"xterm_font",
|
||||
"xterm_colours"
|
||||
],
|
||||
"config": {
|
||||
"face": {
|
||||
"type": "string",
|
||||
"description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).",
|
||||
"pattern": "[A-Za-z0-9 ._-]{1,64}",
|
||||
"default": "JetBrainsMono Nerd Font"
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
|
||||
"minimum": 6,
|
||||
"maximum": 72,
|
||||
"predefined": "font-size",
|
||||
"default": 12
|
||||
},
|
||||
"save-lines": {
|
||||
"type": "number",
|
||||
"description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.",
|
||||
"minimum": 0,
|
||||
"maximum": 1000000,
|
||||
"default": 10000
|
||||
},
|
||||
"term-name": {
|
||||
"type": "string",
|
||||
"description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.",
|
||||
"pattern": "[A-Za-z0-9._-]{1,64}",
|
||||
"default": "xterm-256color"
|
||||
},
|
||||
"render-font": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.",
|
||||
"default": true
|
||||
},
|
||||
"bold-mode": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.",
|
||||
"default": false
|
||||
},
|
||||
"always-bold-mode": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.",
|
||||
"default": false
|
||||
},
|
||||
"allow-bold-fonts": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"type": "string",
|
||||
"description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#000000"
|
||||
},
|
||||
"foreground": {
|
||||
"type": "string",
|
||||
"description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#a8a8a8"
|
||||
},
|
||||
"color0": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#000000"
|
||||
},
|
||||
"color1": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#9e1828"
|
||||
},
|
||||
"color2": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#008800"
|
||||
},
|
||||
"color3": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#d2bb4b"
|
||||
},
|
||||
"color4": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#414171"
|
||||
},
|
||||
"color5": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#963c59"
|
||||
},
|
||||
"color6": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#418179"
|
||||
},
|
||||
"color7": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#bebebe"
|
||||
},
|
||||
"color8": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#666666"
|
||||
},
|
||||
"color9": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#bc5766"
|
||||
},
|
||||
"color10": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#61a171"
|
||||
},
|
||||
"color11": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#e7db52"
|
||||
},
|
||||
"color12": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#5085af"
|
||||
},
|
||||
"color13": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#a97a99"
|
||||
},
|
||||
"color14": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#6ba4a4"
|
||||
},
|
||||
"color15": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#ffffff"
|
||||
},
|
||||
"copy-binding": {
|
||||
"type": "string",
|
||||
"description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.",
|
||||
"pattern": "[^:\\\\\\n]+",
|
||||
"default": "Ctrl Shift ~Alt <Key> C"
|
||||
},
|
||||
"paste-binding": {
|
||||
"type": "string",
|
||||
"description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.",
|
||||
"pattern": "[^:\\\\\\n]+",
|
||||
"default": "Ctrl Shift ~Alt <Key> V"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"collection": "environment",
|
||||
|
||||
Reference in New Issue
Block a user