A draft is a key of the state drafts (author, fields, sections as title, digest and the sequence of their write-section); write puts the text into the record's history at once, submit makes the draft a version through the rules propose is judged by and writes no section again. Only the author writes; take-over hands a draft, a proposal or a reserved number to the caller with the reason in the next version's message. A new version of an effort starts from the served sections and keeps its title, date and deciders (the rows of the commit folded in, now run over the ledger too). A tool call is named to the ledger so the runtime stamps the caller it proved. Issue 1966. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
570 lines
18 KiB
Go
570 lines
18 KiB
Go
package main
|
|
|
|
// The records kept in the ledger (novox/hq ADR 0597 §11, to-be 55 §2.1): the Store the rules of the knowledge base are
|
|
// written against, on the tool runner's ledger calls. A record is one instance, `decision/0290` or `research/081`; every
|
|
// change to it is one operation appended to its history; its head, the entry, is the state apply derives from the
|
|
// operations and is never written by the module; a section's text is in the `write-section` operation that wrote it and
|
|
// nowhere else, the head naming it by its title, its digest and that operation's sequence.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
|
)
|
|
|
|
// MostSectionBytes is the most a section holds (to-be 55 §4), within a history entry's 256 KiB.
|
|
const MostSectionBytes = 40 << 10
|
|
|
|
// sectionRef is how a head names a section: its title, the digest of its text and the sequence of the write-section that
|
|
// wrote it.
|
|
type sectionRef struct {
|
|
Name string `json:"name"`
|
|
Digest string `json:"digest"`
|
|
Sequence uint64 `json:"sequence"`
|
|
}
|
|
|
|
// storedRecord is a record as the head and a version keep it: the record whole but for its sections' text.
|
|
type storedRecord struct {
|
|
Record
|
|
Sections []sectionRef `json:"sections,omitempty"`
|
|
}
|
|
|
|
// entryHead is the head of an instance: the version served, the one proposed over it, and how many versions there are.
|
|
type entryHead struct {
|
|
Current *storedRecord `json:"current"`
|
|
CurrentVersion int `json:"current-version"`
|
|
Pending *storedRecord `json:"pending,omitempty"`
|
|
PendingVersion int `json:"pending-version,omitempty"`
|
|
Versions int `json:"versions"`
|
|
}
|
|
|
|
// versionHead is a version as its operation keeps it.
|
|
type versionHead struct {
|
|
N int `json:"n"`
|
|
By string `json:"by"`
|
|
At time.Time `json:"at"`
|
|
Why string `json:"why"`
|
|
Insight bool `json:"insight,omitempty"`
|
|
Record storedRecord `json:"record"`
|
|
}
|
|
|
|
// headState is the state of an instance, as apply derives it from the operations.
|
|
type headState struct {
|
|
Kind string `json:"kind"`
|
|
Number int `json:"number"`
|
|
Entry *entryHead `json:"entry"`
|
|
Sequence uint64 `json:"sequence"`
|
|
}
|
|
|
|
// SectionDigest is the digest a section's text is kept under; the ledger refuses a write-section whose digest is another.
|
|
func SectionDigest(text string) string {
|
|
sum := sha256.Sum256([]byte(text))
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// InstanceOf is the instance a record is: `decision/0290`, `research/081`.
|
|
func InstanceOf(k Key) string {
|
|
if k.Kind == KindResearch {
|
|
return fmt.Sprintf("research/%03d", k.Number)
|
|
}
|
|
return fmt.Sprintf("decision/%04d", k.Number)
|
|
}
|
|
|
|
// ledgerStore is the Store on the ledger.
|
|
type ledgerStore struct {
|
|
pipe Pipe
|
|
states States
|
|
|
|
mu sync.Mutex
|
|
// heads are the instances read, by key, each with the sequence it was read at; texts are the sections' text by digest.
|
|
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{}, 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
|
|
// new ones are filled by the caller.
|
|
func stripped(r *Record, refs []sectionRef) storedRecord {
|
|
c := r.Clone()
|
|
c.Sections = nil
|
|
return storedRecord{Record: *c, Sections: refs}
|
|
}
|
|
|
|
// read is one instance's head from the ledger, or nil when it has none.
|
|
func (s *ledgerStore) read(k Key) (*headState, error) {
|
|
var got buscalls.InstanceState
|
|
if err := s.pipe.Call(buscalls.LedgerGet, buscalls.InstanceRequest{Ledger: LedgerName, Instance: InstanceOf(k)}, &got); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(got.State) == 0 || string(got.State) == "null" {
|
|
return nil, nil
|
|
}
|
|
var h headState
|
|
if err := json.Unmarshal(got.State, &h); err != nil {
|
|
return nil, fmt.Errorf("the head of %s is not a record's: %w", k, err)
|
|
}
|
|
h.Sequence = got.Sequence
|
|
return &h, nil
|
|
}
|
|
|
|
// fresh is the head, read again unless this process wrote it or read it and nothing else writes: one instance of the
|
|
// module holds the seat, so what it holds is what the ledger has.
|
|
func (s *ledgerStore) fresh(k Key) (*headState, error) {
|
|
if h := s.heads[k]; h != nil {
|
|
return h, nil
|
|
}
|
|
h, err := s.read(k)
|
|
if err != nil || h == nil {
|
|
return nil, err
|
|
}
|
|
s.heads[k] = h
|
|
return h, nil
|
|
}
|
|
|
|
// scan reads every instance the ledger holds, once: the numbers are probed in order until a long run of none, because the
|
|
// ledger answers an instance by its path and not a list of them.
|
|
func (s *ledgerStore) scan() error {
|
|
if s.scanned {
|
|
return nil
|
|
}
|
|
const gap = 50
|
|
for _, kind := range Kinds {
|
|
misses := 0
|
|
for n := 1; misses < gap; n++ {
|
|
k := Key{kind, n}
|
|
h, err := s.fresh(k)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if h == nil {
|
|
misses++
|
|
continue
|
|
}
|
|
misses = 0
|
|
}
|
|
}
|
|
s.scanned = true
|
|
return nil
|
|
}
|
|
|
|
// text is the text of a section by its ref, read from the write-section that wrote it when it is not held, and held to its
|
|
// digest.
|
|
func (s *ledgerStore) text(ref sectionRef, k Key) (string, error) {
|
|
if t, ok := s.texts[ref.Digest]; ok {
|
|
return t, nil
|
|
}
|
|
var page buscalls.HistoryPage
|
|
if err := s.pipe.Call(buscalls.LedgerHistory, buscalls.HistoryRequest{Ledger: LedgerName, Instance: InstanceOf(k), From: ref.Sequence, Limit: 1}, &page); err != nil {
|
|
return "", err
|
|
}
|
|
if len(page.Operations) == 0 || page.Operations[0].Sequence != ref.Sequence || page.Operations[0].Operation != OpWriteSection {
|
|
return "", fmt.Errorf("the section %q of %s names operation %d, which is no write-section of it", ref.Name, k, ref.Sequence)
|
|
}
|
|
var body struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(page.Operations[0].Body, &body); err != nil {
|
|
return "", err
|
|
}
|
|
if SectionDigest(body.Text) != ref.Digest {
|
|
return "", fmt.Errorf("the section %q of %s has a text whose digest is not %s: the history is damaged", ref.Name, k, ref.Digest)
|
|
}
|
|
s.texts[ref.Digest] = body.Text
|
|
return body.Text, nil
|
|
}
|
|
|
|
// whole is a stored record with its sections' text.
|
|
func (s *ledgerStore) whole(sr *storedRecord, k Key) (*Record, error) {
|
|
if sr == nil {
|
|
return nil, nil
|
|
}
|
|
r := sr.Record.Clone()
|
|
r.Sections = nil
|
|
for _, ref := range sr.Sections {
|
|
t, err := s.text(ref, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.Sections = append(r.Sections, Section{Name: ref.Name, Text: t})
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// entryOf is the entry a head is.
|
|
func (s *ledgerStore) entryOf(k Key, h *headState) (*Entry, error) {
|
|
cur, err := s.whole(h.Entry.Current, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pend, err := s.whole(h.Entry.Pending, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Entry{Current: cur, CurrentVersion: h.Entry.CurrentVersion, Pending: pend, PendingVersion: h.Entry.PendingVersion,
|
|
Versions: h.Entry.Versions, Revision: int64(h.Sequence)}, nil
|
|
}
|
|
|
|
func (s *ledgerStore) Get(_ context.Context, k Key) (*Entry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
h, err := s.fresh(k)
|
|
if err != nil || h == nil {
|
|
return nil, err
|
|
}
|
|
return s.entryOf(k, h)
|
|
}
|
|
|
|
func (s *ledgerStore) All(context.Context) ([]*Entry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.scan(); err != nil {
|
|
return nil, err
|
|
}
|
|
keys := make([]Key, 0, len(s.heads))
|
|
for k := range s.heads {
|
|
keys = append(keys, k)
|
|
}
|
|
sortKeys(keys)
|
|
out := make([]*Entry, 0, len(keys))
|
|
for _, k := range keys {
|
|
e, err := s.entryOf(k, s.heads[k])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// write appends a write-section for each section of r that is new, and answers the refs of all its sections. known are
|
|
// the refs already written, by name and digest.
|
|
func (s *ledgerStore) write(ctx context.Context, k Key, r *Record, known []sectionRef) ([]sectionRef, error) {
|
|
var refs []sectionRef
|
|
for _, sec := range r.Sections {
|
|
d := SectionDigest(sec.Text)
|
|
found := false
|
|
for _, kr := range known {
|
|
if kr.Name == sec.Name && kr.Digest == d {
|
|
refs, found = append(refs, kr), true
|
|
break
|
|
}
|
|
}
|
|
if found {
|
|
continue
|
|
}
|
|
if len(sec.Text) > MostSectionBytes {
|
|
return nil, fmt.Errorf("the section %q of %s is %d bytes; a section holds at most %d: split it", sec.Name, k, len(sec.Text), MostSectionBytes)
|
|
}
|
|
seq, err := s.apply(ctx, k, OpWriteSection, map[string]any{"title": sec.Name, "text": sec.Text, "digest": d})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.texts[d] = sec.Text
|
|
refs = append(refs, sectionRef{Name: sec.Name, Digest: d, Sequence: seq})
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
// apply hands one operation to the ledger and answers its sequence.
|
|
func (s *ledgerStore) apply(ctx context.Context, k Key, op string, body map[string]any) (uint64, error) {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var got buscalls.Applied
|
|
if err := s.pipe.Call(buscalls.LedgerApply, buscalls.ApplyRequest{Ledger: LedgerName, Instance: InstanceOf(k), Operation: op, Body: raw, Call: callOf(ctx)}, &got); err != nil {
|
|
return 0, err
|
|
}
|
|
if !got.Taken {
|
|
return 0, fmt.Errorf("the ledger did not take %s on %s", op, k)
|
|
}
|
|
return got.Sequence, nil
|
|
}
|
|
|
|
// operationFor is the operation a change of an entry is, named by what changed, so the history says it.
|
|
func operationFor(old *Entry, v Version, current bool) string {
|
|
if old == nil {
|
|
switch {
|
|
case v.Record.Status == StatusReserved:
|
|
return OpReserve
|
|
case v.Record.Source != "" && v.Record.Author == "" && len(v.Record.Writers) == 0:
|
|
return OpImport
|
|
}
|
|
return OpPropose
|
|
}
|
|
prev, next := old.Working(), v.Record
|
|
switch {
|
|
case next.Status == StatusWithdrawn && prev.Status != StatusWithdrawn:
|
|
return OpWithdraw
|
|
case next.Accepted() && !prev.Accepted(), next.Status == StatusSuperseded && prev.Status != StatusSuperseded:
|
|
return OpAccept
|
|
case (next.Retracting != nil) != (prev.Retracting != nil), len(next.Retractions) != len(prev.Retractions), next.Status == StatusRetracted && prev.Status != StatusRetracted:
|
|
return OpRetract
|
|
case next.Ask != nil && (prev.Ask == nil || prev.Ask.Said != next.Ask.Said) && next.Ask.Warrant != "":
|
|
return OpWarrant
|
|
case next.Ask != nil && (prev.Ask == nil || prev.Ask.ID != next.Ask.ID):
|
|
return OpAsk
|
|
case len(next.Reviews) != len(prev.Reviews):
|
|
return OpReview
|
|
case (next.Drafting != nil) != (prev.Drafting != nil) || (next.Drafting != nil && prev.Drafting != nil && *next.Drafting != *prev.Drafting):
|
|
return OpDraft
|
|
case next.Author != prev.Author:
|
|
return OpTakeOver
|
|
}
|
|
return OpSubmit
|
|
}
|
|
|
|
func (s *ledgerStore) Create(ctx context.Context, v Version) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
k := v.Record.Key()
|
|
h, err := s.fresh(k)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if h != nil {
|
|
return ErrExists
|
|
}
|
|
v.N = 1
|
|
refs, err := s.write(ctx, k, v.Record, s.drafted[k])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.commit(ctx, k, operationFor(nil, v, true), &entryHead{CurrentVersion: 1, Versions: 1, Current: ptr(stripped(v.Record, refs))}, v, refs)
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
// commit appends the operation that carries the new head and the version, and holds the head it derived.
|
|
func (s *ledgerStore) commit(ctx context.Context, k Key, op string, e *entryHead, v Version, refs []sectionRef) error {
|
|
body := map[string]any{"kind": k.Kind, "number": k.Number, "entry": e,
|
|
"version": versionHead{N: v.N, By: v.By, At: v.At, Why: v.Why, Insight: v.Insight, Record: stripped(v.Record, refs)},
|
|
"pending-sections": map[string]any{}}
|
|
seq, err := s.apply(ctx, k, op, body)
|
|
if err != nil {
|
|
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()
|
|
k := v.Record.Key()
|
|
h, err := s.fresh(k)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if h == nil {
|
|
return fmt.Errorf("%s is not held", k)
|
|
}
|
|
if int64(h.Sequence) != revision {
|
|
return ErrChanged
|
|
}
|
|
old, err := s.entryOf(k, h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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...)
|
|
}
|
|
}
|
|
refs, err := s.write(ctx, k, v.Record, known)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e := *h.Entry
|
|
v.N = e.Versions + 1
|
|
e.Versions = v.N
|
|
sr := ptr(stripped(v.Record, refs))
|
|
if current {
|
|
e.Current, e.CurrentVersion = sr, v.N
|
|
} else {
|
|
e.Pending, e.PendingVersion = sr, v.N
|
|
}
|
|
if clearPending {
|
|
e.Pending, e.PendingVersion = nil, 0
|
|
}
|
|
return s.commit(ctx, k, operationFor(old, v, current), &e, v, refs)
|
|
}
|
|
|
|
func (s *ledgerStore) Versions(_ context.Context, k Key) ([]Version, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
h, err := s.fresh(k)
|
|
if err != nil || h == nil {
|
|
return nil, err
|
|
}
|
|
var out []Version
|
|
for from := uint64(0); ; {
|
|
var page buscalls.HistoryPage
|
|
if err := s.pipe.Call(buscalls.LedgerHistory, buscalls.HistoryRequest{Ledger: LedgerName, Instance: InstanceOf(k), From: from}, &page); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, op := range page.Operations {
|
|
var body struct {
|
|
Version *versionHead `json:"version"`
|
|
}
|
|
if op.Operation == OpWriteSection || json.Unmarshal(op.Body, &body) != nil || body.Version == nil {
|
|
continue
|
|
}
|
|
r, err := s.whole(&body.Version.Record, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, Version{N: body.Version.N, By: body.Version.By, At: body.Version.At, Why: body.Version.Why, Record: r, Insight: body.Version.Insight})
|
|
}
|
|
if page.Next == 0 {
|
|
break
|
|
}
|
|
from = page.Next
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].N < out[j].N })
|
|
return out, nil
|
|
}
|
|
|
|
// Search ranks the served versions as the in-memory store does, over every record; the index of the module's memory
|
|
// replaces it (the search index of to-be 55 §1).
|
|
func (s *ledgerStore) Search(ctx context.Context, question string, limit int) ([]Hit, error) {
|
|
all, err := s.All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return searchEntries(all, question, limit), nil
|
|
}
|
|
|
|
func (s *ledgerStore) Inbound(_ context.Context, k Key) ([]Inbound, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.scan(); err != nil {
|
|
return nil, err
|
|
}
|
|
// The links are in the heads, which hold no text: nothing is read of a section.
|
|
var served []*Record
|
|
for _, h := range s.heads {
|
|
served = append(served, &h.Entry.Current.Record)
|
|
}
|
|
return inboundOf(served, k), nil
|
|
}
|
|
|
|
// The whole-set findings and what the operator was told are keys of the state `findings`.
|
|
const (
|
|
findingsState = "findings"
|
|
findingsKey = "set"
|
|
toldPrefix = "told."
|
|
)
|
|
|
|
func (s *ledgerStore) SetFindings(_ context.Context, f []Finding) error {
|
|
_, err := s.states.State(findingsState).Put(findingsKey, f)
|
|
return err
|
|
}
|
|
|
|
func (s *ledgerStore) Findings(context.Context) ([]Finding, error) {
|
|
e, err := s.states.State(findingsState).Get(findingsKey)
|
|
if err != nil || e == nil {
|
|
return nil, err
|
|
}
|
|
var out []Finding
|
|
return out, json.Unmarshal(e.Value, &out)
|
|
}
|
|
|
|
func (s *ledgerStore) Told(_ context.Context, topic string) (bool, error) {
|
|
_, err := s.states.State(findingsState).Create(toldPrefix+topic, time.Now().UTC())
|
|
switch {
|
|
case err == nil:
|
|
return true, nil
|
|
case isChanged(err):
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
func (s *ledgerStore) ToldOf(_ context.Context, prefix string) ([]string, error) {
|
|
keys, err := s.states.State(findingsState).Keys()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
for _, k := range keys {
|
|
if t, ok := strings.CutPrefix(k, toldPrefix); ok && strings.HasPrefix(t, prefix) {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ledgerStore) Untold(_ context.Context, key string) error {
|
|
return s.states.State(findingsState).Delete(toldPrefix + key)
|
|
}
|
|
|
|
func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) }
|