diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index 433efa0e..197ffeae 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -19,7 +19,7 @@ what it refuses. | `open` | `title`, `report`, optionally `located-in`, `record` | checks the issue, then takes the next number and keeps it under that number; answers the number | | `get` | `number` | the issue whole: fields, notes, links | | `list` | optionally `status`, `owner`, `phrase`, `limit` | short rows, newest first: number, status, owner, title | -| `move` | `number`, `status`, and what that status needs | changes the status under the development cycle's rules | +| `move` | `number`, `status`, and what that status needs (`by` for `diagnosing` and `fixing`, optionally `take-over`) | changes the status under the development cycle's rules | | `note` | `number`, `text` | appends a note; a note is never edited | | `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link | | `import` | optionally `dry-run`, `commit` | hq's issue folders, read through the records module, each written as an issue under its own number; see below | @@ -27,12 +27,25 @@ what it refuses. | `groom` | optionally `pass`, `confirm` | the triage over the backlog: without arguments a dry run in the background, answering its pass id; with `pass` its progress and findings; with `confirm` and `pass` exactly that pass's findings applied, each checked again; see below | **The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the -folders until the import): a status is one of `open`, -`diagnosing`, `located`, `resolved`, `wontfix`; `located` needs `located-in`; `resolved` needs -`located-in` and `fixed-by`; a core issue (located in the controller, the node-engine, the tool runner, +folders until the import, and hq ADR 0283): a status is one of `open`, +`diagnosing`, `located`, `fixing`, `resolved`, `wontfix`; `diagnosing` and `fixing` need `by`; `located` needs +`located-in`; `resolved` is reached only from `fixing` and needs `located-in` and `fixed-by`; a core issue (located in the controller, the node-engine, the tool runner, the SDK, or the catalogue's bus, forge or build agent) resolves with `replay` or `replay-none`; `wontfix` needs a `reason`. A refused change changes nothing and says which rule refused it. +**Who works on it** (hq ADR 0283). A move to `diagnosing` or `fixing` names in `by` the issue's **worker**: who +works on it and its node, as `who@node` (such as `fix-399@g14` or `jochen@shanks`, kept in lower case). The issue +keeps its worker (`worker`: `by`, `since`, `active`) while it stays at either status; any other status ends the +work. `get` shows it, with `worker-stale`, and each `list` row shows `worker`, `since` and `stale`. A move to either +status by someone else while the worker is current is refused, naming the worker; with `take-over` saying why, the +issue is taken over, and the reason is noted on it. A worker with no move, note, link or set from a caller on the +issue for `worker-stale-after` (default `24h`) is stale and may be taken over from without a reason, still noted. +The tracker's own writes (the triage, the duplicate check, the delivery notes) keep no worker current. The `moved` +event carries `by` (the worker; who acted, for a move naming none), `since`, `caller` (who acted, as the runtime +proves it: `unproven` today), and `took-over-from`, `take-over` or `released` when they apply. `by` is what the +worker says it is: the runtime does not yet tell a module its caller (hq issue 340), so the worker lets agents and +people see each other; it is not a lock against a false name. + **An issue with a folder in hq** (`record` at open: the folder's name, without the number) is whole in the tracker like any other: the folder holds its narrative, the tracker its status. Until the import of slice 3 ran, such an issue kept its status in the folder and `move` refused it; the import filled it. @@ -45,7 +58,7 @@ with no tools under the node's licence — two questions: a shortlist of at most then, always, a judgement on the reports: whether it repeats one (`duplicate`, `related` or `none`, whether it is certain, and why), its **severity** (critical, major, minor), what it **needs** (none, decision, operator), a suggested owner when it names none, and the shortlisted issues it **depends on** or **relates to**, each with a -one-line reason. Only issues at `open`, `diagnosing` or `located` that are older than the new one are offered. +one-line reason. Only issues at `open`, `diagnosing`, `located` or `fixing` that are older than the new one are offered. The judgement is kept on the issue (`triage`) and said as the event `triaged` before anything else, then applied through the module's own `note`, `set`, `link` and `move`, each recorded as by `triage` and each only when not @@ -91,7 +104,7 @@ the delivery of …", "delivered to every machine it should reach" (covered by a Delivered is not resolved. When every fix of an issue not yet resolved is delivered, the issue gains a note and the event `ready-to-verify`, once per set of fixes; it is resolved once its acceptance is proven live. A fix is named -while the issue stays `located`: `move` to the status it has changes what it holds and is refused when it changes +while the issue stays `fixing`: `move` to the status it has changes what it holds and is refused when it changes nothing. Every five minutes the module looks for a fix merged and not delivered after `stuck-after` (default `2h`): it is @@ -206,6 +219,8 @@ verb takes an argument naming a caller, and none can be given one. and every open branch at the moment the module is switched on. Read once for the counter; the import reads it as the boundary between the folders hq numbered before the tracker and the numbers the tracker gave. +- `worker-stale-after` — how long an issue's worker stays current with nothing done on the issue (hq ADR 0283), + as a duration; `24h` when empty. - `owners` — the registered list `located-in` may name: repositories, and modules as `mesh-catalog modules/`. An entry of `located-in` is an owner, or an owner followed by a space or a slash and where in it (`mesh-controller internal/broker`, `mesh-catalog modules/nats/module.json`). A module diff --git a/modules/mesh-issues/cmd/mesh-issues/bus_test.go b/modules/mesh-issues/cmd/mesh-issues/bus_test.go index 218feeeb..8c422666 100644 --- a/modules/mesh-issues/cmd/mesh-issues/bus_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/bus_test.go @@ -408,6 +408,7 @@ func TestAnIssueIsOpenedNotedLinkedMovedAndListedOverTheBus(t *testing.T) { t.Fatal("resolved without located-in") } must(i.call("move", map[string]any{"number": 365, "status": "located", "located-in": []string{"mesh-controller internal/gate"}})) + must(i.call("move", map[string]any{"number": 365, "status": "fixing", "by": "fix-365@g14"})) if _, err := i.call("move", map[string]any{"number": 365, "status": "resolved", "fixed-by": []string{"mesh-controller#300"}}); err == nil { t.Fatal("a core issue resolved without a replay") } @@ -427,11 +428,16 @@ func TestAnIssueIsOpenedNotedLinkedMovedAndListedOverTheBus(t *testing.T) { var kinds []string for _, e := range events { kinds = append(kinds, e.Key) - if e.Body["by"] != Unproven || e.Body["number"] != float64(365) { + // The move to fixing carries who works on it; every other change, who acted (ADR 0283 §4). + want := Unproven + if e.Key == EventMoved && e.Body["to"] == StatusFixing { + want = "fix-365@g14" + } + if e.Body["by"] != want || e.Body["number"] != float64(365) { t.Fatalf("event %+v", e) } } - if fmt.Sprint(kinds) != "[opened noted linked moved moved]" { + if fmt.Sprint(kinds) != "[opened noted linked moved moved moved]" { t.Fatalf("events %v", kinds) } } diff --git a/modules/mesh-issues/cmd/mesh-issues/delivery.go b/modules/mesh-issues/cmd/mesh-issues/delivery.go index 1e00298e..eaa84b82 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivery.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivery.go @@ -187,7 +187,7 @@ func (t *Tracker) afterFix(n uint64) error { prefix := fmt.Sprintf("Ready to verify: every fix of this issue is delivered (%s).", strings.Join(prs, ", ")) if !noted(is, prefix) { if _, err := t.note(GroomBy, n, prefix+" Delivered is not resolved: prove the acceptance live, then "+ - "resolve it. (novox/hq ADR 0281)"); err != nil { + "resolve it, from fixing: move it to fixing with by first if it is not there. (novox/hq ADR 0281, ADR 0283)"); err != nil { return err } } diff --git a/modules/mesh-issues/cmd/mesh-issues/groom.go b/modules/mesh-issues/cmd/mesh-issues/groom.go index 229ff2ec..88b00a0d 100644 --- a/modules/mesh-issues/cmd/mesh-issues/groom.go +++ b/modules/mesh-issues/cmd/mesh-issues/groom.go @@ -124,9 +124,10 @@ type Verdict struct { // closes says whether the verdict closes the newer issue: a certain duplicate, and nothing else. func (v Verdict) closes() bool { return v.Verdict == VerdictDuplicate && v.Certain } -// unresolved says whether an issue may be offered, linked or noted by the triage: at open, diagnosing or located. +// unresolved says whether an issue may be offered, linked or noted by the triage: at open, diagnosing, located or +// fixing (ADR 0283 §5). func unresolved(is *Issue) bool { - return is.Status == StatusOpen || is.Status == StatusDiagnosing || is.Status == StatusLocated + return is.Status == StatusOpen || is.Status == StatusDiagnosing || is.Status == StatusLocated || is.Status == StatusFixing } // notePrefix starts every note the triage writes; verdictPrefix the one on the issue it judged. diff --git a/modules/mesh-issues/cmd/mesh-issues/issue.go b/modules/mesh-issues/cmd/mesh-issues/issue.go index fcaa8b94..b7056cba 100644 --- a/modules/mesh-issues/cmd/mesh-issues/issue.go +++ b/modules/mesh-issues/cmd/mesh-issues/issue.go @@ -21,17 +21,20 @@ const ( IssueCounter = "issue" ) -// The five statuses of an issue, as the development cycle names them (hq 00-META/checks/cycle.py). +// The six statuses of an issue, as the development cycle names them (hq playbook 03, ADR 0275, ADR 0283). +// Fixing is the status an agent or person moves an issue to when they start its fix; resolved is reached +// only from it (ADR 0283 §3). const ( StatusOpen = "open" StatusDiagnosing = "diagnosing" StatusLocated = "located" + StatusFixing = "fixing" StatusResolved = "resolved" StatusWontfix = "wontfix" ) // Statuses is every status an issue may have, in the order the cycle walks them. -var Statuses = []string{StatusOpen, StatusDiagnosing, StatusLocated, StatusResolved, StatusWontfix} +var Statuses = []string{StatusOpen, StatusDiagnosing, StatusLocated, StatusFixing, StatusResolved, StatusWontfix} // Severities say how much an issue costs (ADR 0279): critical — something the operator relies on is broken, or // data is at risk; major — a rule of the mesh does not hold, or people work around it; minor — a contained fix, @@ -62,7 +65,7 @@ const Unproven = "unproven" type Issue struct { Number uint64 `json:"number"` Title string `json:"title"` - // Status is one of the five. It was empty for an issue opened with a folder in hq before the import of + // Status is one of the six. It was empty for an issue opened with a folder in hq before the import of // slice 3, whose folder kept its status; the import fills it (to-be 53 §8). Status string `json:"status,omitempty"` Report string `json:"report"` @@ -103,6 +106,8 @@ type Issue struct { ReadyFor []uint64 `json:"ready-for,omitempty"` // Triage is the triage's judgement as it was made, kept before it is applied. Triage *Verdict `json:"triage,omitempty"` + // Worker is who works on it, while it is diagnosing or fixing (ADR 0283); nil at every other status. + Worker *Worker `json:"worker,omitempty"` // Opening is the id of the call that opened it, so a write whose answer was lost is known as its own. Opening string `json:"opening"` // Imported says that the issue, or its status, was taken from its folder in hq by the import of diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index 52ae5600..f1730e89 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -44,6 +44,9 @@ type Settings struct { // StuckAfter is how long a fix may be merged and not delivered before it is said stuck (ADR 0281), as a // duration such as 2h; DefaultStuckAfter when empty. StuckAfter string `json:"stuck-after,omitempty"` + // WorkerStaleAfter is how long an issue's worker stays current with nothing done on it (ADR 0283 §2), as a + // duration such as 24h; DefaultWorkerStaleAfter when empty. + WorkerStaleAfter string `json:"worker-stale-after,omitempty"` } func readSettings(path string) (Settings, error) { @@ -335,7 +338,8 @@ func Tools(t *Tracker) []stdio.Tool { }}, {Name: Seat + ".get", Description: "One issue whole, by its number: title, status, severity, what it needs, report, who opened it " + - "and when, the owners it is located in, what fixed it and whether that is delivered, its notes and its " + + "and when, the owners it is located in, who works on it and since when (worker, and worker-stale when " + + "nobody did anything on it for a day), what fixed it and whether that is delivered, its notes and its " + "links; the issues it waits for, those that depend on it (needed-by), and whether it is ready; and " + "whether its fix is delivered, as the delivery last said it (merged, held and why, delivered).", Input: schema([]string{"number"}, map[string]any{"number": number("the issue's number")}), @@ -355,7 +359,9 @@ func Tools(t *Tracker) []stdio.Tool { "those at one status (" + statuses + "), located in one owner, at one severity, needing one thing, " + "ready or not, or whose title or report holds a phrase. An agent looking for work asks severity minor, " + "needs none, ready. An issue is ready when everything it depends on is closed, or resolved with its fix " + - "delivered. Each row says whether its fix is delivered. Answers what is open in one call.", + "delivered. Each row says whether its fix is delivered, and, for an issue at diagnosing or fixing, who " + + "works on it (worker) since when, and stale when nobody did anything on it for a day, so it may be " + + "taken over. Answers what is open in one call.", Input: schema(nil, map[string]any{ "status": map[string]any{"type": "string", "enum": Statuses, "description": "only issues at this status"}, "owner": text("only issues located in this owner, such as mesh-controller"), @@ -404,11 +410,15 @@ func Tools(t *Tracker) []stdio.Tool { }}, {Name: Seat + ".move", Description: "Change an issue's status — " + statuses + " — with what that status needs, under the " + - "development cycle's rules: located needs located-in, owners on the registered list; resolved needs " + - "located-in and fixed-by (the pull requests or commits), and an issue in the mesh's core also replay " + - "(the replay's id, such as R361) or replay-none (why no replay is possible); wontfix needs a reason. " + - "A move to the status it has changes only what it holds, such as naming the fix while it stays located. " + - "A refused move changes nothing and says which rule refused it.", + "development cycle's rules. Move an issue to diagnosing or fixing when you start work on it, with by: " + + "who works on it and its node, as who@node (such as fix-399@g14); the issue shows it until it moves on. " + + "Refused while someone else works on it, naming them, unless take-over says why (noted on the issue); a " + + "worker who did nothing on it for a day may be taken over from without a reason. located needs located-in, owners on " + + "the registered list; resolved is reached only from fixing and needs located-in and fixed-by (the pull " + + "requests or commits), and an issue in the mesh's core also replay (the replay's id, such as R361) or " + + "replay-none (why no replay is possible); wontfix needs a reason. A move to the status it has changes " + + "only what it holds, such as naming the fix while it stays fixing. A refused move changes nothing and " + + "says which rule refused it.", Input: schema([]string{"number", "status"}, map[string]any{ "number": number("the issue's number"), "status": map[string]any{"type": "string", "enum": Statuses, "description": "the status it moves to"}, @@ -418,9 +428,11 @@ func Tools(t *Tracker) []stdio.Tool { "replay": text("for a core issue resolved: the replay's id in mesh-lab's replays register, such as R361"), "replay-none": text("for a core issue resolved: why no replay is possible"), "reason": text("for wontfix: why it will not be fixed"), + "by": text("for diagnosing and fixing: who works on it and its node, as who@node, such as fix-399@g14 or jochen@shanks"), + "take-over": text("for diagnosing and fixing, when someone else works on it: why you take it over"), }), Run: func(m map[string]any) (any, error) { - a, err := readArgs("move", m, "number", "status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "reason") + a, err := readArgs("move", m, "number", "status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "reason", "by", "take-over") if err != nil { return nil, err } @@ -431,7 +443,8 @@ func Tools(t *Tracker) []stdio.Tool { for _, f := range []struct { k string p *string - }{{"status", &ask.Status}, {"replay", &ask.Replay}, {"replay-none", &ask.ReplayNone}, {"reason", &ask.Reason}} { + }{{"status", &ask.Status}, {"replay", &ask.Replay}, {"replay-none", &ask.ReplayNone}, {"reason", &ask.Reason}, + {"by", &ask.By}, {"take-over", &ask.TakeOver}} { if *f.p, err = a.str(f.k); err != nil { return nil, err } diff --git a/modules/mesh-issues/cmd/mesh-issues/memory_test.go b/modules/mesh-issues/cmd/mesh-issues/memory_test.go index a2ac0b6b..33fe8537 100644 --- a/modules/mesh-issues/cmd/mesh-issues/memory_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/memory_test.go @@ -203,6 +203,14 @@ func (h *harness) open(t *testing.T, title string, located ...string) uint64 { return o.Number } +// fixing moves an issue to fixing, as the agent that starts its fix does (ADR 0283). +func (h *harness) fixing(t *testing.T, n uint64) { + t.Helper() + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14"}); err != nil { + t.Fatalf("move %d to fixing: %v", n, err) + } +} + // call runs one verb as the tool runner does: by its tool, with arguments as JSON gives them. func (h *harness) call(t *testing.T, verb string, args map[string]any) (any, error) { t.Helper() diff --git a/modules/mesh-issues/cmd/mesh-issues/ready.go b/modules/mesh-issues/cmd/mesh-issues/ready.go index 152093b4..ed98e6bc 100644 --- a/modules/mesh-issues/cmd/mesh-issues/ready.go +++ b/modules/mesh-issues/cmd/mesh-issues/ready.go @@ -88,6 +88,7 @@ func (t *Tracker) set(by string, a SetAsk, onlyEmpty bool) (*SetDone, error) { if len(changed) == 0 { return errNothing } + touch(is, by, t.now()) return nil }) if errors.Is(err, errNothing) { @@ -270,6 +271,8 @@ type View struct { Ready bool `json:"ready"` // Delivery is whether its fix is delivered, in words (ADR 0281). Delivery string `json:"delivery,omitempty"` + // WorkerStale says nothing was done on it by its worker, or anyone, for worker-stale-after (ADR 0283 §2). + WorkerStale bool `json:"worker-stale,omitempty"` } // View is `get`. @@ -283,7 +286,7 @@ func (t *Tracker) View(n uint64) (*View, error) { return nil, fmt.Errorf("there is no issue %d: %w", n, ErrNoIssue) } return &View{Issue: is, NeededBy: neededBy(all, n), WaitsFor: waitsFor(all, is), Ready: isReady(all, is), - Delivery: DeliveryLine(is)}, nil + Delivery: DeliveryLine(is), WorkerStale: is.Worker != nil && is.Worker.stale(t.now(), t.workerStaleAfter())}, nil } // letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it diff --git a/modules/mesh-issues/cmd/mesh-issues/report.go b/modules/mesh-issues/cmd/mesh-issues/report.go index c25598f7..0c129c38 100644 --- a/modules/mesh-issues/cmd/mesh-issues/report.go +++ b/modules/mesh-issues/cmd/mesh-issues/report.go @@ -237,7 +237,7 @@ func unquote(v string) string { // ParseReport reads one issue report. The folder is its name, `NNN-slug`; the text is the report whole. // What makes a report unreadable is an error: no frontmatter, no title, no status, or a status that -// is not one of the five. What is odd but readable is said in Odd. +// is not one of the six. What is odd but readable is said in Odd. func ParseReport(folder, text string) (*Report, error) { n, ok := folderNumber(folder) if !ok { diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index d1823798..ea9b84cc 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -313,18 +313,29 @@ type MoveAsk struct { Replay string ReplayNone string Reason string + // By is who works on it, who@node: what a move to diagnosing or fixing needs (ADR 0283 §1). + By string + // TakeOver is why the issue is taken over from another worker (ADR 0283 §1). + TakeOver string } // Moved is the answer to a move. type Moved struct { - Number uint64 `json:"number"` - From string `json:"from"` - To string `json:"to"` - Changed []string `json:"changed,omitempty"` - By string `json:"by"` - Said string `json:"said"` - Announced bool `json:"announced"` - Warning string `json:"warning,omitempty"` + Number uint64 `json:"number"` + From string `json:"from"` + To string `json:"to"` + Changed []string `json:"changed,omitempty"` + // By is who works on it, as the move named it; for a move that names nobody, who acted. + By string `json:"by"` + // Since is when By started on it, for a move to diagnosing or fixing. + Since *time.Time `json:"since,omitempty"` + // TookOverFrom is the worker a take-over took the issue over from. + TookOverFrom string `json:"took-over-from,omitempty"` + // Released is the worker whose work on it the move ended. + Released string `json:"released,omitempty"` + Said string `json:"said"` + Announced bool `json:"announced"` + Warning string `json:"warning,omitempty"` } func known(status string) bool { @@ -391,12 +402,36 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) { if reason != "" && to != StatusWontfix { return nil, fmt.Errorf("reason is what wontfix needs; this moves to %s", to) } + // Who works on it (ADR 0283 §1): named on a move to diagnosing or fixing, and on no other. + var worker, takeOver string + if worked(to) { + if strings.TrimSpace(a.By) == "" { + return nil, fmt.Errorf("a move to %s needs by: who works on it and its node, as who@node, such as "+ + "fix-399@g14 or jochen@shanks", to) + } + if worker, err = checkWorker(a.By); err != nil { + return nil, err + } + } else if strings.TrimSpace(a.By) != "" { + return nil, fmt.Errorf("by names who works on an issue, which only diagnosing and fixing hold; this moves to %s", to) + } + if strings.TrimSpace(a.TakeOver) != "" { + if !worked(to) { + return nil, fmt.Errorf("take-over is for taking over who works on an issue, at diagnosing or fixing; this moves to %s", to) + } + if takeOver, err = multiLine("take-over", a.TakeOver, MostReason); err != nil { + return nil, err + } + } + staleAfter := t.workerStaleAfter() var from string var changed []string + var work workOutcome is, err := t.change(a.Number, func(is *Issue) error { changed = nil from = is.Status + at := t.now() // A field counts as changed only when its value does, so a move that changes nothing is refused. set := func(name string, cond bool, apply func()) { if !cond { @@ -414,17 +449,32 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) { set("replay", replay != "", func() { is.Replay, is.ReplayNone = replay, "" }) set("replay-none", replayNone != "", func() { is.ReplayNone, is.Replay = replayNone, "" }) set("reason", reason != "", func() { is.Reason = reason }) + prev := is.Worker + if prev != nil { + c := *prev + prev = &c + } + h, cerr := workerFor(is, to, worker, takeOver, at, staleAfter) + if cerr != nil { + return cerr + } + work = h + if work.took { + changed = append(changed, "by") + } + if work.from != "" { + is.Notes = append(is.Notes, Note{By: by, At: at, Text: takeOverNote(work, worker, takeOver, prev)}) + } // The same status again only to change what it holds: a fix named while the issue stays located, to be // resolved once it is delivered and proven (ADR 0281 §3). if from == to && len(changed) == 0 { return fmt.Errorf("issue %d is already %s", is.Number, to) } - if err := cycleRules(is, to); err != nil { + if err := cycleRules(is, from, to); err != nil { return err } is.Status = to if (to == StatusResolved || to == StatusWontfix) && from != to { - at := t.now() is.Closed = &at } else if to != StatusResolved && to != StatusWontfix { is.Closed = nil @@ -434,9 +484,28 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) { if err != nil { return nil, err } - out := &Moved{Number: is.Number, From: from, To: to, Changed: changed, By: by, + out := &Moved{Number: is.Number, From: from, To: to, Changed: changed, By: by, TookOverFrom: work.from, Released: work.released, Said: fmt.Sprintf("Issue %d moved from %s to %s.", is.Number, from, to)} - body := map[string]any{"number": is.Number, "from": from, "to": to, "by": out.By, "at": t.now()} + // The event's by is who works on it when the move names them, and who acted otherwise; caller is always who + // acted, as the runtime proves it (ADR 0283 §4). + body := map[string]any{"number": is.Number, "from": from, "to": to, "by": by, "caller": by, "at": t.now()} + if is.Worker != nil { + since := is.Worker.Since + out.By, out.Since = is.Worker.By, &since + body["by"], body["since"] = is.Worker.By, since + out.Said = fmt.Sprintf("Issue %d moved from %s to %s; %s works on it since %s.", is.Number, from, to, + is.Worker.By, since.Format(time.RFC3339)) + } + if work.from != "" { + body["took-over-from"] = work.from + if takeOver != "" { + body["take-over"] = takeOver + } + out.Said += fmt.Sprintf(" Taken over from %s; noted on the issue.", work.from) + } + if work.released != "" { + body["released"] = work.released + } for _, c := range changed { switch c { case "located-in": @@ -457,9 +526,9 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) { return out, nil } -// cycleRules is what the development cycle asks of an issue at a status (hq cycle.py, playbook 03, to-be 53 -// §5), said as the rule that refused it. -func cycleRules(is *Issue, to string) error { +// cycleRules is what the development cycle asks of an issue moving from one status to another (playbook 03, +// to-be 53 §5, ADR 0283), said as the rule that refused it. +func cycleRules(is *Issue, from, to string) error { switch to { case StatusLocated: if len(is.LocatedIn) == 0 { @@ -467,6 +536,12 @@ func cycleRules(is *Issue, to string) error { "(a repository, or \"mesh-catalog modules/\")", is.Number) } case StatusResolved: + // Resolved only from fixing: whoever fixed it said so when they started (ADR 0283 §3). A resolved issue + // may still have what it holds corrected. + if from != StatusFixing && from != StatusResolved { + return fmt.Errorf("issue %d is %s, and an issue is resolved only from fixing: move it to fixing with by, "+ + "who works on it (who@node), when the fix starts, then resolve it", is.Number, from) + } if len(is.LocatedIn) == 0 { return fmt.Errorf("issue %d cannot be resolved without located-in: name the owner that was fixed", is.Number) } @@ -508,6 +583,7 @@ func (t *Tracker) note(by string, n uint64, text string) (*Noted, error) { note := Note{By: by, At: t.now(), Text: text} is, err := t.change(n, func(is *Issue) error { is.Notes = append(is.Notes, note) + touch(is, by, note.At) return nil }) if err != nil { @@ -582,6 +658,7 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) { return fmt.Errorf("issue %d already has the %s link to %s", is.Number, kind, target) } is.Links = append(is.Links[:i:i], is.Links[i+1:]...) + touch(is, by, at) return nil } } @@ -592,6 +669,7 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) { return fmt.Errorf("issue %d has %d links, the most one issue keeps", is.Number, MostLinks) } is.Links = append(is.Links, Link{Kind: kind, Target: target, By: by, At: at}) + touch(is, by, at) return nil }) if err != nil { @@ -633,6 +711,11 @@ type Row struct { SuggestedOwner string `json:"suggested-owner,omitempty"` // Delivery is whether its fix is delivered, in words (ADR 0281); empty when fixed-by names no pull request. Delivery string `json:"delivery,omitempty"` + // Worker is who works on it and Since since when, while it is diagnosing or fixing; Stale says nobody did + // anything on it for worker-stale-after, so it may be taken over without a reason (ADR 0283). + Worker string `json:"worker,omitempty"` + Since *time.Time `json:"since,omitempty"` + Stale bool `json:"stale,omitempty"` } // Listed is the answer to a list. @@ -680,6 +763,7 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { if err != nil { return nil, err } + now, staleAfter := t.now(), t.workerStaleAfter() var numbers []uint64 for n := range all { numbers = append(numbers, n) @@ -710,8 +794,13 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { } out.Matched++ if len(out.Rows) < limit { - out.Rows = append(out.Rows, Row{Number: n, Status: is.Status, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title, - Severity: is.Severity, Needs: is.Needs, Ready: ready, SuggestedOwner: is.SuggestedOwner, Delivery: DeliveryLine(is)}) + row := Row{Number: n, Status: is.Status, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title, + Severity: is.Severity, Needs: is.Needs, Ready: ready, SuggestedOwner: is.SuggestedOwner, Delivery: DeliveryLine(is)} + if c := is.Worker; c != nil { + since := c.Since + row.Worker, row.Since, row.Stale = c.By, &since, c.stale(now, staleAfter) + } + out.Rows = append(out.Rows, row) } } switch { diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker_test.go b/modules/mesh-issues/cmd/mesh-issues/tracker_test.go index aff82282..1308826b 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker_test.go @@ -247,7 +247,7 @@ func TestAnIssueWithAFolderIsNumberedTitledAndPointsAtItsFolder(t *testing.T) { t.Fatalf("answer %+v", o) } // Since the import of slice 3, an issue with a folder is whole in the tracker: its status moves here. - if _, err := h.Move(MoveAsk{Number: 365, Status: StatusDiagnosing}); err != nil { + if _, err := h.Move(MoveAsk{Number: 365, Status: StatusDiagnosing, By: "fix-365@g14"}); err != nil { t.Fatalf("a move of an issue with a folder: %v", err) } if _, err := h.Note(365, "notes are taken"); err != nil { @@ -259,42 +259,76 @@ func TestAnIssueWithAFolderIsNumberedTitledAndPointsAtItsFolder(t *testing.T) { } } -// The cycle's rules at the write (to-be 53 §5): one case per row, each refused case changing nothing. +// The cycle's rules at the write (to-be 53 §5, ADR 0283): one case per row, each refused case changing nothing. +// A case starts from open, or, with from, from that status reached first (fixing by fix@g14). func TestTheCyclesRulesHoldAtTheWrite(t *testing.T) { + const fx = StatusFixing cases := []struct { name string located []string // at open + from string // the status it is moved to first; "" stays open ask MoveAsk refused string }{ - {"an unknown status", nil, MoveAsk{Status: "closed"}, "not one of"}, - {"located without located-in", nil, MoveAsk{Status: StatusLocated}, "without located-in"}, - {"located with an unregistered owner", nil, MoveAsk{Status: StatusLocated, LocatedIn: []string{"elsewhere"}}, "no registered owner"}, - {"located with located-in", nil, MoveAsk{Status: StatusLocated, LocatedIn: []string{"mesh-catalog modules/records"}}, ""}, - {"located, named at open", []string{"hq"}, MoveAsk{Status: StatusLocated}, ""}, - {"resolved without located-in", nil, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#1"}}, "without located-in"}, - {"resolved without fixed-by", []string{"hq"}, MoveAsk{Status: StatusResolved}, "without fixed-by"}, - {"resolved", []string{"hq"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#280"}}, ""}, - {"a core issue resolved without a replay", []string{"mesh-controller"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-controller#9"}}, "core issue"}, - {"a core issue in the bus resolved without a replay", []string{"mesh-catalog modules/nats"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-catalog#9"}}, "core issue"}, - {"a core issue resolved with a replay", []string{"mesh-tools node-tools/internal/bus"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-tools#25"}, Replay: "R361"}, ""}, - {"a core issue resolved with replay-none", []string{"mesh-sdk"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-sdk#16"}, ReplayNone: "it needs two machines"}, ""}, - {"a replay that names none", []string{"mesh-sdk"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-sdk#16"}, Replay: "yes"}, "names no replay"}, - {"replay and replay-none", []string{"mesh-sdk"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"x"}, Replay: "R1", ReplayNone: "y"}, "not both"}, - {"a replay on another status", []string{"mesh-sdk"}, MoveAsk{Status: StatusLocated, Replay: "R1"}, "what resolving"}, - {"wontfix without a reason", nil, MoveAsk{Status: StatusWontfix}, "without a reason"}, - {"wontfix", nil, MoveAsk{Status: StatusWontfix, Reason: "the module is retired"}, ""}, - {"a reason on another status", nil, MoveAsk{Status: StatusDiagnosing, Reason: "x"}, "what wontfix needs"}, - {"to the status it has", nil, MoveAsk{Status: StatusOpen}, "already open"}, - {"diagnosing", nil, MoveAsk{Status: StatusDiagnosing}, ""}, - {"an amended design that is not one", nil, MoveAsk{Status: StatusDiagnosing, AmendedDesign: []string{"../etc/passwd"}}, "not a design document"}, - {"a fixed-by of two lines", []string{"hq"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"a\nb"}}, "one line"}, + {"an unknown status", nil, "", MoveAsk{Status: "closed"}, "not one of"}, + {"located without located-in", nil, "", MoveAsk{Status: StatusLocated}, "without located-in"}, + {"located with an unregistered owner", nil, "", MoveAsk{Status: StatusLocated, LocatedIn: []string{"elsewhere"}}, "no registered owner"}, + {"located with located-in", nil, "", MoveAsk{Status: StatusLocated, LocatedIn: []string{"mesh-catalog modules/records"}}, ""}, + {"located, named at open", []string{"hq"}, "", MoveAsk{Status: StatusLocated}, ""}, + {"resolved without located-in", nil, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#1"}}, "without located-in"}, + {"resolved without fixed-by", []string{"hq"}, fx, MoveAsk{Status: StatusResolved}, "without fixed-by"}, + {"resolved", []string{"hq"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#280"}}, ""}, + {"resolved from open", []string{"hq"}, "", MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#280"}}, "only from fixing"}, + {"resolved from located", []string{"hq"}, StatusLocated, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#280"}}, "only from fixing"}, + {"resolved from diagnosing", []string{"hq"}, StatusDiagnosing, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#280"}}, "only from fixing"}, + {"a core issue resolved without a replay", []string{"mesh-controller"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-controller#9"}}, "core issue"}, + {"a core issue in the bus resolved without a replay", []string{"mesh-catalog modules/nats"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-catalog#9"}}, "core issue"}, + {"a core issue resolved with a replay", []string{"mesh-tools node-tools/internal/bus"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-tools#25"}, Replay: "R361"}, ""}, + {"a core issue resolved with replay-none", []string{"mesh-sdk"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-sdk#16"}, ReplayNone: "it needs two machines"}, ""}, + {"a replay that names none", []string{"mesh-sdk"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-sdk#16"}, Replay: "yes"}, "names no replay"}, + {"replay and replay-none", []string{"mesh-sdk"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"x"}, Replay: "R1", ReplayNone: "y"}, "not both"}, + {"a replay on another status", []string{"mesh-sdk"}, "", MoveAsk{Status: StatusLocated, Replay: "R1"}, "what resolving"}, + {"wontfix without a reason", nil, "", MoveAsk{Status: StatusWontfix}, "without a reason"}, + {"wontfix", nil, "", MoveAsk{Status: StatusWontfix, Reason: "the module is retired"}, ""}, + {"wontfix from fixing", nil, fx, MoveAsk{Status: StatusWontfix, Reason: "the module is retired"}, ""}, + {"a reason on another status", nil, "", MoveAsk{Status: StatusDiagnosing, By: "a@g14", Reason: "x"}, "what wontfix needs"}, + {"to the status it has", nil, "", MoveAsk{Status: StatusOpen}, "already open"}, + {"diagnosing", nil, "", MoveAsk{Status: StatusDiagnosing, By: "fix-365@g14"}, ""}, + {"diagnosing without by", nil, "", MoveAsk{Status: StatusDiagnosing}, "needs by"}, + {"fixing", nil, "", MoveAsk{Status: StatusFixing, By: "Jochen@shanks"}, ""}, + {"fixing without by", nil, "", MoveAsk{Status: StatusFixing}, "needs by"}, + {"fixing with a by that names no node", nil, "", MoveAsk{Status: StatusFixing, By: "an agent"}, "who@node"}, + {"fixing with a by that carries a secret", nil, "", MoveAsk{Status: StatusFixing, By: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz@g14"}, "carries no secret"}, + {"by on another status", []string{"hq"}, "", MoveAsk{Status: StatusLocated, By: "a@g14"}, "only diagnosing and fixing"}, + {"take-over on another status", []string{"hq"}, "", MoveAsk{Status: StatusLocated, TakeOver: "x"}, "take-over is for"}, + {"take-over with nobody to take from", nil, "", MoveAsk{Status: StatusFixing, By: "a@g14", TakeOver: "x"}, "nobody works on"}, + {"an amended design that is not one", nil, "", MoveAsk{Status: StatusDiagnosing, By: "a@g14", AmendedDesign: []string{"../etc/passwd"}}, "not a design document"}, + {"a take-over reason that carries a secret", nil, StatusDiagnosing, MoveAsk{Status: StatusFixing, By: "b@ace", TakeOver: "the key is sk-ant-api03-abcdefghijklmnopqrstuvwxyz"}, "carries no secret"}, + {"a fixed-by of two lines", []string{"hq"}, fx, MoveAsk{Status: StatusResolved, FixedBy: []string{"a\nb"}}, "one line"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { h := newHarness(t) n := h.open(t, "an issue", c.located...) + from := StatusOpen + switch c.from { + case "": + case StatusFixing: + h.fixing(t, n) + from = StatusFixing + case StatusDiagnosing: + if _, err := h.Move(MoveAsk{Number: n, Status: c.from, By: "fix@g14"}); err != nil { + t.Fatal(err) + } + from = c.from + default: + if _, err := h.Move(MoveAsk{Number: n, Status: c.from}); err != nil { + t.Fatal(err) + } + from = c.from + } before, _ := h.mem.Get(IssuesState, keyOf(n)) + events := len(h.said()) c.ask.Number = n m, err := h.Move(c.ask) if c.refused != "" { @@ -305,7 +339,7 @@ func TestTheCyclesRulesHoldAtTheWrite(t *testing.T) { if after.Revision != before.Revision || string(after.Value) != string(before.Value) { t.Fatal("a refused move changed the issue") } - if len(h.said()) != 1 { + if len(h.said()) != events { t.Fatalf("a refused move said %v", h.said()) } return @@ -314,16 +348,25 @@ func TestTheCyclesRulesHoldAtTheWrite(t *testing.T) { t.Fatalf("refused: %v", err) } is, _ := h.Get(n) - if is.Status != c.ask.Status || m.From != StatusOpen || m.To != c.ask.Status { + if is.Status != c.ask.Status || m.From != from || m.To != c.ask.Status { t.Fatalf("moved %+v, kept %+v", m, is) } closed := c.ask.Status == StatusResolved || c.ask.Status == StatusWontfix if (is.Closed != nil) != closed { t.Fatalf("closed %v at %s", is.Closed, is.Status) } + wantBy := Unproven + if worked(c.ask.Status) { + wantBy = strings.ToLower(c.ask.By) + if is.Worker == nil || is.Worker.By != wantBy || is.Worker.Since.IsZero() { + t.Fatalf("worker %+v", is.Worker) + } + } else if is.Worker != nil { + t.Fatalf("a worker kept at %s: %+v", is.Status, is.Worker) + } ev := h.said() - if last := ev[len(ev)-1]; last.Event != EventMoved || last.Body["to"] != c.ask.Status || last.Body["from"] != StatusOpen || - last.Body["by"] != Unproven { + if last := ev[len(ev)-1]; last.Event != EventMoved || last.Body["to"] != c.ask.Status || last.Body["from"] != from || + last.Body["by"] != wantBy || last.Body["caller"] != Unproven { t.Fatalf("event %+v", last) } }) @@ -333,6 +376,7 @@ func TestTheCyclesRulesHoldAtTheWrite(t *testing.T) { func TestReopeningClearsWhenItClosed(t *testing.T) { h := newHarness(t) n := h.open(t, "x", "hq") + h.fixing(t, n) if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, FixedBy: []string{"hq#1"}}); err != nil { t.Fatal(err) } @@ -347,7 +391,7 @@ func TestReopeningClearsWhenItClosed(t *testing.T) { func TestAMoveOfAnIssueThatDoesNotExistIsRefused(t *testing.T) { h := newHarness(t) - if _, err := h.Move(MoveAsk{Number: 9, Status: StatusDiagnosing}); !errors.Is(err, ErrNoIssue) { + if _, err := h.Move(MoveAsk{Number: 9, Status: StatusDiagnosing, By: "a@g14"}); !errors.Is(err, ErrNoIssue) { t.Fatalf("%v", err) } if _, err := h.Get(9); err == nil || !strings.Contains(err.Error(), "there is no issue 9") { @@ -483,7 +527,7 @@ func TestListIsNewestFirstAndNarrows(t *testing.T) { h.open(t, "the bus drops a message", "mesh-catalog modules/nats") h.open(t, "a check passes and did nothing", "mesh-controller internal/catalogue") n := h.open(t, "the console names a ghost") - if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing}); err != nil { + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "a@g14"}); err != nil { t.Fatal(err) } l, err := h.List(ListAsk{}) diff --git a/modules/mesh-issues/cmd/mesh-issues/triage_test.go b/modules/mesh-issues/cmd/mesh-issues/triage_test.go index cd7cb71b..98482d7f 100644 --- a/modules/mesh-issues/cmd/mesh-issues/triage_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/triage_test.go @@ -77,6 +77,7 @@ func resolve(t *testing.T, h *harness, n uint64, fixedBy ...string) { if len(fixedBy) == 0 { fixedBy = []string{"mesh-catalog#1"} } + h.fixing(t, n) if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, LocatedIn: []string{"mesh-catalog modules/records"}, FixedBy: fixedBy}); err != nil { t.Fatal(err) diff --git a/modules/mesh-issues/cmd/mesh-issues/worker.go b/modules/mesh-issues/cmd/mesh-issues/worker.go new file mode 100644 index 00000000..ef3f0721 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/worker.go @@ -0,0 +1,146 @@ +package main + +// Who works on an issue (novox/hq ADR 0283). A move to diagnosing or fixing names, in `by`, the issue's worker: +// the agent or person and its node, as who@node. The issue keeps its worker while it stays at either status, and +// `get` and `list` show it with since. A move to either by someone else while the worker is not stale is refused, +// naming the worker; a take-over gives a reason, and the reason is noted. A worker with no move, note, link or set +// from a caller for `worker-stale-after` (24 hours) is stale: `list` says so, and it may be taken over without a +// reason. +// +// `by` is what the worker says it is, not who called: the runtime does not yet hand a module its caller (hq +// issue 340), so who acted stays recorded as the runtime proves it (Unproven), and the worker is a way for +// agents and people to see each other, not a lock against someone giving a false name. + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// Worker is who works on an issue, since when, and when a caller last did anything on it. +type Worker struct { + By string `json:"by"` + Since time.Time `json:"since"` + // Active is the last move, note, link or set on the issue by a caller, not by the tracker itself. + Active time.Time `json:"active"` +} + +// DefaultWorkerStaleAfter is how long a worker stays current with nothing done on its issue: a working day and a +// night, so an agent that stopped (its session ended, its machine asleep) frees the issue by the next day, and +// one that works on it keeps it by noting what it found. +const DefaultWorkerStaleAfter = 24 * time.Hour + +// MostWorker is the most a worker's name, who@node, may be. +const MostWorker = 120 + +// workerName is who@node: who is the agent or person (letters, digits and . _ / -, such as fix-399 or jochen), +// node the machine it works on. +var workerName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*@[a-z0-9][a-z0-9-]*$`) + +// worked says whether an issue at a status has a worker: at diagnosing and fixing. +func worked(status string) bool { return status == StatusDiagnosing || status == StatusFixing } + +// checkWorker is `by` as kept: who@node, in lower case. +func checkWorker(s string) (string, error) { + s, err := singleLine("by", s, MostWorker) + if err != nil { + return "", err + } + s = strings.ToLower(s) + if !workerName.MatchString(s) { + return "", fmt.Errorf("by %q is not who works on it and its node: give who@node, the agent or person and the "+ + "machine it works on, such as fix-399@g14 or jochen@shanks", s) + } + return s, nil +} + +// ownActor says whether a write is the tracker's own (the triage, the duplicate check, the delivery's notes, the +// import), which keeps no worker current. +func ownActor(by string) bool { return by == GroomBy || by == LegacyBy || by == ImportedBy } + +// touch marks a caller's write on an issue as activity of its worker. +func touch(is *Issue, by string, at time.Time) { + if is.Worker != nil && !ownActor(by) { + is.Worker.Active = at + } +} + +// stale says whether nothing was done on the issue past the bound since the worker last was. +func (w *Worker) stale(now time.Time, after time.Duration) bool { + last := w.Active + if last.Before(w.Since) { + last = w.Since + } + return now.Sub(last) > after +} + +// workerStaleAfter is the setting `worker-stale-after`, or DefaultWorkerStaleAfter when it is empty or unreadable. +func (t *Tracker) workerStaleAfter() time.Duration { + if s, err := t.Settings(); err == nil && strings.TrimSpace(s.WorkerStaleAfter) != "" { + if d, err := time.ParseDuration(strings.TrimSpace(s.WorkerStaleAfter)); err == nil && d > 0 { + return d + } + } + return DefaultWorkerStaleAfter +} + +// workOutcome is what a move did to an issue's worker. +type workOutcome struct { + took bool // a worker started, or the issue changed hands + from string // the worker it was taken over from + wasStale bool // the worker taken over from was stale + released string // the worker whose work ended, on a move to a status that has none +} + +// workerFor applies a move's worker to an issue, under ADR 0283 §1 and §2: another worker not stale is refused +// without a take-over reason; a take-over reason with no other worker to take over from is refused. +func workerFor(is *Issue, to, by, takeOver string, now time.Time, staleAfter time.Duration) (workOutcome, error) { + var out workOutcome + cur := is.Worker + if !worked(to) { + if cur != nil { + out.released = cur.By + is.Worker = nil + } + return out, nil + } + if cur != nil && cur.By == by { + if takeOver != "" { + return out, fmt.Errorf("%s works on issue %d already: take-over is for taking it over from someone else", by, is.Number) + } + cur.Active = now + return out, nil + } + if cur == nil { + if takeOver != "" { + return out, fmt.Errorf("nobody works on issue %d: take-over is for taking it over from someone else; move it without one", is.Number) + } + is.Worker = &Worker{By: by, Since: now, Active: now} + out.took = true + return out, nil + } + out.wasStale = cur.stale(now, staleAfter) + if !out.wasStale && takeOver == "" { + return out, fmt.Errorf("%s works on issue %d (%s) since %s, last active %s. Ask them, or take it over with "+ + "take-over saying why; the reason is noted on the issue. A worker who did nothing on it for %s may be "+ + "taken over from without a reason", cur.By, is.Number, is.Status, cur.Since.Format(time.RFC3339), + cur.Active.Format(time.RFC3339), staleAfter) + } + out.from, out.took = cur.By, true + is.Worker = &Worker{By: by, Since: now, Active: now} + return out, nil +} + +// takeOverNote is the note a take-over leaves on the issue. +func takeOverNote(o workOutcome, by, reason string, prev *Worker) string { + s := fmt.Sprintf("Taken over by %s from %s, who worked on it since %s and was last active %s", by, o.from, + prev.Since.Format(time.RFC3339), prev.Active.Format(time.RFC3339)) + if o.wasStale { + s += " (stale)" + } + if reason != "" { + s += ". Why: " + reason + } + return s + ". (novox/hq ADR 0283)" +} diff --git a/modules/mesh-issues/cmd/mesh-issues/worker_test.go b/modules/mesh-issues/cmd/mesh-issues/worker_test.go new file mode 100644 index 00000000..be814aea --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/worker_test.go @@ -0,0 +1,299 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// clocked gives a harness a clock the test moves. +func clocked(h *harness) *time.Time { + now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + h.Tracker.Now = func() time.Time { return now } + return &now +} + +// A move to diagnosing or fixing by someone else while the worker is current is refused, naming the worker, and +// changes nothing (ADR 0283 §1). +func TestAnotherWorkerIsRefusedNamingTheWorker(t *testing.T) { + for _, to := range []string{StatusDiagnosing, StatusFixing} { + t.Run(to, func(t *testing.T) { + h := newHarness(t) + now := clocked(h) + n := h.open(t, "the counter skips a number", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { + t.Fatal(err) + } + *now = now.Add(23 * time.Hour) + before, _ := h.mem.Get(IssuesState, keyOf(n)) + events := len(h.said()) + _, err := h.Move(MoveAsk{Number: n, Status: to, By: "fix-b@ace"}) + if err == nil || !strings.Contains(err.Error(), "fix-a@g14") || !strings.Contains(err.Error(), "take-over") { + t.Fatalf("want refused naming the worker, got %v", err) + } + after, _ := h.mem.Get(IssuesState, keyOf(n)) + if after.Revision != before.Revision || len(h.said()) != events { + t.Fatal("a refused move changed the issue or said an event") + } + }) + } +} + +// The worker moves on from diagnosing to fixing as the same worker; its since stands. +func TestTheWorkerMovesOn(t *testing.T) { + h := newHarness(t) + now := clocked(h) + n := h.open(t, "x", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { + t.Fatal(err) + } + since := *now + *now = now.Add(time.Hour) + m, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "FIX-A@g14"}) + if err != nil { + t.Fatal(err) + } + is, _ := h.Get(n) + if is.Worker.By != "fix-a@g14" || !is.Worker.Since.Equal(since) || !is.Worker.Active.Equal(*now) || m.By != "fix-a@g14" || + m.Since == nil || !m.Since.Equal(since) || m.TookOverFrom != "" { + t.Fatalf("worker %+v, answer %+v", is.Worker, m) + } + // Naming the fix while it stays fixing is the worker's too. + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, FixedBy: []string{"hq#1"}}); err == nil || + !strings.Contains(err.Error(), "needs by") { + t.Fatalf("a move to fixing without by: %v", err) + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace", FixedBy: []string{"hq#1"}}); err == nil { + t.Fatal("another named the fix while the worker was current") + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14", FixedBy: []string{"hq#1"}}); err != nil { + t.Fatal(err) + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14", TakeOver: "mine"}); err == nil || + !strings.Contains(err.Error(), "already") { + t.Fatalf("a take-over of one's own work: %v", err) + } +} + +// A take-over gives a reason, the reason is noted, and the moved event says from whom (ADR 0283 §1, §4). +func TestATakeOverNeedsAReasonAndIsNoted(t *testing.T) { + h := newHarness(t) + clocked(h) + n := h.open(t, "x", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14"}); err != nil { + t.Fatal(err) + } + m, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "jochen@shanks", TakeOver: "the operator finishes it by hand"}) + if err != nil { + t.Fatal(err) + } + is, _ := h.Get(n) + if is.Worker.By != "jochen@shanks" || m.TookOverFrom != "fix-a@g14" || len(is.Notes) != 1 || + !strings.Contains(is.Notes[0].Text, "from fix-a@g14") || !strings.Contains(is.Notes[0].Text, "the operator finishes it by hand") || + is.Notes[0].By != Unproven { + t.Fatalf("worker %+v, notes %+v, answer %+v", is.Worker, is.Notes, m) + } + ev := h.said() + last := ev[len(ev)-1] + if last.Event != EventMoved || last.Body["by"] != "jochen@shanks" || last.Body["took-over-from"] != "fix-a@g14" || + last.Body["take-over"] != "the operator finishes it by hand" || last.Body["caller"] != Unproven { + t.Fatalf("event %+v", last) + } +} + +// A worker with nothing done on it past worker-stale-after is stale: list and get say so, and it may be taken over +// without a reason, noted (ADR 0283 §2). A caller's note keeps it live; the tracker's own does not. +func TestAStaleWorkerIsShownAndMayBeTakenOverWithoutAReason(t *testing.T) { + h := newHarness(t) + now := clocked(h) + n := h.open(t, "x", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14"}); err != nil { + t.Fatal(err) + } + since := *now + *now = now.Add(20 * time.Hour) + if _, err := h.Note(n, "still on it: the counter's revision is read twice"); err != nil { + t.Fatal(err) + } + *now = now.Add(10 * time.Hour) // 30 h after the start, 10 h after the worker's note + if _, err := h.note(GroomBy, n, "Triage: a note of the tracker's own"); err != nil { + t.Fatal(err) + } + l, _ := h.List(ListAsk{}) + if r := l.Rows[0]; r.Worker != "fix-a@g14" || r.Since == nil || !r.Since.Equal(since) || r.Stale { + t.Fatalf("row %+v: the worker's note keeps the worker current", r) + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}); err == nil { + t.Fatal("a current worker was taken over without a reason") + } + *now = now.Add(15 * time.Hour) // 25 h after the worker's note; the tracker's note 15 h ago does not count + l, _ = h.List(ListAsk{}) + if r := l.Rows[0]; !r.Stale || r.Worker != "fix-a@g14" { + t.Fatalf("row %+v: want stale", r) + } + v, _ := h.View(n) + if !v.WorkerStale { + t.Fatalf("get %+v: want worker-stale", v) + } + m, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}) + if err != nil { + t.Fatalf("a stale worker taken over from without a reason: %v", err) + } + is, _ := h.Get(n) + last := is.Notes[len(is.Notes)-1] + if m.TookOverFrom != "fix-a@g14" || is.Worker.By != "fix-b@ace" || !strings.Contains(last.Text, "stale") { + t.Fatalf("answer %+v, notes %+v", m, is.Notes) + } + l, _ = h.List(ListAsk{}) + if r := l.Rows[0]; r.Stale || r.Worker != "fix-b@ace" { + t.Fatalf("row %+v after the take-over", r) + } +} + +// The bound is the setting worker-stale-after. +func TestTheStaleBoundIsTheSetting(t *testing.T) { + h := newHarness(t) + now := clocked(h) + h.settings.WorkerStaleAfter = "1h" + n := h.open(t, "x", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { + t.Fatal(err) + } + *now = now.Add(59 * time.Minute) + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-b@ace"}); err == nil { + t.Fatal("taken under the bound") + } + *now = now.Add(2 * time.Minute) + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-b@ace"}); err != nil { + t.Fatalf("past the bound: %v", err) + } +} + +// A move to a status that has no worker ends the work, and the event says whose ended. +func TestAMoveOnEndsTheWork(t *testing.T) { + h := newHarness(t) + clocked(h) + n := h.open(t, "x", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { + t.Fatal(err) + } + m, err := h.Move(MoveAsk{Number: n, Status: StatusLocated}) + if err != nil { + t.Fatal(err) + } + is, _ := h.Get(n) + ev := h.said() + if is.Worker != nil || m.Released != "fix-a@g14" || ev[len(ev)-1].Body["released"] != "fix-a@g14" || m.By != Unproven { + t.Fatalf("worker %+v, answer %+v, event %+v", is.Worker, m, ev[len(ev)-1]) + } + // Anyone may start on it now, without a take-over. + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}); err != nil { + t.Fatal(err) + } +} + +// The duplicate check and the triage treat fixing as not closed, like diagnosing and located (ADR 0283 §5). +func TestFixingIsNotClosed(t *testing.T) { + for _, s := range Statuses { + want := s != StatusResolved && s != StatusWontfix + if got := unresolved(&Issue{Status: s}); got != want { + t.Errorf("unresolved at %s: %v, want %v", s, got, want) + } + } + h := newHarness(t) + n := h.open(t, "x", "hq") + h.fixing(t, n) + l, _ := h.List(ListAsk{Status: StatusFixing}) + if len(l.Rows) != 1 || l.Rows[0].Number != n || l.Rows[0].Worker != "fix@g14" { + t.Fatalf("%+v", l) + } +} + +// Over the verb as the tool runner calls it: by and take-over are arguments, and fixing is a status the schema +// offers. +func TestTheMoveVerbTakesByAndTakeOver(t *testing.T) { + h := newHarness(t) + clocked(h) + n := h.open(t, "x", "hq") + if _, err := h.call(t, "move", map[string]any{"number": n, "status": "fixing", "by": "fix-a@g14"}); err != nil { + t.Fatal(err) + } + if _, err := h.call(t, "move", map[string]any{"number": n, "status": "fixing", "by": "fix-b@ace"}); err == nil { + t.Fatal("taken without a reason over the verb") + } + if _, err := h.call(t, "move", map[string]any{"number": n, "status": "fixing", "by": "fix-b@ace", "take-over": "fix-a's session ended"}); err != nil { + t.Fatal(err) + } + got, err := h.call(t, "get", map[string]any{"number": n}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(got) + if !strings.Contains(string(raw), `"worker":{"by":"fix-b@ace"`) { + t.Fatalf("get %s", raw) + } + for _, tool := range Tools(h.Tracker) { + if tool.Name != Seat+".move" { + continue + } + raw, _ := json.Marshal(tool.Input) + if !strings.Contains(string(raw), `"fixing"`) || !strings.Contains(string(raw), `"take-over"`) || + !strings.Contains(tool.Description, "only from fixing") { + t.Fatalf("move's schema %s", raw) + } + } +} + +// A resolved issue may still have what it holds corrected: resolved to resolved is not refused as "not from fixing". +func TestAResolvedIssueMayBeCorrected(t *testing.T) { + h := newHarness(t) + n := h.open(t, "x", "hq") + h.fixing(t, n) + if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, FixedBy: []string{"hq#1"}}); err != nil { + t.Fatal(err) + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, FixedBy: []string{"hq#1", "hq#2"}}); err != nil { + t.Fatalf("a correction of a resolved issue: %v", err) + } + if is, _ := h.Get(n); len(is.FixedBy) != 2 { + t.Fatalf("%+v", is.FixedBy) + } +} + +// A caller's link and set keep the worker current, as a note does (ADR 0283 §4). +func TestALinkAndASetKeepTheWorkerCurrent(t *testing.T) { + for _, act := range []string{"link", "unlink", "set"} { + t.Run(act, func(t *testing.T) { + h := newHarness(t) + now := clocked(h) + n := h.open(t, "x", "hq") + other := h.open(t, "y") + if act == "unlink" { + if _, err := h.Link(LinkAsk{Number: n, Kind: LinkRelatesTo, Target: keyOf(other)}); err != nil { + t.Fatal(err) + } + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14"}); err != nil { + t.Fatal(err) + } + *now = now.Add(20 * time.Hour) + var err error + switch act { + case "link": + _, err = h.Link(LinkAsk{Number: n, Kind: LinkRelatesTo, Target: keyOf(other)}) + case "unlink": + _, err = h.Link(LinkAsk{Number: n, Kind: LinkRelatesTo, Target: keyOf(other), Remove: true}) + case "set": + _, err = h.Set(SetAsk{Number: n, Severity: "minor"}) + } + if err != nil { + t.Fatal(err) + } + *now = now.Add(10 * time.Hour) // 30 h after the start, 10 h after the act + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}); err == nil { + t.Fatalf("the %s did not keep the worker current", act) + } + }) + } +} diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index 5e266b1a..7659a40c 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -79,7 +79,7 @@ "path": "${dir:state}/settings.json", "mode": "0600", "merge": "json", - "content": "{\n \"first-number\": 365,\n \"owners\": [\n \"hq\",\n \"mesh-catalog\",\n \"mesh-catalog modules/adwaita\",\n \"mesh-catalog modules/artifact-store-tools\",\n \"mesh-catalog modules/asus-zephyrus-g14\",\n \"mesh-catalog modules/audit-logger\",\n \"mesh-catalog modules/avahi\",\n \"mesh-catalog modules/baserow\",\n \"mesh-catalog modules/betterbird\",\n \"mesh-catalog modules/blueman\",\n \"mesh-catalog modules/bluetooth\",\n \"mesh-catalog modules/build-agent\",\n \"mesh-catalog modules/ca-trust\",\n \"mesh-catalog modules/claude-code\",\n \"mesh-catalog modules/claude-licence-manager\",\n \"mesh-catalog modules/clipmenu\",\n \"mesh-catalog modules/confluence\",\n \"mesh-catalog modules/cups\",\n \"mesh-catalog modules/dbus\",\n \"mesh-catalog modules/de-spiegel\",\n \"mesh-catalog modules/desk-channel\",\n \"mesh-catalog modules/disk-load\",\n \"mesh-catalog modules/distribution\",\n \"mesh-catalog modules/dmenu\",\n \"mesh-catalog modules/dnsmasq\",\n \"mesh-catalog modules/docker\",\n \"mesh-catalog modules/docker-compose\",\n \"mesh-catalog modules/dunst\",\n \"mesh-catalog modules/fail2ban\",\n \"mesh-catalog modules/feh\",\n \"mesh-catalog modules/flatpak\",\n \"mesh-catalog modules/fonts\",\n \"mesh-catalog modules/forticlient\",\n \"mesh-catalog modules/gitea\",\n \"mesh-catalog modules/gitlab\",\n \"mesh-catalog modules/gnome-keyring\",\n \"mesh-catalog modules/grafana\",\n \"mesh-catalog modules/hello-web\",\n \"mesh-catalog modules/home-assistant\",\n \"mesh-catalog modules/hostname\",\n \"mesh-catalog modules/i3\",\n \"mesh-catalog modules/i3status-rust\",\n \"mesh-catalog modules/icecast\",\n \"mesh-catalog modules/influxdb\",\n \"mesh-catalog modules/invoicing\",\n \"mesh-catalog modules/jira\",\n \"mesh-catalog modules/keycloak\",\n \"mesh-catalog modules/lab\",\n \"mesh-catalog modules/lemurs\",\n \"mesh-catalog modules/letta\",\n \"mesh-catalog modules/local-model-consumer\",\n \"mesh-catalog modules/localization\",\n \"mesh-catalog modules/logrotate\",\n \"mesh-catalog modules/mailu\",\n \"mesh-catalog modules/marrytts\",\n \"mesh-catalog modules/matrix\",\n \"mesh-catalog modules/memory-pressure\",\n \"mesh-catalog modules/mesh-catalog\",\n \"mesh-catalog modules/mesh-cli\",\n \"mesh-catalog modules/mesh-delivery\",\n \"mesh-catalog modules/mesh-issues\",\n \"mesh-catalog modules/mesh-vault\",\n \"mesh-catalog modules/mesh-watcher\",\n \"mesh-catalog modules/messenger\",\n \"mesh-catalog modules/minio\",\n \"mesh-catalog modules/model-usage\",\n \"mesh-catalog modules/mongodb\",\n \"mesh-catalog modules/mosquitto\",\n \"mesh-catalog modules/mounts\",\n \"mesh-catalog modules/mssql\",\n \"mesh-catalog modules/n8n\",\n \"mesh-catalog modules/nats\",\n \"mesh-catalog modules/netcheck\",\n \"mesh-catalog modules/networkmanager\",\n \"mesh-catalog modules/nextcloud\",\n \"mesh-catalog modules/nextcloud-client\",\n \"mesh-catalog modules/nfs-server\",\n \"mesh-catalog modules/nftables\",\n \"mesh-catalog modules/nm-applet\",\n \"mesh-catalog modules/node-env\",\n \"mesh-catalog modules/nodered\",\n \"mesh-catalog modules/ollama\",\n \"mesh-catalog modules/only-office\",\n \"mesh-catalog modules/openai-consumer\",\n \"mesh-catalog modules/openrazer\",\n \"mesh-catalog modules/pacman\",\n \"mesh-catalog modules/photos-eef\",\n \"mesh-catalog modules/photos-filip\",\n \"mesh-catalog modules/picom\",\n \"mesh-catalog modules/polychromatic\",\n \"mesh-catalog modules/postgres\",\n \"mesh-catalog modules/power\",\n \"mesh-catalog modules/powerlevel10k\",\n \"mesh-catalog modules/records\",\n \"mesh-catalog modules/redis\",\n \"mesh-catalog modules/restic\",\n \"mesh-catalog modules/rofi\",\n \"mesh-catalog modules/route-adapter\",\n \"mesh-catalog modules/route-proxy\",\n \"mesh-catalog modules/screen-lock\",\n \"mesh-catalog modules/searxng\",\n \"mesh-catalog modules/sensors\",\n \"mesh-catalog modules/showcase\",\n \"mesh-catalog modules/slack\",\n \"mesh-catalog modules/snapd\",\n \"mesh-catalog modules/ssh-client\",\n \"mesh-catalog modules/sshd\",\n \"mesh-catalog modules/step-ca\",\n \"mesh-catalog modules/sudo\",\n \"mesh-catalog modules/supabase\",\n \"mesh-catalog modules/systemd\",\n \"mesh-catalog modules/systemd-networkd\",\n \"mesh-catalog modules/systemd-resolved\",\n \"mesh-catalog modules/telegram\",\n \"mesh-catalog modules/time-sync\",\n \"mesh-catalog modules/triggerhappy\",\n \"mesh-catalog modules/umami\",\n \"mesh-catalog modules/unifi\",\n \"mesh-catalog modules/website\",\n \"mesh-catalog modules/xclip\",\n \"mesh-catalog modules/xdg\",\n \"mesh-catalog modules/xorg\",\n \"mesh-catalog modules/xterm\",\n \"mesh-catalog modules/zfs\",\n \"mesh-catalog modules/zsh\",\n \"mesh-catalog modules/zsh-autosuggestions\",\n \"mesh-catalog modules/zsh-syntax-highlighting\",\n \"mesh-controller\",\n \"mesh-host\",\n \"mesh-lab\",\n \"mesh-media-catalog\",\n \"mesh-sdk\",\n \"mesh-tools\"\n ],\n \"duplicate-check\": \"on\",\n \"judge-model\": \"sonnet\",\n \"forge-owner\": \"novox\",\n \"stuck-after\": \"2h\"\n}\n" + "content": "{\n \"first-number\": 365,\n \"owners\": [\n \"hq\",\n \"mesh-catalog\",\n \"mesh-catalog modules/adwaita\",\n \"mesh-catalog modules/artifact-store-tools\",\n \"mesh-catalog modules/asus-zephyrus-g14\",\n \"mesh-catalog modules/audit-logger\",\n \"mesh-catalog modules/avahi\",\n \"mesh-catalog modules/baserow\",\n \"mesh-catalog modules/betterbird\",\n \"mesh-catalog modules/blueman\",\n \"mesh-catalog modules/bluetooth\",\n \"mesh-catalog modules/build-agent\",\n \"mesh-catalog modules/ca-trust\",\n \"mesh-catalog modules/claude-code\",\n \"mesh-catalog modules/claude-licence-manager\",\n \"mesh-catalog modules/clipmenu\",\n \"mesh-catalog modules/confluence\",\n \"mesh-catalog modules/cups\",\n \"mesh-catalog modules/dbus\",\n \"mesh-catalog modules/de-spiegel\",\n \"mesh-catalog modules/desk-channel\",\n \"mesh-catalog modules/disk-load\",\n \"mesh-catalog modules/distribution\",\n \"mesh-catalog modules/dmenu\",\n \"mesh-catalog modules/dnsmasq\",\n \"mesh-catalog modules/docker\",\n \"mesh-catalog modules/docker-compose\",\n \"mesh-catalog modules/dunst\",\n \"mesh-catalog modules/fail2ban\",\n \"mesh-catalog modules/feh\",\n \"mesh-catalog modules/flatpak\",\n \"mesh-catalog modules/fonts\",\n \"mesh-catalog modules/forticlient\",\n \"mesh-catalog modules/gitea\",\n \"mesh-catalog modules/gitlab\",\n \"mesh-catalog modules/gnome-keyring\",\n \"mesh-catalog modules/grafana\",\n \"mesh-catalog modules/hello-web\",\n \"mesh-catalog modules/home-assistant\",\n \"mesh-catalog modules/hostname\",\n \"mesh-catalog modules/i3\",\n \"mesh-catalog modules/i3status-rust\",\n \"mesh-catalog modules/icecast\",\n \"mesh-catalog modules/influxdb\",\n \"mesh-catalog modules/invoicing\",\n \"mesh-catalog modules/jira\",\n \"mesh-catalog modules/keycloak\",\n \"mesh-catalog modules/lab\",\n \"mesh-catalog modules/lemurs\",\n \"mesh-catalog modules/letta\",\n \"mesh-catalog modules/local-model-consumer\",\n \"mesh-catalog modules/localization\",\n \"mesh-catalog modules/logrotate\",\n \"mesh-catalog modules/mailu\",\n \"mesh-catalog modules/marrytts\",\n \"mesh-catalog modules/matrix\",\n \"mesh-catalog modules/memory-pressure\",\n \"mesh-catalog modules/mesh-catalog\",\n \"mesh-catalog modules/mesh-cli\",\n \"mesh-catalog modules/mesh-delivery\",\n \"mesh-catalog modules/mesh-issues\",\n \"mesh-catalog modules/mesh-vault\",\n \"mesh-catalog modules/mesh-watcher\",\n \"mesh-catalog modules/messenger\",\n \"mesh-catalog modules/minio\",\n \"mesh-catalog modules/model-usage\",\n \"mesh-catalog modules/mongodb\",\n \"mesh-catalog modules/mosquitto\",\n \"mesh-catalog modules/mounts\",\n \"mesh-catalog modules/mssql\",\n \"mesh-catalog modules/n8n\",\n \"mesh-catalog modules/nats\",\n \"mesh-catalog modules/netcheck\",\n \"mesh-catalog modules/networkmanager\",\n \"mesh-catalog modules/nextcloud\",\n \"mesh-catalog modules/nextcloud-client\",\n \"mesh-catalog modules/nfs-server\",\n \"mesh-catalog modules/nftables\",\n \"mesh-catalog modules/nm-applet\",\n \"mesh-catalog modules/node-env\",\n \"mesh-catalog modules/nodered\",\n \"mesh-catalog modules/ollama\",\n \"mesh-catalog modules/only-office\",\n \"mesh-catalog modules/openai-consumer\",\n \"mesh-catalog modules/openrazer\",\n \"mesh-catalog modules/pacman\",\n \"mesh-catalog modules/photos-eef\",\n \"mesh-catalog modules/photos-filip\",\n \"mesh-catalog modules/picom\",\n \"mesh-catalog modules/polychromatic\",\n \"mesh-catalog modules/postgres\",\n \"mesh-catalog modules/power\",\n \"mesh-catalog modules/powerlevel10k\",\n \"mesh-catalog modules/records\",\n \"mesh-catalog modules/redis\",\n \"mesh-catalog modules/restic\",\n \"mesh-catalog modules/rofi\",\n \"mesh-catalog modules/route-adapter\",\n \"mesh-catalog modules/route-proxy\",\n \"mesh-catalog modules/screen-lock\",\n \"mesh-catalog modules/searxng\",\n \"mesh-catalog modules/sensors\",\n \"mesh-catalog modules/showcase\",\n \"mesh-catalog modules/slack\",\n \"mesh-catalog modules/snapd\",\n \"mesh-catalog modules/ssh-client\",\n \"mesh-catalog modules/sshd\",\n \"mesh-catalog modules/step-ca\",\n \"mesh-catalog modules/sudo\",\n \"mesh-catalog modules/supabase\",\n \"mesh-catalog modules/systemd\",\n \"mesh-catalog modules/systemd-networkd\",\n \"mesh-catalog modules/systemd-resolved\",\n \"mesh-catalog modules/telegram\",\n \"mesh-catalog modules/time-sync\",\n \"mesh-catalog modules/triggerhappy\",\n \"mesh-catalog modules/umami\",\n \"mesh-catalog modules/unifi\",\n \"mesh-catalog modules/website\",\n \"mesh-catalog modules/xclip\",\n \"mesh-catalog modules/xdg\",\n \"mesh-catalog modules/xorg\",\n \"mesh-catalog modules/xterm\",\n \"mesh-catalog modules/zfs\",\n \"mesh-catalog modules/zsh\",\n \"mesh-catalog modules/zsh-autosuggestions\",\n \"mesh-catalog modules/zsh-syntax-highlighting\",\n \"mesh-controller\",\n \"mesh-host\",\n \"mesh-lab\",\n \"mesh-media-catalog\",\n \"mesh-sdk\",\n \"mesh-tools\"\n ],\n \"duplicate-check\": \"on\",\n \"judge-model\": \"sonnet\",\n \"forge-owner\": \"novox\",\n \"stuck-after\": \"2h\",\n \"worker-stale-after\": \"24h\"\n}\n" } ], "build": {