Let the knowledge base list its records by kind, status, topic or a flag (issue 1064)

This commit is contained in:
jochen
2026-10-17 22:59:09 +02:00
parent 1124f17912
commit 1cfbcf8e30
6 changed files with 353 additions and 2 deletions
@@ -0,0 +1,216 @@
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 proposal waits for the knowledge base's own review or the operator's Approve.
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++
if len(out.Rows) < limit {
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,
})
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
}
@@ -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)
}
}
@@ -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", "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,46 @@ 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 + ".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. " +
@@ -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", "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) {