Let the knowledge base list its records by kind, status, topic or a flag (issue 1064)
This commit is contained in:
@@ -17,6 +17,7 @@ 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 |
|
||||
| `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 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"serves": [
|
||||
"ask",
|
||||
"get",
|
||||
"list",
|
||||
"holds",
|
||||
"links",
|
||||
"history",
|
||||
@@ -40,6 +41,7 @@
|
||||
"serves": [
|
||||
"ask",
|
||||
"get",
|
||||
"list",
|
||||
"holds",
|
||||
"links",
|
||||
"history",
|
||||
|
||||
Reference in New Issue
Block a user