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) } // A section is a part with a title, never a file: learned on the section just written (to-be 55 §2.2, §4). if problems := sectionFormProblems(title, text); len(problems) > 0 { return nil, fmt.Errorf("the section is refused, and nothing was written:\n- %s", strings.Join(problems, "\n- ")) } 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 }