diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go index f0e3bbf5..72737d0d 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go @@ -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) + } + } + }) }) } } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index 4cb35b14..e41ef976 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -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) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore.go index a145dd20..97985c51 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore.go @@ -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...) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 9ece161b..5bf79f4a 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -37,7 +37,7 @@ import ( const Seat = "knowledge-base" // Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6). -var Verbs = []string{"ask", "get", "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 +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 50f6dcfa..643757ca 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -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) { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/pipe.go b/modules/mesh-knowledge/cmd/mesh-knowledge/pipe.go index 66cb8f8a..cb64add8 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/pipe.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/pipe.go @@ -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() +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go b/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go new file mode 100644 index 00000000..92673137 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go @@ -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 +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/writing_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/writing_test.go new file mode 100644 index 00000000..0c9cb00f --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/writing_test.go @@ -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) + } +} diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index 314de843..e7ed2df5 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -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",