CAT-K4: a record is written in a draft, one section a call: open, write, remove, order, submit, drop, take-over
A draft is a key of the state drafts (author, fields, sections as title, digest and the sequence of their write-section); write puts the text into the record's history at once, submit makes the draft a version through the rules propose is judged by and writes no section again. Only the author writes; take-over hands a draft, a proposal or a reserved number to the caller with the reason in the next version's message. A new version of an effort starts from the served sections and keeps its title, date and deciders (the rows of the commit folded in, now run over the ledger too). A tool call is named to the ledger so the runtime stamps the caller it proved. Issue 1966. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -42,62 +42,64 @@ func TestAnActiveEffortTakesANewVersionWithANewDocument(t *testing.T) {
|
||||
}, "became", "", nil, false},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := effortAsk(r.Number, StatusActive, overview)
|
||||
w.accepted(t, "author@g14", first)
|
||||
key := Key{KindResearch, r.Number}
|
||||
reviews, versions := len(w.judge.reviews), mustGet(t, w.k.Store, key).Versions
|
||||
next := c.next(r.Number)
|
||||
next.Why = c.name
|
||||
_, err = w.k.Propose("author@g14", next)
|
||||
if c.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||
t.Fatalf("want a refusal naming %s, got %v", c.refused, err)
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e := mustGet(t, w.k.Store, key); e.Pending != nil || e.Versions != versions {
|
||||
t.Fatalf("a refused version was written: pending %v, %d versions", e.Pending != nil, e.Versions)
|
||||
first := effortAsk(r.Number, StatusActive, overview)
|
||||
w.accepted(t, "author@g14", first)
|
||||
key := Key{KindResearch, r.Number}
|
||||
reviews, versions := len(w.judge.reviews), mustGet(t, w.k.Store, key).Versions
|
||||
next := c.next(r.Number)
|
||||
next.Why = c.name
|
||||
_, err = w.k.Propose("author@g14", next)
|
||||
if c.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||
t.Fatalf("want a refusal naming %s, got %v", c.refused, err)
|
||||
}
|
||||
if e := mustGet(t, w.k.Store, key); e.Pending != nil || e.Versions != versions {
|
||||
t.Fatalf("a refused version was written: pending %v, %d versions", e.Pending != nil, e.Versions)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("an active effort refused a new version: %v", err)
|
||||
}
|
||||
e := mustGet(t, w.k.Store, key)
|
||||
if e.Pending == nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "" {
|
||||
t.Fatalf("the new version was served before its review")
|
||||
}
|
||||
w.reviewAll(t)
|
||||
q := w.judge.reviews[reviews]
|
||||
if strings.Contains(q, "a correction of fact (a progressive insight)") {
|
||||
t.Fatalf("the review judged a new version as an insight:\n%.400s", q)
|
||||
}
|
||||
if !strings.Contains(q, overview.Text) {
|
||||
t.Fatalf("the review did not see the kept text:\n%.400s", q)
|
||||
}
|
||||
e = mustGet(t, w.k.Store, key)
|
||||
cur := e.Current
|
||||
if e.Pending != nil || cur.Status != c.status || len(cur.Insights) != 0 || !reflect.DeepEqual(cur.Sections, c.docs) {
|
||||
t.Fatalf("after the review: %s, pending %v, insights %v, sections %+v", cur.Status, e.Pending != nil, cur.Insights, cur.Sections)
|
||||
}
|
||||
if cur.Title != first.Title || cur.Date != first.Date || !reflect.DeepEqual(cur.Deciders, first.Deciders) {
|
||||
t.Fatalf("title, date or deciders not kept: %q %s %v", cur.Title, cur.Date, cur.Deciders)
|
||||
}
|
||||
if got := hasLink(cur, Link{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 290}}); got != c.became {
|
||||
t.Fatalf("became link %v, want %v: %+v", got, c.became, cur.Links)
|
||||
}
|
||||
h, err := w.k.History(key, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range h.Versions {
|
||||
if v.Insight {
|
||||
t.Errorf("version %d is marked an insight", v.N)
|
||||
if err != nil {
|
||||
t.Fatalf("an active effort refused a new version: %v", err)
|
||||
}
|
||||
}
|
||||
e := mustGet(t, w.k.Store, key)
|
||||
if e.Pending == nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "" {
|
||||
t.Fatalf("the new version was served before its review")
|
||||
}
|
||||
w.reviewAll(t)
|
||||
q := w.judge.reviews[reviews]
|
||||
if strings.Contains(q, "a correction of fact (a progressive insight)") {
|
||||
t.Fatalf("the review judged a new version as an insight:\n%.400s", q)
|
||||
}
|
||||
if !strings.Contains(q, overview.Text) {
|
||||
t.Fatalf("the review did not see the kept text:\n%.400s", q)
|
||||
}
|
||||
e = mustGet(t, w.k.Store, key)
|
||||
cur := e.Current
|
||||
if e.Pending != nil || cur.Status != c.status || len(cur.Insights) != 0 || !reflect.DeepEqual(cur.Sections, c.docs) {
|
||||
t.Fatalf("after the review: %s, pending %v, insights %v, sections %+v", cur.Status, e.Pending != nil, cur.Insights, cur.Sections)
|
||||
}
|
||||
if cur.Title != first.Title || cur.Date != first.Date || !reflect.DeepEqual(cur.Deciders, first.Deciders) {
|
||||
t.Fatalf("title, date or deciders not kept: %q %s %v", cur.Title, cur.Date, cur.Deciders)
|
||||
}
|
||||
if got := hasLink(cur, Link{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 290}}); got != c.became {
|
||||
t.Fatalf("became link %v, want %v: %+v", got, c.became, cur.Links)
|
||||
}
|
||||
h, err := w.k.History(key, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range h.Versions {
|
||||
if v.Insight {
|
||||
t.Errorf("version %d is marked an insight", v.N)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ type KB struct {
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
|
||||
// calls are the tool calls in flight, by the proven caller of each (noteCall).
|
||||
calls sync.Map
|
||||
|
||||
// States are the module's states by local name (the drafts, the parts asked of the judge, the marks of what was sent).
|
||||
States States
|
||||
|
||||
// mu serialises the writes: one instance holds the seat, and a write reads, judges and writes as one.
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -221,7 +227,7 @@ func (k *KB) ReserveNumber(caller, kind, title string) (*Reserved, error) {
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
ctx := context.Background()
|
||||
ctx := k.ctxFor(caller)
|
||||
at := k.now()
|
||||
r := &Record{Kind: kind, Number: n, Title: title, Status: StatusReserved, ReservedBy: caller, ReservedAt: at, ReservedFor: title}
|
||||
err = k.Store.Create(ctx, Version{By: caller, At: at, Why: "number reserved from the issue tracker", Record: r})
|
||||
@@ -287,7 +293,7 @@ const MostRecordBytes = 512 << 10
|
||||
// revised as new versions by its author until it is accepted; an accepted record is changed only by a progressive
|
||||
// insight (Insight), which is reviewed like a proposal while the accepted version stays the one served.
|
||||
func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
|
||||
ctx := context.Background()
|
||||
ctx := k.ctxFor(caller)
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if !contains(Kinds, a.Kind) {
|
||||
@@ -716,7 +722,7 @@ func (k *KB) WithdrawRecord(caller string, key Key, why string) (*Withdrawn, err
|
||||
if why == "" {
|
||||
return nil, errors.New("withdraw needs why: one line saying why the record is withdrawn; nothing was withdrawn")
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx := k.ctxFor(caller)
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
e, err := k.Store.Get(ctx, key)
|
||||
|
||||
@@ -90,11 +90,14 @@ type ledgerStore struct {
|
||||
heads map[Key]*headState
|
||||
texts map[string]string
|
||||
scanned bool
|
||||
// drafted are the sections written for a draft and named by no version yet: a version that carries one reuses its
|
||||
// write-section and writes none again.
|
||||
drafted map[Key][]sectionRef
|
||||
}
|
||||
|
||||
// newLedgerStore is the store over a pipe and the module's states.
|
||||
func newLedgerStore(p Pipe, s States) *ledgerStore {
|
||||
return &ledgerStore{pipe: p, states: s, heads: map[Key]*headState{}, texts: map[string]string{}}
|
||||
return &ledgerStore{pipe: p, states: s, heads: map[Key]*headState{}, texts: map[string]string{}, drafted: map[Key][]sectionRef{}}
|
||||
}
|
||||
|
||||
// stripped is r as a stored record naming its sections by ref: the refs of the sections already written are kept, the
|
||||
@@ -341,7 +344,7 @@ func (s *ledgerStore) Create(ctx context.Context, v Version) error {
|
||||
return ErrExists
|
||||
}
|
||||
v.N = 1
|
||||
refs, err := s.write(ctx, k, v.Record, nil)
|
||||
refs, err := s.write(ctx, k, v.Record, s.drafted[k])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -360,9 +363,56 @@ func (s *ledgerStore) commit(ctx context.Context, k Key, op string, e *entryHead
|
||||
return err
|
||||
}
|
||||
s.heads[k] = &headState{Kind: k.Kind, Number: k.Number, Entry: e, Sequence: seq}
|
||||
delete(s.drafted, k)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteSection appends a write-section of the record (SectionWriter): a draft's text is in the history at once.
|
||||
func (s *ledgerStore) WriteSection(ctx context.Context, k Key, title, text string) (sectionRef, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(text) > MostSectionBytes {
|
||||
return sectionRef{}, fmt.Errorf("the section %q is %d bytes; a section holds at most %d: split it", title, len(text), MostSectionBytes)
|
||||
}
|
||||
d := SectionDigest(text)
|
||||
seq, err := s.apply(ctx, k, OpWriteSection, map[string]any{"title": title, "text": text, "digest": d})
|
||||
if err != nil {
|
||||
return sectionRef{}, err
|
||||
}
|
||||
s.texts[d] = text
|
||||
ref := sectionRef{Name: title, Digest: d, Sequence: seq}
|
||||
s.drafted[k] = append(s.drafted[k], ref)
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// SectionText is the text a ref names (SectionWriter).
|
||||
func (s *ledgerStore) SectionText(_ context.Context, k Key, ref sectionRef) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.text(ref, k)
|
||||
}
|
||||
|
||||
// Working is the refs of the version a writer works on (SectionWriter).
|
||||
func (s *ledgerStore) Working(_ context.Context, k Key) ([]sectionRef, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
h, err := s.fresh(k)
|
||||
if err != nil || h == nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Entry.Pending != nil {
|
||||
return append([]sectionRef(nil), h.Entry.Pending.Sections...), nil
|
||||
}
|
||||
return append([]sectionRef(nil), h.Entry.Current.Sections...), nil
|
||||
}
|
||||
|
||||
// Known tells the store sections already written for a draft, after a restart (SectionWriter).
|
||||
func (s *ledgerStore) Known(k Key, refs []sectionRef) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.drafted[k] = append([]sectionRef(nil), refs...)
|
||||
}
|
||||
|
||||
func (s *ledgerStore) Save(ctx context.Context, v Version, current, clearPending bool, revision int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -381,7 +431,7 @@ func (s *ledgerStore) Save(ctx context.Context, v Version, current, clearPending
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var known []sectionRef
|
||||
known := append([]sectionRef(nil), s.drafted[k]...)
|
||||
for _, sr := range []*storedRecord{h.Entry.Current, h.Entry.Pending} {
|
||||
if sr != nil {
|
||||
known = append(known, sr.Sections...)
|
||||
|
||||
@@ -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", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "retract", "progress"}
|
||||
var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "open", "write", "remove", "order", "submit", "drop", "take-over", "import", "return", "withdraw", "retract", "progress"}
|
||||
|
||||
// HealthTool is the module's own tool the node-engine asks how it is.
|
||||
const HealthTool = "knowledge_health"
|
||||
@@ -92,6 +92,7 @@ func newKB(store Store) *KB {
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
||||
Settings: func() (Settings, error) { return readSettings(file) },
|
||||
Logf: logf,
|
||||
States: sdkStates{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,8 +756,20 @@ func schema(required []string, props map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
// Tools are the seat's verbs, each said in plain words: what it does and what it refuses. caller is who called,
|
||||
// as the runtime proves it.
|
||||
// as the runtime proves it. A verb that writes names the tool call it serves to the ledger while it runs (noteCall).
|
||||
func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
tools := verbTools(k, caller)
|
||||
for i := range tools {
|
||||
run := tools[i].Run
|
||||
tools[i].Run = func(m map[string]any) (any, error) {
|
||||
defer k.noteCall(caller(m), m)()
|
||||
return run(m)
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func verbTools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: Seat + ".ask",
|
||||
Description: "Ask what holds about something, in plain words — \"what holds about the merge window, and why\" — and get the " +
|
||||
@@ -982,33 +995,37 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
"its review asks changes for the whole-set rule cited, which its acceptance would break. On approve it is accepted (a record that needs the operator waits for the operator's " +
|
||||
"Approve), on changes it stays proposed with the findings for its author to revise, and when no judgement can be had " +
|
||||
"it stays proposed and says so. Nothing proposed is in force.",
|
||||
Input: schema([]string{"number"}, map[string]any{
|
||||
"kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line; a research effort's new version keeps the served title when absent"),
|
||||
"topic": text("for a decision: a topic of the reading order, such as the mesh"),
|
||||
"date": text("the day, YYYY-MM-DD (today when absent)"),
|
||||
"deciders": texts("who decided"),
|
||||
"clauses": texts("a decision's clauses in order, each \"**Lead.** the statement\""),
|
||||
"sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown; a research effort's new version keeps the served version's when absent",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{"name": text("such as Context"), "text": text("markdown")},
|
||||
"required": []string{"name", "text"}}},
|
||||
"links": map[string]any{"type": "array", "description": "its links: kind, to (\"0276\", \"0276 §4\", \"research 045\", \"issue 365\", a design's path), from (the clause it goes out from)",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{"kind": map[string]any{"type": "string", "enum": LinkKinds},
|
||||
"to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}},
|
||||
"homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"),
|
||||
"needs-operator": flag("it holds only once the operator approves it"),
|
||||
"brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " +
|
||||
"in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " +
|
||||
"not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " +
|
||||
"costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word",
|
||||
"properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"),
|
||||
"points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}},
|
||||
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
|
||||
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
|
||||
"why": text("one line: why this version"),
|
||||
}),
|
||||
Input: schema([]string{"number"}, proposeProps()),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections",
|
||||
"links", "homes", "needs-operator", "brief", "status", "insight", "why")
|
||||
p, err := proposalOf("propose", m, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Propose(caller(m), p)
|
||||
}},
|
||||
{Name: Seat + ".open",
|
||||
Description: "Open a draft of a reserved or held record, to write it one section at a time: its title, topic, date and " +
|
||||
"deciders, for a decision its clauses, its links and homes, whether it needs the operator and the brief its ask shows. " +
|
||||
"Called again by its author, it changes those. A held record's draft starts from the sections of the version being " +
|
||||
"worked on, so a section left alone costs nothing. Then write each section, and submit. Refused: a caller the runtime " +
|
||||
"could not prove, a number held of none, a draft by another author (take-over makes you its author), a number reserved " +
|
||||
"by another caller.",
|
||||
Input: schema([]string{"number"}, openProps()),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
p, err := proposalOf("open", m, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Open(caller(m), p)
|
||||
}},
|
||||
{Name: Seat + ".write",
|
||||
Description: "Write one section of your draft: its plain title and its markdown text, one call per section; a title already in " +
|
||||
"the draft is replaced. The text is kept at once, so the draft survives this session and another can take it over. " +
|
||||
"Refused: a section over 40 KiB (split it), a draft over 64 sections or 1 MiB, a draft by another author, no draft open.",
|
||||
Input: schema([]string{"number", "title", "text"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
|
||||
"title": text("the section's title, such as Context"), "text": text("the section's markdown")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("write", m, "kind", "number", "title", "text")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1016,39 +1033,114 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := ProposeAsk{Kind: key.Kind, Number: key.Number}
|
||||
for _, f := range []struct {
|
||||
k string
|
||||
p *string
|
||||
}{{"title", &p.Title}, {"topic", &p.Topic}, {"date", &p.Date}, {"status", &p.Status}, {"insight", &p.Insight}, {"why", &p.Why}} {
|
||||
if *f.p, err = a.str(f.k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, f := range []struct {
|
||||
k string
|
||||
p *[]string
|
||||
}{{"deciders", &p.Deciders}, {"clauses", &p.Clauses}, {"homes", &p.Homes}} {
|
||||
if *f.p, err = a.strs(f.k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if p.NeedsOperator, err = a.flag("needs-operator"); err != nil {
|
||||
title, err := a.str("title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decode(m["sections"], &p.Sections, "sections is a list of {name, text}"); err != nil {
|
||||
body, err := a.str("text")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil {
|
||||
return k.Write(caller(m), key, title, body)
|
||||
}},
|
||||
{Name: Seat + ".remove",
|
||||
Description: "Remove one section, by its title, from your draft of a record. The text it held stays in the record's history, " +
|
||||
"named by no version. Refused: a title the draft does not hold, a draft by another author, no draft open.",
|
||||
Input: schema([]string{"number", "title"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "title": text("the section's title")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("remove", m, "kind", "number", "title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m["brief"] != nil {
|
||||
p.Brief = &Brief{}
|
||||
if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Propose(caller(m), p)
|
||||
title, err := a.str("title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Remove(caller(m), key, title)
|
||||
}},
|
||||
{Name: Seat + ".order",
|
||||
Description: "Set the order of the sections of your draft: every title the draft holds, once, in the order the record reads. " +
|
||||
"Refused: a list that leaves out or adds a title, a draft by another author, no draft open.",
|
||||
Input: schema([]string{"number", "titles"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "titles": texts("every section's title, in order")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("order", m, "kind", "number", "titles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
titles, err := a.strs("titles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Order(caller(m), key, titles)
|
||||
}},
|
||||
{Name: Seat + ".submit",
|
||||
Description: "Make your draft a version of the record: every rule that judges one record or its prose is checked and every " +
|
||||
"reason of a refusal named at once, the draft kept as it was; on success the knowledge base reviews the version, a part " +
|
||||
"at a time. Takes why, one line. Refused: no draft open, a draft by another author, a caller the runtime could not prove.",
|
||||
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "why": text("one line: why this version")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("submit", m, "kind", "number", "why")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
why, err := a.str("why")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.SubmitDraft(caller(m), key, why)
|
||||
}},
|
||||
{Name: Seat + ".drop",
|
||||
Description: "Discard your draft of a record, with a reason in one line if you have one. The sections written stay in the " +
|
||||
"record's history, named by no version. A draft untouched for 14 days is dropped, and its author told. Refused: a draft " +
|
||||
"by another author, no draft open.",
|
||||
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "reason": text("one line")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("drop", m, "kind", "number", "reason")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reason, err := a.str("reason")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.DropDraft(caller(m), key, reason)
|
||||
}},
|
||||
{Name: Seat + ".take-over",
|
||||
Description: "Make yourself the author of a draft or a proposal another session wrote, with the reason in one line: only its " +
|
||||
"author writes a draft or revises a proposal. The previous author is told, and the reason goes in the next version's " +
|
||||
"message. Refused: no reason, a caller the runtime could not prove, a proposal waiting for the operator's Approve (its " +
|
||||
"author withdraws it first), a record with neither a draft nor a proposal.",
|
||||
Input: schema([]string{"number", "reason"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "reason": text("one line: why you take it over")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("take-over", m, "kind", "number", "reason")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reason, err := a.str("reason")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.TakeOver(caller(m), key, reason)
|
||||
}},
|
||||
{Name: Seat + ".import",
|
||||
Description: "Import hq's decision records and research efforts, read through the records module at the commit its checkout " +
|
||||
@@ -1222,3 +1314,86 @@ func decode(v any, into any, says string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// proposeProps are the arguments of propose; open takes the same but the sections, which are written one call each.
|
||||
func proposeProps() map[string]any {
|
||||
return map[string]any{
|
||||
"kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line; a research effort's new version keeps the served title when absent"),
|
||||
"topic": text("for a decision: a topic of the reading order, such as the mesh"),
|
||||
"date": text("the day, YYYY-MM-DD (today when absent)"),
|
||||
"deciders": texts("who decided"),
|
||||
"clauses": texts("a decision's clauses in order, each \"**Lead.** the statement\""),
|
||||
"sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown; a research effort's new version keeps the served version's when absent",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{"name": text("such as Context"), "text": text("markdown")},
|
||||
"required": []string{"name", "text"}}},
|
||||
"links": map[string]any{"type": "array", "description": "its links: kind, to (\"0276\", \"0276 §4\", \"research 045\", \"issue 365\", a design's path), from (the clause it goes out from)",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{"kind": map[string]any{"type": "string", "enum": LinkKinds},
|
||||
"to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}},
|
||||
"homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"),
|
||||
"needs-operator": flag("it holds only once the operator approves it"),
|
||||
"brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " +
|
||||
"in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " +
|
||||
"not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " +
|
||||
"costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word",
|
||||
"properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"),
|
||||
"points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}},
|
||||
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
|
||||
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
|
||||
"why": text("one line: why this version"),
|
||||
}
|
||||
}
|
||||
|
||||
func openProps() map[string]any {
|
||||
p := proposeProps()
|
||||
delete(p, "sections")
|
||||
return p
|
||||
}
|
||||
|
||||
// proposalOf reads the arguments of propose (with its sections) or of open (without them).
|
||||
func proposalOf(verb string, m map[string]any, withSections bool) (ProposeAsk, error) {
|
||||
takes := []string{"kind", "number", "title", "topic", "date", "deciders", "clauses", "links", "homes", "needs-operator", "brief", "status", "insight", "why"}
|
||||
if withSections {
|
||||
takes = append(takes, "sections")
|
||||
}
|
||||
a, err := readArgs(verb, m, takes...)
|
||||
if err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
p := ProposeAsk{Kind: key.Kind, Number: key.Number}
|
||||
for _, f := range []struct {
|
||||
k string
|
||||
p *string
|
||||
}{{"title", &p.Title}, {"topic", &p.Topic}, {"date", &p.Date}, {"status", &p.Status}, {"insight", &p.Insight}, {"why", &p.Why}} {
|
||||
if *f.p, err = a.str(f.k); err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
}
|
||||
for _, f := range []struct {
|
||||
k string
|
||||
p *[]string
|
||||
}{{"deciders", &p.Deciders}, {"clauses", &p.Clauses}, {"homes", &p.Homes}} {
|
||||
if *f.p, err = a.strs(f.k); err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
}
|
||||
if p.NeedsOperator, err = a.flag("needs-operator"); err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
if err := decode(m["sections"], &p.Sections, "sections is a list of {name, text}"); err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
if m["brief"] != nil {
|
||||
p.Brief = &Brief{}
|
||||
if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil {
|
||||
return ProposeAsk{}, err
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
|
||||
}
|
||||
// ADR 0290 rule 6's verbs and those added after it; progress says a design's state, which ADR 0424 §1 makes the
|
||||
// knowledge base's to work out (novox/hq issue 1947).
|
||||
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "retract", "progress"}) {
|
||||
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "open", "write", "remove", "order", "submit", "drop", "take-over", "import", "return", "withdraw", "retract", "progress"}) {
|
||||
t.Fatalf("the verbs are not ADR 0290 rule 6's and those added after it: %v", Verbs)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, Events) {
|
||||
|
||||
@@ -80,3 +80,28 @@ func (sdkPipe) Call(method string, req, answer any) error {
|
||||
}
|
||||
return json.Unmarshal(raw, answer)
|
||||
}
|
||||
|
||||
// callIDOf is the id of the tool call that was handed args, as the tool runner names it in the call's `_meta` under
|
||||
// buscalls.MetaCall: wired to the SDK where it offers it (a request to the SDK's owner: stdio.CallIDOf); until then no
|
||||
// call is named, and an operation carries the module's own account alone.
|
||||
var callIDOf = func(args map[string]any) string { return "" }
|
||||
|
||||
// noteCall holds the call a caller's write serves for as long as it runs, and ctxFor names it to the ledger: the tool
|
||||
// runner stamps the caller it proved for that call as the operation's author (ADR 0597 §7), never a name the caller or
|
||||
// the module gives. A caller's writes are serialised by the module, so one note per proven caller is the call in flight.
|
||||
func (k *KB) noteCall(caller string, args map[string]any) func() {
|
||||
id := callIDOf(args)
|
||||
if id == "" || !proven(caller) {
|
||||
return func() {}
|
||||
}
|
||||
k.calls.Store(caller, id)
|
||||
return func() { k.calls.Delete(caller) }
|
||||
}
|
||||
|
||||
// ctxFor is the context of a write by caller: the call it serves, when it serves one.
|
||||
func (k *KB) ctxFor(caller string) context.Context {
|
||||
if id, ok := k.calls.Load(caller); ok {
|
||||
return withCall(context.Background(), id.(string))
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
package main
|
||||
|
||||
// Writing a record section by section (novox/hq to-be 55 §4, ADR 0592 §17, §19, §20, ADR 0597 §11): a draft is a key of the
|
||||
// state `drafts` holding its author, the fields of the record and its sections as title, digest and the sequence of the
|
||||
// write-section that wrote each; `write` puts a section's text into the record's history at once, `submit` makes the
|
||||
// draft a version through the same rules `propose` is judged by.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The bounds of a draft (to-be 55 §4).
|
||||
const (
|
||||
MostDraftSections = 64
|
||||
MostDraftBytes = 1 << 20
|
||||
// DraftIdle is how long a draft untouched is counted in the health, and DraftDropped when it is dropped.
|
||||
DraftIdle = 7 * 24 * time.Hour
|
||||
DraftDropped = 14 * 24 * time.Hour
|
||||
)
|
||||
|
||||
const draftsState = "drafts"
|
||||
|
||||
// Draft is a record being written.
|
||||
type Draft struct {
|
||||
Kind string `json:"kind"`
|
||||
Number int `json:"number"`
|
||||
Author string `json:"author"`
|
||||
Opened time.Time `json:"opened"`
|
||||
Touched time.Time `json:"touched"`
|
||||
// From is the version the draft starts from (0 for a record not yet proposed).
|
||||
From int `json:"from"`
|
||||
// Fields are the record's fields and clauses and links as the draft carries them; its sections are Sections.
|
||||
Fields ProposeAsk `json:"fields"`
|
||||
Sections []sectionRef `json:"sections"`
|
||||
// TakenOver is the reason of the last take-over, carried in the next version's message.
|
||||
TakenOver string `json:"taken-over,omitempty"`
|
||||
}
|
||||
|
||||
// SectionWriter is a store that keeps a section's text the moment it is written: the ledger.
|
||||
type SectionWriter interface {
|
||||
// WriteSection appends the text as a write-section of the record and answers its ref.
|
||||
WriteSection(ctx context.Context, k Key, title, text string) (sectionRef, error)
|
||||
// SectionText is the text a ref names, held to its digest.
|
||||
SectionText(ctx context.Context, k Key, ref sectionRef) (string, error)
|
||||
// Working is the refs of the sections of the version a writer works on (the proposed one, else the served one).
|
||||
Working(ctx context.Context, k Key) ([]sectionRef, error)
|
||||
// Known tells the store the sections already written for a draft, so a version that carries them writes none again.
|
||||
Known(k Key, refs []sectionRef)
|
||||
}
|
||||
|
||||
func draftKey(k Key) string { return fmt.Sprintf("%s.%04d", k.Kind, k.Number) }
|
||||
|
||||
// writer is the store's section writer, or the refusal that the records are not yet on the ledger.
|
||||
func (k *KB) writer() (SectionWriter, error) {
|
||||
if w, ok := k.Store.(SectionWriter); ok {
|
||||
return w, nil
|
||||
}
|
||||
return nil, errors.New("the records are still kept in the database, which takes a record whole: propose it; writing a draft section by section waits for the move to the ledger")
|
||||
}
|
||||
|
||||
func (k *KB) draftState() (KV, error) {
|
||||
if k.States == nil {
|
||||
return nil, errors.New("the knowledge base has no state to keep a draft in")
|
||||
}
|
||||
return k.States.State(draftsState), nil
|
||||
}
|
||||
|
||||
// readDraft is the draft of a record and the revision it was read at; nil when there is none.
|
||||
func (k *KB) readDraft(key Key) (*Draft, uint64, error) {
|
||||
kv, err := k.draftState()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
e, err := kv.Get(draftKey(key))
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var d Draft
|
||||
if err := json.Unmarshal(e.Value, &d); err != nil {
|
||||
return nil, 0, fmt.Errorf("the draft of %s is not a draft: %w", key, err)
|
||||
}
|
||||
return &d, e.Revision, nil
|
||||
}
|
||||
|
||||
// keepDraft writes the draft at the revision read, retried at most three times on a lost race and then refused with the
|
||||
// reason (ADR 0597 §12).
|
||||
func (k *KB) keepDraft(d *Draft, revision uint64) error {
|
||||
kv, err := k.draftState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := draftKey(Key{d.Kind, d.Number})
|
||||
for try := 0; try < 3; try++ {
|
||||
if revision == 0 {
|
||||
_, err = kv.Create(key, d)
|
||||
} else {
|
||||
_, err = kv.Update(key, d, revision)
|
||||
}
|
||||
if !errors.Is(err, stdio.ErrChanged) {
|
||||
return err
|
||||
}
|
||||
cur, rev, rerr := k.readDraft(Key{d.Kind, d.Number})
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if cur != nil && cur.Author != d.Author {
|
||||
return fmt.Errorf("%s was taken over by %s while this was written", Key{d.Kind, d.Number}, cur.Author)
|
||||
}
|
||||
revision = rev
|
||||
}
|
||||
return fmt.Errorf("the draft of %s was written by another writer three times in a row; read it and write again", Key{d.Kind, d.Number})
|
||||
}
|
||||
|
||||
// draftOf is the draft the caller may write: it exists, and the caller is its author.
|
||||
func (k *KB) draftOf(caller string, key Key, verb string) (*Draft, uint64, error) {
|
||||
if !proven(caller) {
|
||||
return nil, 0, fmt.Errorf("%s needs a caller the runtime proved; this call is by %s. Nothing was written", verb, caller)
|
||||
}
|
||||
d, rev, err := k.readDraft(key)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if d == nil {
|
||||
return nil, 0, fmt.Errorf("%s has no draft: open it first. Nothing was written", key)
|
||||
}
|
||||
if d.Author != caller {
|
||||
return nil, 0, fmt.Errorf("%s is a draft by %s: only its author, proven by the runtime, writes it (this call is by %s); take-over makes you its author. Nothing was written", key, d.Author, caller)
|
||||
}
|
||||
return d, rev, nil
|
||||
}
|
||||
|
||||
// OpenAsk opens or changes a draft: the fields of a proposal, but its sections.
|
||||
type OpenAsk = ProposeAsk
|
||||
|
||||
// Opened is the answer to open.
|
||||
type Opened struct {
|
||||
Record string `json:"record"`
|
||||
Sections []string `json:"sections,omitempty"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// Open opens a draft of a reserved or held record, with its fields, clauses and links; called again by its author it
|
||||
// changes them. A held record's draft starts from the sections of the version a writer works on.
|
||||
func (k *KB) Open(caller string, a OpenAsk) (*Opened, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if !proven(caller) {
|
||||
return nil, fmt.Errorf("open needs a caller the runtime proved; this call is by %s. Nothing was opened", caller)
|
||||
}
|
||||
w, err := k.writer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !contains(Kinds, a.Kind) {
|
||||
return nil, fmt.Errorf("kind is decision or research, not %q; nothing was opened", a.Kind)
|
||||
}
|
||||
key := Key{a.Kind, a.Number}
|
||||
ctx := k.ctxFor(caller)
|
||||
held, err := k.Store.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the knowledge base cannot be read (%v); nothing was opened", err)
|
||||
}
|
||||
if held == nil {
|
||||
return nil, fmt.Errorf("%s is held of none: reserve a number first. Nothing was opened", key)
|
||||
}
|
||||
if held.Current.Status == StatusReserved && proven(held.Current.ReservedBy) && held.Current.ReservedBy != caller {
|
||||
return nil, fmt.Errorf("%s was reserved by %s: only who reserved a number writes under it. Nothing was opened", key, held.Current.ReservedBy)
|
||||
}
|
||||
d, rev, err := k.readDraft(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := k.now()
|
||||
switch {
|
||||
case d != nil && d.Author != caller:
|
||||
return nil, fmt.Errorf("%s is a draft by %s: only its author writes it (this call is by %s); take-over makes you its author. Nothing was opened", key, d.Author, caller)
|
||||
case d == nil:
|
||||
sections, err := w.Working(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d = &Draft{Kind: key.Kind, Number: key.Number, Author: caller, Opened: now, From: held.Versions, Sections: sections}
|
||||
}
|
||||
a.Sections = nil
|
||||
// A research effort's new version keeps the served title, date and deciders where none are given (issue 1966).
|
||||
if s := held.Current; a.Kind == KindResearch && s.Status != StatusReserved {
|
||||
if strings.TrimSpace(a.Title) == "" {
|
||||
a.Title = s.Title
|
||||
}
|
||||
if strings.TrimSpace(a.Date) == "" {
|
||||
a.Date = s.Date
|
||||
}
|
||||
if len(a.Deciders) == 0 {
|
||||
a.Deciders = append([]string(nil), s.Deciders...)
|
||||
}
|
||||
}
|
||||
d.Fields, d.Touched = a, now
|
||||
if err := k.keepDraft(d, rev); err != nil {
|
||||
return nil, fmt.Errorf("the draft of %s could not be kept (%v); nothing was opened", key, err)
|
||||
}
|
||||
k.emit(EventOpened, held.Current, caller, fmt.Sprintf("a draft opened from version %d", d.From), d.From)
|
||||
return &Opened{Record: key.String(), Sections: titlesOf(d.Sections), Says: fmt.Sprintf("A draft of %s is open, by you, starting from version %d. Write its sections one call each; submit makes it a version.", key, d.From)}, nil
|
||||
}
|
||||
|
||||
func titlesOf(refs []sectionRef) []string {
|
||||
out := make([]string, len(refs))
|
||||
for i, r := range refs {
|
||||
out[i] = r.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Written is the answer to write, remove and order.
|
||||
type Written struct {
|
||||
Record string `json:"record"`
|
||||
Sections []string `json:"sections"`
|
||||
Bytes int `json:"bytes"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
func (k *KB) draftBytes(w SectionWriter, ctx context.Context, d *Draft) (int, error) {
|
||||
n := 0
|
||||
for _, r := range d.Sections {
|
||||
t, err := w.SectionText(ctx, Key{d.Kind, d.Number}, r)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n += len(t)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Write writes one section of the draft: its title and its text, into the record's history at once.
|
||||
func (k *KB) Write(caller string, key Key, title, text string) (*Written, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
w, err := k.writer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, rev, err := k.draftOf(caller, key, "write")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, errors.New("write needs the section's title. Nothing was written")
|
||||
}
|
||||
if len(text) > MostSectionBytes {
|
||||
return nil, fmt.Errorf("the section %q is %d bytes; a section holds at most %d: split it. Nothing was written", title, len(text), MostSectionBytes)
|
||||
}
|
||||
ctx := k.ctxFor(caller)
|
||||
at := -1
|
||||
for i, r := range d.Sections {
|
||||
if r.Name == title {
|
||||
at = i
|
||||
}
|
||||
}
|
||||
if at < 0 && len(d.Sections) >= MostDraftSections {
|
||||
return nil, fmt.Errorf("a draft holds at most %d sections. Nothing was written", MostDraftSections)
|
||||
}
|
||||
ref, err := w.WriteSection(ctx, key, title, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the section %q could not be written (%v); nothing was written", title, err)
|
||||
}
|
||||
if at >= 0 {
|
||||
d.Sections[at] = ref
|
||||
} else {
|
||||
d.Sections = append(d.Sections, ref)
|
||||
}
|
||||
n, err := k.draftBytes(w, ctx, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > MostDraftBytes {
|
||||
return nil, fmt.Errorf("a draft holds at most %d bytes; this would hold %d. Nothing was kept in the draft (the section stays in the history, named by no version)", MostDraftBytes, n)
|
||||
}
|
||||
d.Touched = k.now()
|
||||
if err := k.keepDraft(d, rev); err != nil {
|
||||
return nil, fmt.Errorf("the draft of %s could not be kept (%v)", key, err)
|
||||
}
|
||||
k.emit(EventWritten, &Record{Kind: key.Kind, Number: key.Number}, caller, fmt.Sprintf("the section %q, %s", title, ref.Digest), 0)
|
||||
return &Written{Record: key.String(), Sections: titlesOf(d.Sections), Bytes: n, Says: fmt.Sprintf("The section %q is written (operation %d).", title, ref.Sequence)}, nil
|
||||
}
|
||||
|
||||
// Remove removes one section from the draft.
|
||||
func (k *KB) Remove(caller string, key Key, title string) (*Written, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
w, err := k.writer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, rev, err := k.draftOf(caller, key, "remove")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var kept []sectionRef
|
||||
for _, r := range d.Sections {
|
||||
if r.Name != title {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(d.Sections) {
|
||||
return nil, fmt.Errorf("the draft of %s has no section %q (it has %s). Nothing was removed", key, title, strings.Join(titlesOf(d.Sections), ", "))
|
||||
}
|
||||
d.Sections, d.Touched = kept, k.now()
|
||||
if err := k.keepDraft(d, rev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, _ := k.draftBytes(w, k.ctxFor(caller), d)
|
||||
k.emit(EventRemoved, &Record{Kind: key.Kind, Number: key.Number}, caller, fmt.Sprintf("the section %q", title), 0)
|
||||
return &Written{Record: key.String(), Sections: titlesOf(d.Sections), Bytes: n, Says: fmt.Sprintf("The section %q is removed from the draft.", title)}, nil
|
||||
}
|
||||
|
||||
// Order sets the sections' order: every title the draft holds, once.
|
||||
func (k *KB) Order(caller string, key Key, titles []string) (*Written, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
w, err := k.writer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, rev, err := k.draftOf(caller, key, "order")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
have := titlesOf(d.Sections)
|
||||
a, b := append([]string(nil), have...), append([]string(nil), titles...)
|
||||
sort.Strings(a)
|
||||
sort.Strings(b)
|
||||
if strings.Join(a, "\x00") != strings.Join(b, "\x00") {
|
||||
return nil, fmt.Errorf("order names every section of the draft once; the draft holds %s. Nothing was reordered", strings.Join(have, ", "))
|
||||
}
|
||||
byName := map[string]sectionRef{}
|
||||
for _, r := range d.Sections {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
d.Sections = d.Sections[:0:0]
|
||||
for _, t := range titles {
|
||||
d.Sections = append(d.Sections, byName[t])
|
||||
}
|
||||
d.Touched = k.now()
|
||||
if err := k.keepDraft(d, rev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, _ := k.draftBytes(w, k.ctxFor(caller), d)
|
||||
k.emit(EventOrdered, &Record{Kind: key.Kind, Number: key.Number}, caller, strings.Join(titles, ", "), 0)
|
||||
return &Written{Record: key.String(), Sections: titles, Bytes: n, Says: "The sections are in the order given."}, nil
|
||||
}
|
||||
|
||||
// Dropped is the answer to drop.
|
||||
type Dropped struct {
|
||||
Record string `json:"record"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// DropDraft discards the draft; its sections stay in the history, named by no version.
|
||||
func (k *KB) DropDraft(caller string, key Key, reason string) (*Dropped, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if _, err := k.writer(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := k.draftOf(caller, key, "drop"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.dropped(caller, key, strings.TrimSpace(reason), "dropped by its author")
|
||||
}
|
||||
|
||||
func (k *KB) dropped(by string, key Key, reason, what string) (*Dropped, error) {
|
||||
kv, err := k.draftState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kv.Delete(draftKey(key)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reason != "" {
|
||||
what += ": " + reason
|
||||
}
|
||||
k.emit(EventDropped, &Record{Kind: key.Kind, Number: key.Number}, by, what, 0)
|
||||
return &Dropped{Record: key.String(), Says: "The draft of " + key.String() + " is gone."}, nil
|
||||
}
|
||||
|
||||
// TakenOver is the answer to take-over.
|
||||
type TakenOver struct {
|
||||
Record string `json:"record"`
|
||||
From string `json:"from"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// TakeOver makes the caller the author of a draft or of a proposal, with a reason; the reason is carried in the next
|
||||
// version's message and the previous author is told by the event `taken-over`. A proposal waiting for the operator's
|
||||
// Approve is not taken over: its author withdraws it first.
|
||||
func (k *KB) TakeOver(caller string, key Key, reason string) (*TakenOver, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if !proven(caller) {
|
||||
return nil, fmt.Errorf("take-over needs a caller the runtime proved; this call is by %s. Nothing was taken over", caller)
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return nil, errors.New("take-over needs the reason, in one line. Nothing was taken over")
|
||||
}
|
||||
if _, err := k.writer(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := k.ctxFor(caller)
|
||||
held, err := k.Store.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if held == nil {
|
||||
return nil, fmt.Errorf("%s is held of none. Nothing was taken over", key)
|
||||
}
|
||||
if held.Working().Ask != nil && held.Working().Ask.Said == "waiting" {
|
||||
return nil, fmt.Errorf("%s waits for the operator's Approve: its author withdraws it first. Nothing was taken over", key)
|
||||
}
|
||||
d, rev, err := k.readDraft(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var from string
|
||||
switch {
|
||||
case d != nil:
|
||||
from = d.Author
|
||||
d.Author, d.TakenOver, d.Touched = caller, reason, k.now()
|
||||
if err := k.keepDraft(d, rev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if held.Current.Status == StatusReserved {
|
||||
// Only who reserved a number proposes under it: the number is the new author's.
|
||||
w := held.Current.Clone()
|
||||
w.ReservedBy = caller
|
||||
if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: "taken over from " + from + ": " + reason, Record: w}, true, false, held.Revision); err != nil {
|
||||
return nil, fmt.Errorf("the number of %s could not be handed over (%v)", key, err)
|
||||
}
|
||||
}
|
||||
case held.Working().Status == StatusProposed:
|
||||
w := held.Working().Clone()
|
||||
from = w.Author
|
||||
w.Author = caller
|
||||
if !contains(w.Writers, caller) {
|
||||
w.Writers = append(w.Writers, caller)
|
||||
}
|
||||
current := held.Pending == nil
|
||||
if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: "taken over from " + from + ": " + reason, Record: w}, current, false, held.Revision); err != nil {
|
||||
return nil, fmt.Errorf("%s could not be taken over (%v)", key, err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%s has no draft and is %s, not proposed: there is nothing to take over", key, held.Working().Status)
|
||||
}
|
||||
if from == caller {
|
||||
return nil, fmt.Errorf("%s is already yours", key)
|
||||
}
|
||||
k.emit(EventTakenOver, held.Current, caller, fmt.Sprintf("from %s: %s", from, reason), held.Versions)
|
||||
return &TakenOver{Record: key.String(), From: from, Says: fmt.Sprintf("You are the author of %s now; %s is told. The reason goes in its next version's message.", key, from)}, nil
|
||||
}
|
||||
|
||||
// SubmitDraft makes the draft a version: it is judged by the rules a proposal is, every reason of a refusal named at
|
||||
// once and the draft kept as it was, and the review asked.
|
||||
func (k *KB) SubmitDraft(caller string, key Key, why string) (*Proposed, error) {
|
||||
w, err := k.writer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.mu.Lock()
|
||||
d, _, err := k.draftOf(caller, key, "submit")
|
||||
k.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := d.Fields
|
||||
a.Kind, a.Number = key.Kind, key.Number
|
||||
if strings.TrimSpace(why) != "" {
|
||||
a.Why = why
|
||||
} else if d.TakenOver != "" && a.Why == "" {
|
||||
a.Why = "revised after a take-over: " + d.TakenOver
|
||||
}
|
||||
ctx := k.ctxFor(caller)
|
||||
for _, r := range d.Sections {
|
||||
t, err := w.SectionText(ctx, key, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Sections = append(a.Sections, Section{Name: r.Name, Text: t})
|
||||
}
|
||||
w.Known(key, d.Sections)
|
||||
out, err := k.Propose(caller, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if _, err := k.dropped(caller, key, "", "submitted"); err != nil {
|
||||
k.logf("[mesh-knowledge] the draft of %s could not be removed after it was submitted: %v", key, err)
|
||||
}
|
||||
k.emit(EventSubmitted, &Record{Kind: key.Kind, Number: key.Number, Status: out.Status}, caller, fmt.Sprintf("version %d, %d sections", out.Version, len(a.Sections)), out.Version)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ledgerWorld is a knowledge base over the ledger played here, holding the fixture's records.
|
||||
func ledgerWorld(t *testing.T) (*world, *fakeLedger) {
|
||||
t.Helper()
|
||||
f := newFakeLedger(t)
|
||||
w := imported(t, newLedgerStore(f, newFakeStates()))
|
||||
w.k.States = newFakeStates()
|
||||
return w, f
|
||||
}
|
||||
|
||||
func (f *fakeLedger) count(op string) int {
|
||||
n := 0
|
||||
for _, a := range f.applied {
|
||||
if a.Operation == op {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A record is written in a draft one section per call and submitted as a version (to-be 55 §4, AC48): each section's text
|
||||
// is in the history once, at the write; submit writes none again; only the draft's author writes it; take-over makes
|
||||
// another the author, with the reason in the next version's message; a refusal at submit names every reason and keeps the draft.
|
||||
func TestADraftIsWrittenOneSectionAtATimeAndSubmittedAsAVersion(t *testing.T) {
|
||||
w, f := ledgerWorld(t)
|
||||
r, _ := w.k.ReserveNumber("author@g14", KindDecision, "a test record")
|
||||
p := valid(r.Number)
|
||||
secs := p.Sections
|
||||
p.Sections = nil
|
||||
key := Key{KindDecision, r.Number}
|
||||
|
||||
if _, err := w.k.Open("", p); err == nil || !strings.Contains(err.Error(), "proved") {
|
||||
t.Fatalf("an unproven caller opened a draft: %v", err)
|
||||
}
|
||||
if _, err := w.k.Open("other@g14", p); err == nil || !strings.Contains(err.Error(), "reserved by author@g14") {
|
||||
t.Fatalf("another caller opened under a reserved number: %v", err)
|
||||
}
|
||||
if _, err := w.k.Write("author@g14", key, "Context", "x"); err == nil || !strings.Contains(err.Error(), "no draft") {
|
||||
t.Fatalf("a write with no draft: %v", err)
|
||||
}
|
||||
if _, err := w.k.Open("author@g14", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := f.count(OpWriteSection)
|
||||
for _, s := range secs[:3] {
|
||||
if _, err := w.k.Write("author@g14", key, s.Name, s.Text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if f.count(OpWriteSection) != before+3 {
|
||||
t.Fatalf("the text was not in the history at the write")
|
||||
}
|
||||
// Another session cannot write, remove, order, submit or drop it; the refusal names the author.
|
||||
for name, do := range map[string]func() error{
|
||||
"write": func() error { _, err := w.k.Write("other@g14", key, "Context", "y"); return err },
|
||||
"remove": func() error { _, err := w.k.Remove("other@g14", key, "Context"); return err },
|
||||
"order": func() error { _, err := w.k.Order("other@g14", key, []string{"Context"}); return err },
|
||||
"submit": func() error { _, err := w.k.SubmitDraft("other@g14", key, ""); return err },
|
||||
"drop": func() error { _, err := w.k.DropDraft("other@g14", key, ""); return err },
|
||||
} {
|
||||
if err := do(); err == nil || !strings.Contains(err.Error(), "author@g14") {
|
||||
t.Errorf("%s by another session: %v", name, err)
|
||||
}
|
||||
}
|
||||
// The fourth section, then the order is set and one section is replaced.
|
||||
if _, err := w.k.Write("author@g14", key, secs[3].Name, secs[3].Text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.k.Order("author@g14", key, []string{"Context", "Considered Options"}); err == nil {
|
||||
t.Fatal("an order that leaves sections out was taken")
|
||||
}
|
||||
if _, err := w.k.Remove("author@g14", key, "No such"); err == nil {
|
||||
t.Fatal("a section the draft lacks was removed")
|
||||
}
|
||||
if _, err := w.k.Write("author@g14", key, "Consequences", "Merges land sooner, and later too."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Take-over: the reason is carried into the next version.
|
||||
if _, err := w.k.TakeOver("other@g14", key, ""); err == nil {
|
||||
t.Fatal("a take-over with no reason")
|
||||
}
|
||||
if to, err := w.k.TakeOver("other@g14", key, "the author's session is gone"); err != nil || to.From != "author@g14" {
|
||||
t.Fatalf("%v %+v", err, to)
|
||||
}
|
||||
if _, err := w.k.Write("author@g14", key, "Context", "z"); err == nil {
|
||||
t.Fatal("the previous author still writes")
|
||||
}
|
||||
writes := f.count(OpWriteSection)
|
||||
out, err := w.k.SubmitDraft("other@g14", key, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.count(OpWriteSection) != writes {
|
||||
t.Errorf("submit wrote %d sections again", f.count(OpWriteSection)-writes)
|
||||
}
|
||||
// Version 3: the reservation, the take-over, the proposal.
|
||||
if out.Status != StatusProposed || out.Version != 3 {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
e := mustGet(t, w.k.Store, key)
|
||||
if len(e.Current.Sections) != 4 || e.Current.Section("Consequences") != "Merges land sooner, and later too." || !strings.Contains(e.Current.Author, "other@g14") {
|
||||
t.Fatalf("%+v", e.Current.Sections)
|
||||
}
|
||||
if len(w.said.of(EventSubmitted)) != 1 || len(w.said.of(EventTakenOver)) != 1 || len(w.said.of(EventWritten)) < 5 || len(w.said.of(EventOpened)) != 1 {
|
||||
t.Fatalf("events %v", w.said.events)
|
||||
}
|
||||
if d, _, _ := w.k.readDraft(key); d != nil {
|
||||
t.Fatal("the draft stands after its submit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASubmitRefusedKeepsTheDraftAndANewVersionOfAnEffortStartsFromItsSections(t *testing.T) {
|
||||
w, f := ledgerWorld(t)
|
||||
r, _ := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
first := effortAsk(r.Number, StatusActive, Section{Name: "Overview", Text: "What it would be."}, Section{Name: "Findings", Text: "One."})
|
||||
w.accepted(t, "author@g14", first)
|
||||
key := Key{KindResearch, r.Number}
|
||||
|
||||
// Closed with no section written: the draft starts from the served sections, so no text is written at all.
|
||||
closing := ProposeAsk{Kind: KindResearch, Number: r.Number, Status: StatusGraduated, Links: append(first.Links, LinkAsk{Kind: LinkBecame, To: "0290"})}
|
||||
d, err := w.k.Open("author@g14", closing)
|
||||
if err != nil || strings.Join(d.Sections, ",") != "Overview,Findings" {
|
||||
t.Fatalf("%v %+v", err, d)
|
||||
}
|
||||
writes := f.count(OpWriteSection)
|
||||
if _, err := w.k.SubmitDraft("author@g14", key, "graduated"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.count(OpWriteSection) != writes {
|
||||
t.Fatal("a version that changed no section wrote a section")
|
||||
}
|
||||
w.reviewAll(t)
|
||||
if e := mustGet(t, w.k.Store, key); e.Current.Status != StatusGraduated || len(e.Current.Sections) != 2 {
|
||||
t.Fatalf("%s %v", e.Current.Status, e.Current.Sections)
|
||||
}
|
||||
|
||||
// A decision that breaks a rule: every reason at once, the draft kept as it was.
|
||||
n, _ := w.k.ReserveNumber("author@g14", KindDecision, "a test record")
|
||||
p := valid(n.Number)
|
||||
p.Sections, p.Topic = nil, "no such topic"
|
||||
k := Key{KindDecision, n.Number}
|
||||
_, _ = w.k.Open("author@g14", p)
|
||||
_, _ = w.k.Write("author@g14", k, "Context", "short")
|
||||
if _, err := w.k.SubmitDraft("author@g14", k, ""); err == nil || !strings.Contains(err.Error(), "refused by") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if d, _, _ := w.k.readDraft(k); d == nil || len(d.Sections) != 1 {
|
||||
t.Fatal("a refused submit lost the draft")
|
||||
}
|
||||
if _, err := w.k.DropDraft("author@g14", k, "not now"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d, _, _ := w.k.readDraft(k); d != nil {
|
||||
t.Fatal("a dropped draft stands")
|
||||
}
|
||||
}
|
||||
|
||||
// Until the records move to the ledger, the database takes a record whole: a draft is refused saying so.
|
||||
func TestADraftIsRefusedWhileTheRecordsAreInTheDatabase(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
w.k.States = newFakeStates()
|
||||
if _, err := w.k.Open("author@g14", ProposeAsk{Kind: KindDecision, Number: 1}); err == nil || !strings.Contains(err.Error(), "propose it") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,13 @@
|
||||
"history",
|
||||
"reserve",
|
||||
"propose",
|
||||
"open",
|
||||
"write",
|
||||
"remove",
|
||||
"order",
|
||||
"submit",
|
||||
"drop",
|
||||
"take-over",
|
||||
"import",
|
||||
"return",
|
||||
"withdraw",
|
||||
@@ -126,6 +133,13 @@
|
||||
"history",
|
||||
"reserve",
|
||||
"propose",
|
||||
"open",
|
||||
"write",
|
||||
"remove",
|
||||
"order",
|
||||
"submit",
|
||||
"drop",
|
||||
"take-over",
|
||||
"import",
|
||||
"return",
|
||||
"withdraw",
|
||||
|
||||
Reference in New Issue
Block a user