diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index 865dda67..35955f8a 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -18,8 +18,8 @@ what it refuses. | verb | takes | does | |---|---|---| | `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`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor) | short rows, newest first, or best first for a `search`: number, status, owner, title, and each flag that holds; `next`, the `before` that reads on, when more match | +| `get` | `number`, optionally `brief` | the issue whole: fields, notes, links; with `brief`, a short answer with only its last note | +| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor), `numbers` (only these issues, in the order given, naming any not an issue in `missing`), `worker` (only that worker's held issues) | short rows, newest first, or best first for a `search`, or in the order given for `numbers`: number, status, owner, title, and each flag that holds; `next`, the `before` that reads on, when more match | | `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 | diff --git a/modules/mesh-issues/cmd/mesh-issues/brief_test.go b/modules/mesh-issues/cmd/mesh-issues/brief_test.go new file mode 100644 index 00000000..10ebcc15 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/brief_test.go @@ -0,0 +1,50 @@ +package main + +import "testing" + +// A get with brief: true answers only its last note, saying how many were left out (issue 1071). +func TestGetBriefAnswersOnlyTheLastNote(t *testing.T) { + h := newHarness(t) + n := h.open(t, "the counter skips a number", "hq") + for _, text := range []string{"first", "second", "third"} { + if _, err := h.Note(n, text); err != nil { + t.Fatalf("note: %v", err) + } + } + + got, err := h.call(t, "get", map[string]any{"number": float64(n), "brief": true}) + if err != nil { + t.Fatalf("get brief: %v", err) + } + b, ok := got.(*Brief) + if !ok { + t.Fatalf("want *Brief, got %T", got) + } + if len(b.Notes) != 1 || b.Notes[0].Text != "third" { + t.Fatalf("want only the last note (third), got %+v", b.Notes) + } + if b.NotesLeftOut != 2 { + t.Fatalf("want notes-left-out 2, got %d", b.NotesLeftOut) + } +} + +// A get without brief is unchanged (issue 1071). +func TestGetWithoutBriefIsUnchanged(t *testing.T) { + h := newHarness(t) + n := h.open(t, "the counter skips a number", "hq") + if _, err := h.Note(n, "first"); err != nil { + t.Fatalf("note: %v", err) + } + + got, err := h.call(t, "get", map[string]any{"number": float64(n)}) + if err != nil { + t.Fatalf("get: %v", err) + } + v, ok := got.(*View) + if !ok { + t.Fatalf("want *View, got %T", got) + } + if len(v.Notes) != 1 || v.Notes[0].Text != "first" { + t.Fatalf("want every note, got %+v", v.Notes) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/list_numbers_review1_test.go b/modules/mesh-issues/cmd/mesh-issues/list_numbers_review1_test.go new file mode 100644 index 00000000..1f61a09e --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/list_numbers_review1_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "strings" + "testing" +) + +// list numbers combines search and before as AND filters, never ignoring them (review round 1, issue 1070). +func TestListNumbersAppliesSearchAndBeforeAsFilters(t *testing.T) { + h := newHarness(t) + a := h.open(t, "alpha widget", "hq") + b := h.open(t, "beta widget", "hq") + c := h.open(t, "gamma widget", "hq") + d := h.open(t, "delta nomatch", "hq") + + got, err := h.call(t, "list", map[string]any{ + "numbers": []any{float64(d), float64(c), float64(b), float64(a)}, "search": "widget"}) + if err != nil { + t.Fatalf("list numbers+search: %v", err) + } + l := got.(*Listed) + if len(l.Rows) != 3 || l.Rows[0].Number != c || l.Rows[1].Number != b || l.Rows[2].Number != a { + t.Fatalf("want c, b, a (widget only, in the order given), got %+v", l.Rows) + } + + got, err = h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}, "before": float64(c)}) + if err != nil { + t.Fatalf("list numbers+before: %v", err) + } + l = got.(*Listed) + if len(l.Rows) != 2 || l.Rows[0].Number != b || l.Rows[1].Number != a { + t.Fatalf("want b, a (below c, in the order given), got %+v", l.Rows) + } +} + +// list numbers' limit defaults to the count of numbers asked, still capped, and said names a cut (review round +// 1, issue 1070). +func TestListNumbersLimitDefaultsAndSaysWhenCut(t *testing.T) { + h := newHarness(t) + a := h.open(t, "one", "hq") + b := h.open(t, "two", "hq") + c := h.open(t, "three", "hq") + + got, err := h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}}) + if err != nil { + t.Fatalf("list numbers, default limit: %v", err) + } + l := got.(*Listed) + if len(l.Rows) != 3 { + t.Fatalf("want the limit to default to len(numbers)=3, got %d rows", len(l.Rows)) + } + + got, err = h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}, "limit": float64(2)}) + if err != nil { + t.Fatalf("list numbers, limit 2: %v", err) + } + l = got.(*Listed) + if len(l.Rows) != 2 || l.Matched != 3 { + t.Fatalf("want 2 rows of 3 matched, got rows %d matched %d", len(l.Rows), l.Matched) + } + if !strings.Contains(l.Said, "first 2") { + t.Fatalf("want said to name the cut, got %q", l.Said) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/list_numbers_worker_test.go b/modules/mesh-issues/cmd/mesh-issues/list_numbers_worker_test.go new file mode 100644 index 00000000..e4dbb8cd --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/list_numbers_worker_test.go @@ -0,0 +1,52 @@ +package main + +import "testing" + +// list numbers answers only those issues, rows in the order given, naming a number that does not exist in +// missing (issue 1070). +func TestListNumbersAnswersInTheOrderGivenNamingMissing(t *testing.T) { + h := newHarness(t) + a := h.open(t, "issue a", "hq") + b := h.open(t, "issue b", "hq") + missing := b + 1000 + + got, err := h.call(t, "list", map[string]any{"numbers": []any{float64(b), float64(a), float64(missing)}}) + if err != nil { + t.Fatalf("list numbers: %v", err) + } + l, ok := got.(*Listed) + if !ok { + t.Fatalf("want *Listed, got %T", got) + } + if len(l.Rows) != 2 || l.Rows[0].Number != b || l.Rows[1].Number != a { + t.Fatalf("want rows b then a, got %+v", l.Rows) + } + if len(l.Missing) != 1 || l.Missing[0] != missing { + t.Fatalf("want missing [%d], got %v", missing, l.Missing) + } +} + +// list worker answers only the issues that worker holds, in diagnosing or fixing (issue 1070). +func TestListWorkerAnswersOnlyThatWorkersHeldIssues(t *testing.T) { + h := newHarness(t) + held := h.open(t, "held by x", "hq") + other := h.open(t, "held by someone else", "hq") + if _, err := h.Move(MoveAsk{Number: held, Status: StatusFixing, By: "x@n"}); err != nil { + t.Fatalf("move held: %v", err) + } + if _, err := h.Move(MoveAsk{Number: other, Status: StatusFixing, By: "y@n"}); err != nil { + t.Fatalf("move other: %v", err) + } + + got, err := h.call(t, "list", map[string]any{"worker": "x@n"}) + if err != nil { + t.Fatalf("list worker: %v", err) + } + l, ok := got.(*Listed) + if !ok { + t.Fatalf("want *Listed, got %T", got) + } + if len(l.Rows) != 1 || l.Rows[0].Number != held { + t.Fatalf("want only x@n's held issue, got %+v", l.Rows) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index b8d5670a..03c7d83c 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -282,6 +282,30 @@ func (a args) list(k string) ([]string, error) { return nil, fmt.Errorf("%s is a list of text", k) } +// numbers is a list of whole numbers from 1, at most max entries (issue 1070's `numbers`). +func (a args) numbers(k string, max int) ([]uint64, error) { + v, ok := a.m[k] + if !ok || v == nil { + return nil, nil + } + t, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("%s is a list of whole numbers from 1, such as [312, 313]", k) + } + if len(t) > max { + return nil, fmt.Errorf("%s names %d entries; at most %d", k, len(t), max) + } + out := make([]uint64, 0, len(t)) + for _, e := range t { + f, ok := e.(float64) + if !ok || f < 1 || f > 1e9 || f != math.Trunc(f) { + return nil, fmt.Errorf("every entry of %s is a whole number from 1, such as 312", k) + } + out = append(out, uint64(f)) + } + return out, nil +} + func (a args) number(k string, required bool) (uint64, error) { v, ok := a.m[k] if !ok || v == nil { @@ -387,10 +411,16 @@ func Tools(t *Tracker) []stdio.Tool { "links; the issues it waits for, those that depend on it (needed-by), and whether it is ready; " + "whether its fix is delivered, as the delivery last said it (merged, held and why, delivered); and, once " + "the last of its fixes is delivered, its fix time (fix-time) from its first move to fixing, its class and " + - "fix budget, and its phases: implementing, review, checks, merge to running.", - Input: schema([]string{"number"}, map[string]any{"number": number("the issue's number")}), + "fix budget, and its phases: implementing, review, checks, merge to running. With brief: true, a short " + + "answer instead: number, title, status, severity, needs, ready, worker and since (when diagnosing or " + + "fixing), located-in, fixed-by, the delivery line, its links without who made them or when, opened, and " + + "only its last note, with notes-left-out saying how many were left out.", + Input: schema([]string{"number"}, map[string]any{ + "number": number("the issue's number"), + "brief": map[string]any{"type": "boolean", "description": "a short answer with only its last note, instead of every note and the whole triage"}, + }), Run: func(m map[string]any) (any, error) { - a, err := readArgs("get", m, "number") + a, err := readArgs("get", m, "number", "brief") if err != nil { return nil, err } @@ -398,7 +428,18 @@ func Tools(t *Tracker) []stdio.Tool { if err != nil { return nil, err } - return t.View(n) + brief, err := a.flag("brief") + if err != nil { + return nil, err + } + v, err := t.View(n) + if err != nil { + return nil, err + } + if brief { + return briefOf(v), nil + } + return v, nil }}, {Name: Seat + ".list", Description: "The issues, newest first, one short row each — number, status, owner, title — optionally only " + @@ -411,7 +452,9 @@ func Tools(t *Tracker) []stdio.Tool { "pull requests linked) is delivered; flag abandoned the issues whose worker did nothing on them for a " + "day with no pull request open, which a move takes over without a reason. Each row says both. Answers " + "what is open in one call. search ranks the issues whose title, report or notes hold its words, best first; " + - "before pages the newest-first list: give the answer's next to read on below it.", + "before pages the newest-first list: give the answer's next to read on below it. numbers answers only " + + "those issues, rows in the order given, naming any that is not an issue in missing; worker (who@node) " + + "keeps only the issues in diagnosing or fixing whose worker is exactly that.", 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"), @@ -423,9 +466,12 @@ func Tools(t *Tracker) []stdio.Tool { "flag": map[string]any{"type": "string", "enum": Flags, "description": "only issues delivered and not resolved, or the abandoned ones"}, "search": text("rank the issues whose title, report or notes hold these words, best first"), "before": number("only issues numbered below this: the next of the page before, to read on"), + "numbers": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 1}, + "maxItems": MostNumbers, "description": "only these issues, rows in the order given; one not an issue is named in missing"}, + "worker": text("only issues in diagnosing or fixing whose worker is exactly this, who@node"), }), Run: func(m map[string]any) (any, error) { - a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before") + a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before", "numbers", "worker") if err != nil { return nil, err } @@ -451,9 +497,15 @@ func Tools(t *Tracker) []stdio.Tool { if ask.Search, err = a.str("search"); err != nil { return nil, err } + if ask.Worker, err = a.str("worker"); err != nil { + return nil, err + } if ask.Before, err = a.number("before", false); err != nil { return nil, err } + if ask.Numbers, err = a.numbers("numbers", MostNumbers); err != nil { + return nil, err + } if v, given := m["ready"]; given && v != nil { r, err := a.flag("ready") if err != nil { diff --git a/modules/mesh-issues/cmd/mesh-issues/ready.go b/modules/mesh-issues/cmd/mesh-issues/ready.go index 07e66006..71737739 100644 --- a/modules/mesh-issues/cmd/mesh-issues/ready.go +++ b/modules/mesh-issues/cmd/mesh-issues/ready.go @@ -12,6 +12,7 @@ import ( "regexp" "sort" "strings" + "time" ) // The events of ADR 0279. @@ -320,6 +321,53 @@ func (t *Tracker) View(n uint64) (*View, error) { DeliveredNotResolved: deliveredNotResolved(is), Abandoned: abandoned(is, now, staleAfter)}, nil } +// BriefLink is a link as brief answers it: its kind and target, without who made it or when (issue 1071). +type BriefLink struct { + Kind string `json:"kind"` + Target string `json:"target"` +} + +// Brief is `get` with brief: true — a short answer naming only what deciding what to do with an issue needs, +// with its last note alone and how many were left out (novox/hq issue 1071). +type Brief struct { + Number uint64 `json:"number"` + Title string `json:"title"` + Status string `json:"status,omitempty"` + Severity string `json:"severity,omitempty"` + Needs string `json:"needs,omitempty"` + Ready bool `json:"ready"` + Worker string `json:"worker,omitempty"` + Since *time.Time `json:"since,omitempty"` + LocatedIn []string `json:"located-in,omitempty"` + FixedBy []string `json:"fixed-by,omitempty"` + Delivery string `json:"delivery,omitempty"` + Links []BriefLink `json:"links,omitempty"` + Opened time.Time `json:"opened"` + Notes []Note `json:"notes,omitempty"` + NotesLeftOut int `json:"notes-left-out"` +} + +// briefOf is a View shaped for brief: true: only its last note, with how many were left out. +func briefOf(v *View) *Brief { + b := &Brief{ + Number: v.Number, Title: v.Title, Status: v.Status, Severity: v.Severity, Needs: v.Needs, + Ready: v.Ready, LocatedIn: v.LocatedIn, FixedBy: v.FixedBy, Delivery: v.Delivery, Opened: v.Opened, + } + if v.Worker != nil { + b.Worker = v.Worker.By + since := v.Worker.Since + b.Since = &since + } + for _, l := range v.Links { + b.Links = append(b.Links, BriefLink{Kind: l.Kind, Target: l.Target}) + } + if n := len(v.Notes); n > 0 { + b.Notes = []Note{v.Notes[n-1]} + b.NotesLeftOut = n - 1 + } + return b +} + // letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it // made ready: a note on each, once, and the event `ready`. func (t *Tracker) letGo(dep uint64) error { diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index ad85538d..ec5adb20 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -699,8 +699,17 @@ type ListAsk struct { Search string // Before keeps only the issues numbered below it: the cursor that pages the newest-first list. Before uint64 + // Numbers keeps only these issues, rows in the order given, combined with the other filters as AND; a + // number that is not an issue is named in the answer's Missing (novox/hq issue 1070). + Numbers []uint64 + // Worker keeps only the issues in diagnosing or fixing whose worker is exactly this, who@node (novox/hq + // issue 1070). + Worker string } +// MostNumbers is the most `numbers` may name in one list. +const MostNumbers = 200 + // Row is one issue in a list: short enough to read in a chat. type Row struct { Number uint64 `json:"number"` @@ -731,7 +740,23 @@ type Listed struct { Matched int `json:"matched"` // Next is the before that continues the list where this page ends; 0 when nothing more matches. Next uint64 `json:"next,omitempty"` - Said string `json:"said"` + // Missing is each number asked for (in Numbers) that is not an issue, in the order given (novox/hq issue + // 1070). + Missing []uint64 `json:"missing,omitempty"` + Said string `json:"said"` +} + +// searchHits ranks the issues whose title, report or notes hold search's words, best first, from memory once it +// is loaded on start, else from a fresh index (novox/hq ADR 0300 §5). +func (t *Tracker) searchHits(all map[uint64]*Issue, search string) []hit { + if v := t.view(); v.ready() { + return v.search(search) + } + x := newIndex() + for _, is := range all { + x.add(is) + } + return x.search(search) } // List is the issues that match, newest first, or ranked by a search: from memory once it is loaded on start @@ -770,6 +795,10 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { return nil, err } } + usingNumbers := len(a.Numbers) > 0 + if len(a.Numbers) > MostNumbers { + return nil, fmt.Errorf("numbers names %d entries; at most %d", len(a.Numbers), MostNumbers) + } search := "" if strings.TrimSpace(a.Search) != "" { if search, err = singleLine("search", a.Search, MostPhrase); err != nil { @@ -778,14 +807,24 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { if len(tokens(search)) == 0 { return nil, fmt.Errorf("search %q holds no word of two letters or digits", search) } - if a.Before != 0 { + if a.Before != 0 && !usingNumbers { return nil, errors.New("before pages the newest-first list; a search is ranked best first, so give limit, " + "or more words, instead") } } + worker := "" + if strings.TrimSpace(a.Worker) != "" { + if worker, err = checkWorkerField("worker", a.Worker); err != nil { + return nil, err + } + } limit := a.Limit if limit <= 0 { - limit = 50 + if usingNumbers { + limit = len(a.Numbers) + } else { + limit = 50 + } } limit = min(limit, MostListResult) all, err := t.allIssues() @@ -793,19 +832,18 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { return nil, err } now, staleAfter := t.now(), t.workerStaleAfter() - var numbers []uint64 - if search != "" { - var hits []hit - if v := t.view(); v.ready() { - hits = v.search(search) - } else { - x := newIndex() - for _, is := range all { - x.add(is) + var numbers, missing []uint64 + var searchSet map[uint64]bool + if usingNumbers { + numbers = a.Numbers + if search != "" { + searchSet = map[uint64]bool{} + for _, h := range t.searchHits(all, search) { + searchSet[h.Number] = true } - hits = x.search(search) } - for _, h := range hits { + } else if search != "" { + for _, h := range t.searchHits(all, search) { if all[h.Number] != nil { numbers = append(numbers, h.Number) } @@ -822,6 +860,20 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { lower := strings.ToLower(phrase) for _, n := range numbers { is := all[n] + if is == nil { + if usingNumbers { + missing = append(missing, n) + } + continue + } + if usingNumbers { + if a.Before != 0 && n >= a.Before { + continue + } + if searchSet != nil && !searchSet[n] { + continue + } + } if status != "" && is.Status != status { continue } @@ -841,6 +893,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { if owner != "" && !locatedUnder(is.LocatedIn, owner) { continue } + if worker != "" && (is.Worker == nil || is.Worker.By != worker) { + continue + } if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) { continue } @@ -857,11 +912,22 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { out.Rows = append(out.Rows, row) } } + out.Missing = missing below := "" if a.Before != 0 { below = fmt.Sprintf(" below %d", a.Before) } switch { + case usingNumbers && out.Matched == 0 && len(missing) == 0: + out.Said = "No issue asked matches." + case usingNumbers: + out.Said = fmt.Sprintf("%d of %d asked match, in the order given.", out.Matched, len(a.Numbers)) + if out.Matched > len(out.Rows) { + out.Said += fmt.Sprintf(" The first %d are shown.", len(out.Rows)) + } + if len(missing) > 0 { + out.Said += fmt.Sprintf(" %d of the numbers asked are not an issue.", len(missing)) + } case out.Matched == 0: out.Said = "No issue matches" + below + "." case search != "" && out.Matched > len(out.Rows): diff --git a/modules/mesh-issues/cmd/mesh-issues/worker.go b/modules/mesh-issues/cmd/mesh-issues/worker.go index f7946958..ed1f43b6 100644 --- a/modules/mesh-issues/cmd/mesh-issues/worker.go +++ b/modules/mesh-issues/cmd/mesh-issues/worker.go @@ -42,15 +42,18 @@ var workerName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*@[a-z0-9][a-z0- 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) +func checkWorker(s string) (string, error) { return checkWorkerField("by", s) } + +// checkWorkerField is a field holding who@node (`by`, or `worker` as list filters), as kept: in lower case. +func checkWorkerField(field, s string) (string, error) { + s, err := singleLine(field, 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 "", fmt.Errorf("%s %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", field, s) } return s, nil } diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 7cb938b1..163f1c70 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -17,6 +17,8 @@ operator, it is accepted only on the operator's Approve, given on a channel that |---|---|---| | `ask` | `question` (at most 500 characters), `bound` | the records and clauses bearing on the question, ranked; each clause says whether it holds, what changed it, or that it is only drafted; each record says what changed it since and how to read its reasoning | | `get` | `number`, `kind`, `reasoning`, `section`, `version`, `bound` | one record: clauses, links both ways, what changed it since, versions, reviews, the operator's ask; its reasoning only when asked | +| `list` | `kind`, `status`, `topic`, `flag` (`research-done` or `proposed-old`), `before` (a number, the cursor), `limit` | short rows, newest first, number by number: number, kind, title, status, topic, date, versions, and whether a proposal is pending; `next`, the `before` that reads on, when more match | +| `findings` | `number` (optional) | the whole-set findings kept — each a rule's failure, the records it names, what it says, and when; with `number`, only the findings naming that record | | `holds` | `topic` or `number`, `bound` | the clauses in force for a topic of the reading order or for one record; never a drafted clause | | `links` | `number`, `kind` | the record's typed links, out and in | | `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line | diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/findings.go b/modules/mesh-knowledge/cmd/mesh-knowledge/findings.go new file mode 100644 index 00000000..8c08b4be --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/findings.go @@ -0,0 +1,55 @@ +package main + +// findings (novox/hq issue 1069): the whole-set findings kept (judgeSet, kb.go), or only those naming one +// record. + +import ( + "context" + "fmt" + "strings" + "time" +) + +// FindingRow is one whole-set finding as findings answers it. +type FindingRow struct { + Rule string `json:"rule"` + Records []string `json:"records"` + Says string `json:"says"` + At time.Time `json:"at"` +} + +// Findings is the answer to findings. +type Findings struct { + Rows []FindingRow `json:"rows"` + Count int `json:"count"` +} + +// Findings is the whole-set findings kept, or only those naming one record, given as a record names it ("276", +// "ADR 276", "research 045") (novox/hq issue 1069). +func (k *KB) Findings(number string) (*Findings, error) { + named := "" + if strings.TrimSpace(number) != "" { + ref, err := ParseRef(number) + if err != nil { + return nil, err + } + key, ok := ref.Record() + if !ok { + return nil, fmt.Errorf("number is a record's number, such as 276 or research 045, not %q", number) + } + named = key.String() + } + all, err := k.Store.Findings(context.Background()) + if err != nil { + return nil, err + } + out := &Findings{Rows: []FindingRow{}} + for _, f := range all { + if named != "" && !contains(f.Records, named) { + continue + } + out.Rows = append(out.Rows, FindingRow{Rule: f.Rule, Records: f.Records, Says: f.Says, At: f.At}) + } + out.Count = len(out.Rows) + return out, nil +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/findings_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/findings_test.go new file mode 100644 index 00000000..4f96eb8f --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/findings_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// findings answers the whole-set findings kept, or only those naming one record (issue 1069). +func TestFindingsAnswersKeptFindingsOrOnlyTheOneNamingARecord(t *testing.T) { + s := newMemStore() + at := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC) + seeded := []Finding{ + {Rule: "cited", Records: []string{"ADR 0276"}, Says: "ADR 0276 is accepted and nothing cites it", At: at}, + {Rule: "supersession", Records: []string{"ADR 0100", "ADR 0101"}, Says: "ADR 0100 supersedes ADR 0101, which is accepted", At: at}, + } + if err := s.SetFindings(context.Background(), seeded); err != nil { + t.Fatal(err) + } + k := &KB{Store: s} + + all, err := k.Findings("") + if err != nil { + t.Fatal(err) + } + if all.Count != 2 || len(all.Rows) != 2 { + t.Fatalf("want both findings, got %+v", all) + } + + one, err := k.Findings("276") + if err != nil { + t.Fatal(err) + } + if one.Count != 1 || len(one.Rows) != 1 || one.Rows[0].Rule != "cited" { + t.Fatalf("want only the finding naming 0276, got %+v", one) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/list.go b/modules/mesh-knowledge/cmd/mesh-knowledge/list.go new file mode 100644 index 00000000..c1df234c --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/list.go @@ -0,0 +1,221 @@ +package main + +// list (novox/hq issue 1064): one short row per record, filtered by kind, status, topic or a flag, and paged +// newest first, number by number, like issue-tracker.list. + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// The flags list may be asked for. +const ( + FlagResearchDone = "research-done" + FlagProposedOld = "proposed-old" +) + +// ListFlags are the flags list takes. +var ListFlags = []string{FlagResearchDone, FlagProposedOld} + +// ProposedOldAfter is how long a proposal may sit at proposed before flag proposed-old names it. +const ProposedOldAfter = 24 * time.Hour + +// MostListResult is the most rows list may answer in one call. +const MostListResult = 500 + +// AllStatuses are every status a record of either kind may hold. +var AllStatuses = []string{StatusReserved, StatusProposed, StatusAccepted, StatusSuperseded, StatusWithdrawn, + StatusActive, StatusGraduated, StatusAbandoned} + +// ListAsk narrows a list. +type ListAsk struct { + Kind string + Status string + Topic string + // Flag keeps only the research efforts done (research-done) or the proposals grown old (proposed-old). + Flag string + // Before keeps only the records numbered below it: the cursor that pages the newest-first list. + Before int + Limit int +} + +// ListRow is one record in a list: short enough to read in a chat. +type ListRow struct { + // Number is the record's number as the records print it: "0276" for a decision, "research 045" for + // research. + Number string `json:"number"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status"` + Topic string `json:"topic,omitempty"` + Date string `json:"date,omitempty"` + Versions int `json:"versions"` + // Pending says a version waits for the knowledge base's own review, or the operator still waits to Approve + // (an ask that ended, approved or declined, is not pending; novox/hq issue 1064, review round 1). + Pending bool `json:"pending,omitempty"` +} + +// Listed is the answer to a list. +type Listed struct { + Rows []ListRow `json:"rows"` + Matched int `json:"matched"` + // Next is the before that continues the list where this page ends; 0 when nothing more matches. + Next int `json:"next,omitempty"` + Said string `json:"said"` +} + +// rowNumber is a record's number as list's row prints it. +func rowNumber(kind string, number int) string { + if kind == KindResearch { + return fmt.Sprintf("research %03d", number) + } + return fmt.Sprintf("%04d", number) +} + +// List is the records that match, newest first, number by number (novox/hq issue 1064). +func (k *KB) List(a ListAsk) (*Listed, error) { + if a.Kind != "" && !contains(Kinds, a.Kind) { + return nil, fmt.Errorf("kind is decision or research, not %q", a.Kind) + } + if a.Status != "" && !contains(AllStatuses, a.Status) { + return nil, fmt.Errorf("status %q is not one of %s", a.Status, strings.Join(AllStatuses, ", ")) + } + if a.Flag != "" && !contains(ListFlags, a.Flag) { + return nil, fmt.Errorf("flag is %s, not %q", strings.Join(ListFlags, " or "), a.Flag) + } + limit := a.Limit + if limit <= 0 { + limit = 50 + } + limit = min(limit, MostListResult) + ctx := context.Background() + all, err := k.Store.All(ctx) + if err != nil { + return nil, err + } + byKey := map[Key]*Entry{} + for _, e := range all { + byKey[Key{e.Current.Kind, e.Current.Number}] = e + } + keys := make([]Key, 0, len(byKey)) + for key := range byKey { + if a.Before == 0 || key.Number < a.Before { + keys = append(keys, key) + } + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].Number != keys[j].Number { + return keys[i].Number > keys[j].Number + } + return keys[i].Kind < keys[j].Kind + }) + out := &Listed{Rows: []ListRow{}} + var lastNumber int + for _, key := range keys { + e := byKey[key] + r := e.Current + if a.Kind != "" && r.Kind != a.Kind { + continue + } + if a.Status != "" && r.Status != a.Status { + continue + } + if a.Topic != "" && r.Topic != a.Topic { + continue + } + if a.Flag != "" { + ok, err := k.matchesListFlag(ctx, a.Flag, key, e, byKey) + if err != nil { + return nil, err + } + if !ok { + continue + } + } + out.Matched++ + // Never cut a page between two records of the same number: a decision and a research effort may share + // one, and a bare-number cursor (before) must stay correct for both (novox/hq issue 1064, review round + // 1). + if len(out.Rows) < limit || (len(out.Rows) > 0 && key.Number == lastNumber) { + out.Rows = append(out.Rows, ListRow{ + Number: rowNumber(r.Kind, r.Number), Kind: r.Kind, Title: r.Title, Status: r.Status, + Topic: r.Topic, Date: r.Date, Versions: e.Versions, + Pending: e.Pending != nil || (r.Ask != nil && r.Ask.Said == AskWaiting), + }) + lastNumber = key.Number + } + } + below := "" + if a.Before != 0 { + below = fmt.Sprintf(" below %d", a.Before) + } + switch { + case out.Matched == 0: + out.Said = "No record matches" + below + "." + case out.Matched > len(out.Rows): + out.Next = lastNumber + out.Said = fmt.Sprintf("%d records match%s; the newest %d are shown. The next page is before %d.", out.Matched, + below, len(out.Rows), out.Next) + default: + out.Said = fmt.Sprintf("%d records match%s, newest first.", out.Matched, below) + } + return out, nil +} + +// matchesListFlag says whether an entry matches a list flag. +func (k *KB) matchesListFlag(ctx context.Context, flag string, key Key, e *Entry, byKey map[Key]*Entry) (bool, error) { + switch flag { + case FlagResearchDone: + return researchDone(e, byKey), nil + case FlagProposedOld: + return k.proposedOld(ctx, key, e) + } + return false, nil +} + +// researchDone says whether a research effort is active, names what it became in at least one became link to a +// decision (a became to a design's path is ignored), and every decision it names that way is accepted: a +// candidate for graduated (novox/hq issue 1064). +func researchDone(e *Entry, byKey map[Key]*Entry) bool { + r := e.Current + if r.Kind != KindResearch || r.Status != StatusActive { + return false + } + any := false + for _, l := range r.Links { + if l.Kind != LinkBecame { + continue + } + target, isRecord := l.To.Record() + if !isRecord || target.Kind != KindDecision { + continue + } + any = true + d := byKey[target] + if d == nil || d.Current.Status != StatusAccepted { + return false + } + } + return any +} + +// proposedOld says whether a record is proposed and its current version is older than ProposedOldAfter, by the +// KB's clock (novox/hq issue 1064). +func (k *KB) proposedOld(ctx context.Context, key Key, e *Entry) (bool, error) { + if e.Current.Status != StatusProposed { + return false, nil + } + vs, err := k.Store.Versions(ctx, key) + if err != nil { + return false, err + } + for _, v := range vs { + if v.N == e.CurrentVersion { + return k.now().Sub(v.At) > ProposedOldAfter, nil + } + } + return false, nil +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/list_review1_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/list_review1_test.go new file mode 100644 index 00000000..bb712d79 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/list_review1_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// list's pending counts only an ask still waiting, or a pending version: an ask that ended (approved or +// declined) does not make an accepted record show pending (review round 1, issue 1064). +func TestListPendingIgnoresAnAskThatHasEnded(t *testing.T) { + s := newMemStore() + seedRecord(t, s, &Record{Kind: KindDecision, Number: 500, Title: "approved, ask ended", Status: StatusAccepted, + Ask: &OperatorAsk{ID: "a1", Said: AskApproved}}, time.Now()) + seedRecord(t, s, &Record{Kind: KindDecision, Number: 501, Title: "still waiting on the operator", Status: StatusProposed, + Ask: &OperatorAsk{ID: "a2", Said: AskWaiting}}, time.Now()) + k := &KB{Store: s} + + got, err := k.List(ListAsk{}) + if err != nil { + t.Fatal(err) + } + byNumber := map[string]bool{} + for _, row := range got.Rows { + byNumber[row.Number] = row.Pending + } + if byNumber["0500"] { + t.Fatalf("an ended ask (approved) must not show pending: %+v", got.Rows) + } + if !byNumber["0501"] { + t.Fatalf("an ask still waiting must show pending: %+v", got.Rows) + } +} + +// list's before cursor never cuts a page between a decision and a research effort of the same bare number: the +// page extends to include the twin (review round 1, issue 1064). +func TestListNeverCutsAPageBetweenRecordsOfTheSameNumber(t *testing.T) { + s := newMemStore() + ctx := context.Background() + now := time.Now() + // Two records both numbered 100: a decision and a research effort. The decision sorts first (kind + // "decision" < "research"). + if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed", + Record: &Record{Kind: KindDecision, Number: 100, Title: "decision 100", Status: StatusAccepted}}); err != nil { + t.Fatal(err) + } + if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed", + Record: &Record{Kind: KindResearch, Number: 100, Title: "research 100", Status: StatusActive}}); err != nil { + t.Fatal(err) + } + if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed", + Record: &Record{Kind: KindDecision, Number: 99, Title: "decision 99", Status: StatusAccepted}}); err != nil { + t.Fatal(err) + } + k := &KB{Store: s} + + // A limit of 1 would otherwise cut between decision 100 and research 100: both must be shown. + got, err := k.List(ListAsk{Limit: 1}) + if err != nil { + t.Fatal(err) + } + if len(got.Rows) != 2 || got.Rows[0].Number != "0100" || got.Rows[1].Number != "research 100" { + t.Fatalf("want both twins of 100 on the page, got %+v", got.Rows) + } + if got.Next != 100 { + t.Fatalf("want next below 100 (past both twins), got %d", got.Next) + } + + // The next page, asked with before the twins' number, must still answer decision 99. + got, err = k.List(ListAsk{Before: got.Next}) + if err != nil { + t.Fatal(err) + } + if len(got.Rows) != 1 || got.Rows[0].Number != "0099" { + t.Fatalf("want decision 99 on the next page, got %+v", got.Rows) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/list_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/list_test.go new file mode 100644 index 00000000..972beec6 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/list_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "testing" + "time" +) + +func seedRecord(t *testing.T, s Store, r *Record, at time.Time) { + t.Helper() + if err := s.Create(context.Background(), Version{By: "test", At: at, Why: "seed", Record: r}); err != nil { + t.Fatalf("seed %s: %v", Key{r.Kind, r.Number}, err) + } +} + +// list filters by kind, status and topic (combined as AND), pages with before, and answers both flags as issue +// 1064 specifies. +func TestListFiltersPagesAndFlags(t *testing.T) { + s := newMemStore() + now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) + k := &KB{Store: s, Now: func() time.Time { return now }} + + seedRecord(t, s, &Record{Kind: KindDecision, Number: 100, Title: "accepted in the mesh", Status: StatusAccepted, + Topic: "the mesh", Date: "2026-01-01"}, now.Add(-48*time.Hour)) + seedRecord(t, s, &Record{Kind: KindDecision, Number: 200, Title: "proposed, old", Status: StatusProposed, + Topic: "the controller", Date: "2026-10-15"}, now.Add(-30*time.Hour)) + seedRecord(t, s, &Record{Kind: KindDecision, Number: 201, Title: "proposed, fresh", Status: StatusProposed, + Topic: "the controller", Date: "2026-10-17"}, now.Add(-1*time.Hour)) + seedRecord(t, s, &Record{Kind: KindResearch, Number: 10, Title: "research done", Status: StatusActive, + Topic: "the mesh", Links: []Link{{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 100}}}}, now.Add(-72*time.Hour)) + seedRecord(t, s, &Record{Kind: KindResearch, Number: 20, Title: "research not done: no became", Status: StatusActive, + Topic: "the mesh"}, now.Add(-72*time.Hour)) + seedRecord(t, s, &Record{Kind: KindResearch, Number: 30, Title: "research not done: became to a proposed decision", + Status: StatusActive, Topic: "the mesh", + Links: []Link{{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 200}}}}, now.Add(-72*time.Hour)) + + // kind, status and topic, combined as AND. + got, err := k.List(ListAsk{Kind: KindDecision, Status: StatusProposed, Topic: "the controller"}) + if err != nil { + t.Fatal(err) + } + if got.Matched != 2 || len(got.Rows) != 2 { + t.Fatalf("want 2 proposed decisions in the controller, got %+v", got) + } + + // paging: before. + got, err = k.List(ListAsk{Kind: KindDecision, Before: 200}) + if err != nil { + t.Fatal(err) + } + if len(got.Rows) != 1 || got.Rows[0].Number != "0100" { + t.Fatalf("want only 0100 before 200, got %+v", got.Rows) + } + + // flag research-done: active, with every became decision link accepted. + got, err = k.List(ListAsk{Kind: KindResearch, Flag: FlagResearchDone}) + if err != nil { + t.Fatal(err) + } + if len(got.Rows) != 1 || got.Rows[0].Number != "research 010" { + t.Fatalf("want only research 010 done, got %+v", got.Rows) + } + + // flag proposed-old: proposed, current version older than 24h by the KB's clock. + got, err = k.List(ListAsk{Flag: FlagProposedOld}) + if err != nil { + t.Fatal(err) + } + if len(got.Rows) != 1 || got.Rows[0].Number != "0200" { + t.Fatalf("want only 0200 proposed-old, got %+v", got.Rows) + } +} + +// The answer for 500 rows stays one call (issue 1064). +func TestListOf500RowsStaysOneCall(t *testing.T) { + s := newMemStore() + ctx := context.Background() + for n := 1; n <= 505; n++ { + if err := s.Create(ctx, Version{By: "test", Why: "seed", + Record: &Record{Kind: KindDecision, Number: n, Title: "seed", Status: StatusAccepted}}); err != nil { + t.Fatalf("seed %d: %v", n, err) + } + } + k := &KB{Store: s} + got, err := k.List(ListAsk{Limit: 500}) + if err != nil { + t.Fatal(err) + } + if got.Matched != 505 || len(got.Rows) != 500 || got.Next == 0 { + t.Fatalf("want 500 of 505 matched in one call, got matched %d rows %d next %d", got.Matched, len(got.Rows), got.Next) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 2eb6a1ef..288a894c 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -37,7 +37,7 @@ import ( const Seat = "knowledge-base" // Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6). -var Verbs = []string{"ask", "get", "holds", "links", "history", "reserve", "propose", "import", "return"} +var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return"} // HealthTool is the module's own tool the node-engine asks how it is. const HealthTool = "knowledge_health" @@ -813,6 +813,62 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { } return k.Get(g) }}, + {Name: Seat + ".list", + Description: "The records that match, newest first, number by number, one short row each — number, kind, title, status, " + + "topic, date and versions — optionally only those of one kind (decision or research), at one status, of one topic of " + + "the reading order, or named by flag: research-done is an active research effort naming what it became in a became " + + "link to a decision, every such decision accepted — a candidate for graduated; proposed-old is a proposal whose " + + "current version is more than a day old. before pages the newest-first list: give the answer's next to read on below it.", + Input: schema(nil, map[string]any{"kind": kindArg(), + "status": map[string]any{"type": "string", "enum": AllStatuses, "description": "only records at this status"}, + "topic": text("only records of this topic, as the reading order groups them"), + "flag": map[string]any{"type": "string", "enum": ListFlags, + "description": "only the research efforts done (research-done), or the proposals grown old (proposed-old)"}, + "before": integer("only records numbered below this: the next of the page before, to read on"), + "limit": map[string]any{"type": "integer", "minimum": 1, "maximum": MostListResult, "description": "at most this many rows (default 50)"}, + }), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("list", m, "kind", "status", "topic", "flag", "before", "limit") + if err != nil { + return nil, err + } + var ask ListAsk + if ask.Kind, err = a.str("kind"); err != nil { + return nil, err + } + if ask.Status, err = a.str("status"); err != nil { + return nil, err + } + if ask.Topic, err = a.str("topic"); err != nil { + return nil, err + } + if ask.Flag, err = a.str("flag"); err != nil { + return nil, err + } + if ask.Before, err = a.num("before", false); err != nil { + return nil, err + } + if ask.Limit, err = a.num("limit", false); err != nil { + return nil, err + } + return k.List(ask) + }}, + {Name: Seat + ".findings", + Description: "The whole-set findings kept: every failure of the rules that judge the whole record set, each saying which " + + "records it names and what the rule says of them — a record accepted and cited by nothing, a supersession or a " + + "change no accepted record makes. With number, only the findings that name that record.", + Input: schema(nil, map[string]any{"number": text("only the findings naming this record, such as 276 or research 045")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("findings", m, "number") + if err != nil { + return nil, err + } + number, err := a.str("number") + if err != nil { + return nil, err + } + return k.Findings(number) + }}, {Name: Seat + ".holds", Description: "The clauses in force now, for a topic of the reading order or for one record, each with the record it comes " + "from; a clause changed in part says by what. Never a clause only drafted from prose: those are counted and not served. " + diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 46649ab7..9945acb0 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -62,7 +62,7 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) { if m.Module != Module || !reflect.DeepEqual(m.Seats, want) || !reflect.DeepEqual(m.Claims, want) { t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims) } - if !reflect.DeepEqual(Verbs, []string{"ask", "get", "holds", "links", "history", "reserve", "propose", "import", "return"}) { + if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return"}) { t.Fatalf("the verbs are not ADR 0290 rule 6's: %v", Verbs) } if !reflect.DeepEqual(m.Emits, Events) { diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index c57687b6..33c8c71f 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -23,6 +23,8 @@ "serves": [ "ask", "get", + "list", + "findings", "holds", "links", "history", @@ -40,6 +42,8 @@ "serves": [ "ask", "get", + "list", + "findings", "holds", "links", "history",