Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/import.go
T

548 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// The import (novox/hq ADR 0290 rule 8, to-be 55 §8): every decision record and research effort of hq, read at
// one commit through the records module, written whole at its own number as its first version, naming the commit
// and path it came from. It creates what is absent and never overwrites; it never asks the issue tracker for a
// number, so no counter moves; a second run changes nothing and says so. A dry run reads everything, writes
// nothing, and says what it would do. A record whose Decision section is prose is written with no clauses and
// waits for the drafting and confirming of its clauses (draft.go).
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"sync"
)
// The folders of hq the import reads.
const (
DecisionsFolder = "02-DECISIONS"
ResearchFolder = "01-RESEARCH"
)
// LinkPending is the source of a `changes` link the import read from hq's supersedes-in-part, which named the
// record and not the clause: which clause it changes is drafted and confirmed by the judgement (to-be 55 §7).
const LinkPending = "pending"
var commitPrefix = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
// ImportAsk is what an import takes. Refresh also repairs a record held whose only versions are the imports' own
// (hq issue 487): it is read again at the commit and written as a new version naming what was lost and why. A
// record anyone changed after its import is never touched.
type ImportAsk struct {
Commit string
DryRun bool
Refresh bool
}
// ImportRow is what the import did, or would do, with one record.
type ImportRow struct {
Record string `json:"record"`
Action string `json:"action"`
Status string `json:"status,omitempty"`
From string `json:"from"`
Why string `json:"why,omitempty"`
}
// The actions of an import.
const (
ImportCreate = "create"
ImportHeld = "held"
ImportRefused = "refused"
ImportWould = "would-create"
// A refresh's: a record repaired, or one that would be; and one changed here since its import, left alone.
ImportRefresh = "refresh"
ImportWouldRefresh = "would-refresh"
ImportChangedSince = "changed-since"
)
// Imported is the answer to an import.
type Imported struct {
DryRun bool `json:"dry-run"`
Commit string `json:"commit"`
Read int `json:"read"`
Created int `json:"created"`
Held int `json:"held"`
Refused int `json:"refused"`
// A refresh's counts: repaired (or would be), read again and found the same, and changed here since their
// import, which a refresh never touches.
Refreshed int `json:"refreshed,omitempty"`
Unchanged int `json:"unchanged,omitempty"`
ChangedSince int `json:"changed-since,omitempty"`
Clauses int `json:"written-clauses"`
ToDraft int `json:"prose-decisions"`
Rows []ImportRow `json:"rows"`
Odd []string `json:"odd,omitempty"`
Cut string `json:"cut,omitempty"`
Said string `json:"said"`
Findings []Finding `json:"whole-set-findings,omitempty"`
Unresolved int `json:"unresolved-links"`
}
// MostImportRows is how many rows an import answers; the counts are always whole.
const MostImportRows = 60
const importReaders = 8
type importCandidate struct {
path string
parsed *ParsedRecord
err error
}
// Import reads hq at its commit and writes every record it does not hold.
func (k *KB) Import(caller string, a ImportAsk, records Records) (*Imported, error) {
commit, err := records.Standing()
if err != nil {
return nil, fmt.Errorf("hq cannot be read through the records module (%v); nothing was imported", err)
}
want := strings.TrimSpace(a.Commit)
if want != "" && !commitPrefix.MatchString(want) {
return nil, errors.New("commit is a commit's id or a prefix of it, 7 to 40 hexadecimal digits; nothing was imported")
}
if want != "" && !strings.HasPrefix(commit, want) {
return nil, fmt.Errorf("the record stands at hq commit %s, not %s: bring it there (records_sync), or name the commit it "+
"stands at. Nothing was imported", short(commit), want)
}
_, docs, err := records.List(DecisionsFolder)
if err != nil {
return nil, fmt.Errorf("hq's %s cannot be listed (%v); nothing was imported", DecisionsFolder, err)
}
efforts, _, err := records.List(ResearchFolder)
if err != nil {
return nil, fmt.Errorf("hq's %s cannot be listed (%v); nothing was imported", ResearchFolder, err)
}
var cands []*importCandidate
for _, d := range docs {
if adrFile.MatchString(d) {
cands = append(cands, &importCandidate{path: DecisionsFolder + "/" + d})
}
}
for _, f := range efforts {
if regexp.MustCompile(`^\d{3}-`).MatchString(f) {
cands = append(cands, &importCandidate{path: ResearchFolder + "/" + f})
}
}
if len(cands) == 0 {
// The records left hq (hq issue 426): what it held is here, and an import from it would say "nothing
// created" as a success (hq issue 488).
return nil, fmt.Errorf("hq holds no decision record or research effort at commit %s: its %s and %s hold none. "+
"The records live in the knowledge base now, and nothing was imported", short(commit), DecisionsFolder, ResearchFolder)
}
// Read and parse, a few at a time, every document at the one commit.
var moved error
var movedMu sync.Mutex
read := func(path string) (string, error) {
text, at, err := records.Read(path)
if err != nil {
return "", err
}
if at != commit {
movedMu.Lock()
moved = fmt.Errorf("hq moved while it was read: it stood at %s and %s was read at %s. Run the import again", short(commit), path, short(at))
movedMu.Unlock()
}
return text, nil
}
var wg sync.WaitGroup
sem := make(chan struct{}, importReaders)
for _, c := range cands {
wg.Add(1)
sem <- struct{}{}
go func(c *importCandidate) {
defer wg.Done()
defer func() { <-sem }()
if strings.HasPrefix(c.path, DecisionsFolder) {
text, err := read(c.path)
if err != nil {
c.err = fmt.Errorf("could not be read: %v", err)
return
}
c.parsed, c.err = ParseDecision(c.path, text)
return
}
_, names, err := records.List(c.path)
if err != nil {
c.err = fmt.Errorf("could not be listed: %v", err)
return
}
texts := map[string]string{}
for _, n := range names {
text, err := read(c.path + "/" + n)
if err != nil {
c.err = fmt.Errorf("%s could not be read: %v", n, err)
return
}
texts[n] = text
}
c.parsed, c.err = ParseResearch(docName(c.path), texts)
}(c)
}
wg.Wait()
if moved != nil {
return nil, fmt.Errorf("%v; nothing was imported", moved)
}
out := &Imported{DryRun: a.DryRun, Commit: commit, Rows: []ImportRow{}}
byKey := map[Key][]*importCandidate{}
for _, c := range cands {
if c.parsed != nil {
byKey[c.parsed.Record.Key()] = append(byKey[c.parsed.Record.Key()], c)
}
}
for key, cs := range byKey {
if len(cs) > 1 {
paths := make([]string, len(cs))
for i, c := range cs {
paths[i] = c.path
}
for _, c := range cs {
c.err = fmt.Errorf("%s is the number of %s: a number names one record", key, strings.Join(paths, " and "))
}
}
}
sort.Slice(cands, func(i, j int) bool { return cands[i].path < cands[j].path })
ctx := context.Background()
k.mu.Lock()
defer k.mu.Unlock()
known := map[Key]bool{}
for key := range byKey {
known[key] = true
}
all, err := k.Store.All(ctx)
if err != nil {
return nil, fmt.Errorf("the knowledge base cannot be read (%v); nothing was imported", err)
}
held := map[Key]*Entry{}
for _, e := range all {
held[e.Current.Key()] = e
known[e.Current.Key()] = true
}
at := k.now()
var rows []ImportRow
for _, c := range cands {
row := ImportRow{From: c.path}
if c.err != nil {
row.Action, row.Why = ImportRefused, c.err.Error()
row.Record = c.path
out.Refused++
rows = append(rows, row)
continue
}
r := c.parsed.Record
row.Record, row.Status = r.Key().String(), r.Status
out.Read++
out.Odd = append(out.Odd, c.parsed.Odd...)
for i, l := range r.Links {
if to, ok := l.To.Record(); ok && !known[to] {
r.Links[i].Unresolved = true
if held[r.Key()] == nil {
out.Unresolved++
out.Odd = append(out.Odd, fmt.Sprintf("%s %s %s, which hq does not hold at this commit", r.Key(), l.Kind, to))
}
}
if l.Kind == LinkChanges && l.To.Clause == 0 {
r.Links[i].Source = LinkPending
}
}
r.Source = "hq@" + commit + ":" + c.path
if e := held[r.Key()]; e != nil {
if !a.Refresh {
row.Action = ImportHeld
out.Held++
continue
}
k.refresh(ctx, a, e, r, c.path, commit, caller, &row, out)
if row.Action != ImportHeld {
rows = append(rows, row)
}
continue
}
out.Clauses += len(r.Clauses)
if r.Drafting != nil {
out.ToDraft++
}
row.Action = ImportWould
if !a.DryRun {
err := k.Store.Create(ctx, Version{By: caller, At: at, Why: "imported from hq at " + short(commit) + ": " + c.path, Record: r})
switch {
case errors.Is(err, ErrExists):
row.Action = ImportHeld
out.Held++
continue
case err != nil:
row.Action, row.Why = ImportRefused, err.Error()
out.Refused++
rows = append(rows, row)
continue
}
row.Action = ImportCreate
k.emit(EventImported, r, caller, "imported from hq at "+short(commit), 1)
}
out.Created++
rows = append(rows, row)
}
if len(rows) > MostImportRows {
out.Cut = fmt.Sprintf("%d more row(s) are not shown; the counts are whole", len(rows)-MostImportRows)
rows = rows[:MostImportRows]
}
out.Rows = append(out.Rows, rows...)
if len(out.Odd) > 40 {
out.Odd = append(out.Odd[:40], fmt.Sprintf("and %d more", len(out.Odd)-40))
}
verb, again := "created", "refreshed"
if a.DryRun {
verb, again = "would create", "would refresh"
}
out.Said = fmt.Sprintf("Read %d records of hq at commit %s: %s %d, already held %d, refused %d; %d clauses written as such, "+
"%d decisions written as prose wait for their clauses to be drafted and confirmed.",
out.Read, short(commit), verb, out.Created, out.Held+out.Refreshed+out.Unchanged+out.ChangedSince, out.Refused, out.Clauses, out.ToDraft)
if a.Refresh {
out.Said += fmt.Sprintf(" Of those held: %s %d that only the import ever wrote, %d read again and the same, %d changed here "+
"since their import and left as they are.", again, out.Refreshed, out.Unchanged, out.ChangedSince)
}
switch {
case a.DryRun:
out.Said += " Nothing was written: a dry run."
case out.Created == 0 && out.Refreshed == 0 && out.Refused == 0:
out.Said += " Nothing changed: every record is already held as hq says it."
case out.Created == 0 && out.Refreshed == 0:
out.Said += " Nothing changed: every record not refused is already held."
}
if !a.DryRun && out.Created+out.Refreshed > 0 {
out.Findings = k.judgeSet(ctx)
}
return out, nil
}
// draftedHere is the why of a version the knowledge base wrote itself while drafting and confirming clauses, or
// judging which clause a change of hq's names (draft.go): the import's side of a record, not anyone's change.
var draftedHere = regexp.MustCompile(`^(?:\d+ clauses drafted by |\d+ of \d+ drafted clauses confirmed by |drafting failed: |` +
`confirming failed: |no Decision section|which clause of .+ it changes drafted: |that it changes .+ (?:was not confirmed: |confirmed by )|` +
`§\d+ changed by .+ \(as hq said, its clause judged and confirmed\)$)`)
// importedOnly says whether every version of a record was written by the import, a refresh of it, or the
// knowledge base's own drafting and confirming of its clauses: nobody has changed it since it was read from hq. A
// return, a correction, an acceptance over it or an insight is someone's change.
func importedOnly(vs []Version) bool {
if len(vs) == 0 {
return false
}
for _, v := range vs {
if v.Insight || v.Record == nil || !strings.HasPrefix(v.Record.Source, "hq@") {
return false
}
imported := strings.HasPrefix(v.Why, "imported from hq at ") || strings.HasPrefix(v.Why, "refreshed from hq at ")
drafted := draftedHere.MatchString(v.Why) && (v.By == "the knowledge base" || strings.HasPrefix(v.By, "the agent on "))
if !imported && !drafted {
return false
}
}
return true
}
// carry keeps in a record read again (now) what the knowledge base made of the one held: its clauses with their
// statuses when hq writes the same ones; its drafted or confirmed clauses and their drafting while the text they
// were drafted from is what hq says (else they are drafted again); and the clause a change of hq's names, once
// judged.
func carry(held, now *Record) {
switch {
case len(now.Clauses) > 0:
if len(held.Clauses) == len(now.Clauses) {
same := true
for i := range now.Clauses {
same = same && held.Clauses[i].N == now.Clauses[i].N && held.Clauses[i].Text == now.Clauses[i].Text
}
if same {
now.Clauses = held.Clauses
}
}
case now.Drafting != nil && held.Drafting != nil && (len(held.Clauses) > 0 || held.Drafting.State != DraftWaiting):
a, _ := draftSource(held)
b, _ := draftSource(now)
if a == b {
now.Clauses, now.Drafting = held.Clauses, held.Drafting
}
}
judged := map[Ref][]Link{}
for _, l := range held.Links {
if l.Kind == LinkChanges && l.Source != LinkPending {
if to, ok := l.To.Record(); ok {
judged[Ref{Kind: to.Kind, Number: to.Number}] = append(judged[Ref{Kind: to.Kind, Number: to.Number}], l)
}
}
}
var links []Link
for _, l := range now.Links {
target := Ref{Kind: l.To.Kind, Number: l.To.Number}
if l.Kind == LinkChanges && l.Source == LinkPending && len(judged[target]) > 0 {
for _, j := range judged[target] {
j.Unresolved = l.Unresolved
links = append(links, j)
}
delete(judged, target)
continue
}
links = append(links, l)
}
now.Links = links
}
// refresh reads a held record again (r, as hq has it at the commit) and, when only the import ever wrote it and
// what it holds differs, writes r as its new version naming what changed and why. A record changed here since
// its import is never touched; a second refresh finds the same and changes nothing.
func (k *KB) refresh(ctx context.Context, a ImportAsk, e *Entry, r *Record, path, commit, caller string, row *ImportRow, out *Imported) {
vs, err := k.Store.Versions(ctx, r.Key())
if err != nil {
row.Action, row.Why = ImportRefused, "its versions cannot be read: "+err.Error()
out.Refused++
return
}
if e.Pending != nil || !importedOnly(vs) {
last := vs[len(vs)-1]
row.Action = ImportChangedSince
row.Why = fmt.Sprintf("changed here since its import (version %d by %s: %s); a refresh never touches it", last.N, last.By, last.Why)
if e.Pending != nil {
row.Why = "a version waits for its review over it; a refresh never touches it"
}
out.ChangedSince++
return
}
carry(e.Current, r)
diffs := differences(e.Current, r)
if len(diffs) == 0 {
row.Action = ImportHeld
out.Unchanged++
return
}
row.Why = strings.Join(diffs, "; ")
row.Action = ImportWouldRefresh
out.Refreshed++
if a.DryRun {
return
}
why := fmt.Sprintf("refreshed from hq at %s: %s; the import before hq issue 487 had lost or misread %s", short(commit), path, row.Why)
if len(why) > 1000 {
why = why[:997] + "..."
}
if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
row.Action, row.Why = ImportRefused, "it could not be written: "+err.Error()
out.Refused++
out.Refreshed--
return
}
row.Action = ImportRefresh
k.emit(EventChanged, r, caller, why, e.Versions+1)
}
// differences names what a record read again from hq holds that the one held does not, field by field; none when
// the two hold the same.
func differences(old, now *Record) []string {
var out []string
o, n := old.Clone(), now.Clone()
o.Source, n.Source = "", ""
if a, b := renderJSON(o), renderJSON(n); a == b {
return nil
}
field := func(name, a, b string) {
if a != b {
if a == "" {
a = "unset"
}
if b == "" {
b = "unset"
}
out = append(out, fmt.Sprintf("%s: %s → %s", name, a, b))
}
}
boolOf := func(p *bool) string {
if p == nil {
return ""
}
return fmt.Sprint(*p)
}
field("title", o.Title, n.Title)
field("status", o.Status, n.Status)
field("topic", o.Topic, n.Topic)
field("date", o.Date, n.Date)
field("deciders", strings.Join(o.Deciders, ", "), strings.Join(n.Deciders, ", "))
field("reconstructed", boolOf(o.Reconstructed), boolOf(n.Reconstructed))
field("amended", strings.Join(o.Amended, ", "), strings.Join(n.Amended, ", "))
field("touches", strings.Join(o.Touches, ", "), strings.Join(n.Touches, ", "))
var gone []string
for key := range o.Extra {
if _, ok := n.Extra[key]; !ok {
gone = append(gone, key)
}
}
sort.Strings(gone)
if len(gone) > 0 {
out = append(out, "held as a field of its own now: "+strings.Join(gone, ", "))
}
if renderJSON(o.Clauses) != renderJSON(n.Clauses) {
s := fmt.Sprintf("clauses: %d → %d", len(o.Clauses), len(n.Clauses))
if len(n.Clauses) == 0 && n.Drafting != nil {
s += ", to be drafted"
}
out = append(out, s)
}
if (o.Drafting == nil) != (n.Drafting == nil) || (n.Drafting != nil && o.Drafting != nil && o.Drafting.Reason != n.Drafting.Reason) {
if n.Drafting != nil {
s := "drafting queued"
if n.Drafting.Reason != "" {
s += " (" + n.Drafting.Reason + ")"
}
out = append(out, s)
} else {
out = append(out, "no drafting: its clauses are written")
}
}
ol, nl := map[string]bool{}, map[string]bool{}
for _, l := range o.Links {
ol[l.String()] = true
}
for _, l := range n.Links {
nl[l.String()] = true
}
var added, removed []string
for l := range nl {
if !ol[l] {
added = append(added, l)
}
}
for l := range ol {
if !nl[l] {
removed = append(removed, l)
}
}
sort.Strings(added)
sort.Strings(removed)
if len(added)+len(removed) > 0 {
s := "links:"
if len(added) > 0 {
s += " + " + strings.Join(added, ", + ")
}
if len(removed) > 0 {
s += " − " + strings.Join(removed, ", − ")
}
out = append(out, s)
}
if renderJSON(o.Sections) != renderJSON(n.Sections) {
out = append(out, "its sections")
}
if len(out) == 0 {
out = append(out, "what it holds otherwise (as hq writes it)")
}
return out
}
func renderJSON(v any) string {
raw, _ := json.Marshal(v)
return string(raw)
}