Add mesh-issues, the issue tracker, so an issue's number comes from one counter no two openers share (hq ADR 0275, to-be 53 slice 1)
mesh/merge-gate pass: builds new: modules/mesh-issues, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request

The seat issue-tracker, held once for the mesh: open, get, list, move, note
and link. The counter is advanced by the SDK's compare-and-set Increment
(go/v0.1.12) and started once at the setting first-number (365, after hq's
highest), never below an issue kept. The cycle's issue rules are refused at
the write; every change is an event. The runtime does not yet say who called,
so every change is recorded as by "unproven" and no argument can name a caller.
This commit is contained in:
jochen
2026-10-10 12:51:15 +02:00
parent 52f34d362a
commit 2493e3e048
12 changed files with 3070 additions and 0 deletions
@@ -0,0 +1,688 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tracker is the holder of the issue-tracker seat (novox/hq ADR 0275, to-be 53): it hands out each issue's
// number from one counter, keeps each issue, refuses what the development cycle refuses, and says every
// change as an event.
type Tracker struct {
Store Store
// Emit says an event as this module (ADR 0041); the events are the audit trail.
Emit func(event string, body any) error
Settings func() (Settings, error)
Now func() time.Time
Logf func(format string, a ...any)
// Sleep waits between rounds of a counter that other openers keep winning.
Sleep func(time.Duration)
}
// The events this module says, one per kind of change.
const (
EventOpened = "opened"
EventMoved = "moved"
EventNoted = "noted"
EventLinked = "linked"
)
// Bounds on how often a write another writer won is tried again. Each is a bounded loop: past it the
// caller is told, and nothing was written.
const (
// counterRounds × counterAttempts is how many times one open tries to advance the counter.
counterRounds = 12
counterAttempts = 8
// issueAttempts is how many times a change to one issue is read and written again.
issueAttempts = 16
// createAttempts is how many times the new issue's key is written after a failure that was not a refusal.
createAttempts = 3
)
// by is who acted on this call. The runtime does not hand a module its caller, so it is Unproven; it is
// never read from the call's arguments, so no caller can name another (ADR 0275 §5).
func (t *Tracker) by() string { return Unproven }
func (t *Tracker) now() time.Time { return t.Now().UTC().Truncate(time.Second) }
func (t *Tracker) owners() (Owners, error) {
s, err := t.Settings()
if err != nil {
return nil, fmt.Errorf("the registered list of owners cannot be read: %v", err)
}
return Owners(s.Owners), nil
}
// emit says an event, and answers whether it was said: a change already written is never undone because
// its event could not be said, and never reported as said when it was not.
func (t *Tracker) emit(event string, body map[string]any) (bool, string) {
if err := t.Emit(event, body); err != nil {
t.Logf("[mesh-issues] issue %v was changed and its %s event could not be said: %v", body["number"], event, err)
return false, fmt.Sprintf("the change is kept, and its %s event could not be said on the bus (%v): "+
"what watches the events did not hear of it", event, err)
}
return true, ""
}
// OpenAsk is what an open carries.
type OpenAsk struct {
Title string
Report string
LocatedIn []string
Record string
}
// Opened is the answer to an open.
type Opened struct {
Number uint64 `json:"number"`
Title string `json:"title"`
Status string `json:"status,omitempty"`
LocatedIn []string `json:"located-in,omitempty"`
Record string `json:"record,omitempty"`
By string `json:"by"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
// Open checks an issue, then takes the next number and writes the issue under it (to-be 53 §4). A refused
// issue takes no number. A number taken and then not written — the bus failing between the two — stays
// unused, and the answer says which: numbers may skip, never repeat.
func (t *Tracker) Open(a OpenAsk) (*Opened, error) {
title, err := singleLine("title", a.Title, MostTitle)
if err != nil {
return nil, err
}
report, err := multiLine("report", a.Report, MostReport)
if err != nil {
return nil, err
}
owners, err := t.owners()
if err != nil {
return nil, err
}
located, err := entries("located-in", a.LocatedIn, owners.locatedIn)
if err != nil {
return nil, err
}
record := ""
if strings.TrimSpace(a.Record) != "" {
if record, err = checkSlug(a.Record); err != nil {
return nil, err
}
if len(located) > 0 {
return nil, errors.New("an issue with a folder in hq keeps located-in in that folder's report until " +
"the import: open it without located-in, or without record")
}
}
at := t.now()
is := Issue{Title: title, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID()}
if record == "" {
is.Status = StatusOpen
}
if size, _ := json.Marshal(is); len(size) > MostIssueBytes-1024 {
return nil, fmt.Errorf("the issue is %d bytes; one issue is kept in at most %d", len(size), MostIssueBytes)
}
n, err := t.next()
if err != nil {
return nil, err
}
is.Number = n
if record != "" {
is.Record = RecordOf(n, record)
}
if err := t.create(&is); err != nil {
return nil, err
}
out := &Opened{Number: n, Title: title, Status: is.Status, LocatedIn: located, Record: is.Record, By: is.OpenedBy}
out.Said = fmt.Sprintf("Opened issue %d: %s.", n, title)
if is.Record != "" {
out.Said += fmt.Sprintf(" Its narrative goes in hq's %s/00-report.md, which keeps its status.", is.Record)
}
out.Announced, out.Warning = t.emit(EventOpened, map[string]any{
"number": n, "title": title, "status": is.Status, "located-in": located, "record": is.Record,
"by": is.OpenedBy, "at": at})
return out, nil
}
// create writes a new issue under its number, never over one: a key that is there already is a fault said
// loudly, and the number stays unused.
func (t *Tracker) create(is *Issue) error {
key := keyOf(is.Number)
var err error
for i := 0; i < createAttempts; i++ {
_, err = t.Store.Create(IssuesState, key, is)
if err == nil {
return nil
}
if errors.Is(err, stdio.ErrChanged) {
// Ours, written by an attempt whose answer was lost?
if there, _, rerr := read(t.Store, is.Number); rerr == nil && there.Opening == is.Opening {
return nil
}
t.Logf("[mesh-issues] the counter handed out %d and an issue %d is already kept: not overwritten", is.Number, is.Number)
return fmt.Errorf("a fault, not your mistake: the counter handed out %d, and an issue %d is already kept. "+
"Nothing was overwritten and nothing was opened; %d stays unused. Open it again for the next number, "+
"and tell the operator the counter is behind the issues kept", is.Number, is.Number, is.Number)
}
}
t.Logf("[mesh-issues] number %d was taken and the issue could not be written: %v", is.Number, err)
return fmt.Errorf("number %d was taken and the issue could not be written (%v); nothing was opened and %d "+
"stays unused. Open it again", is.Number, err, is.Number)
}
// next takes the next issue number: the counter advanced only at the revision read, so two openers — in
// one instance or two — never take one number (ADR 0275 §3).
func (t *Tracker) next() (uint64, error) {
e, err := t.Store.Get(CountersState, IssueCounter)
if err != nil {
return 0, fmt.Errorf("the issue counter cannot be read (%v); no number was taken", err)
}
if e == nil {
if err := t.seed(); err != nil {
return 0, err
}
}
for round := 0; round < counterRounds; round++ {
n, err := t.Store.Increment(CountersState, IssueCounter, counterAttempts)
if err == nil {
return n, nil
}
if !errors.Is(err, stdio.ErrChanged) {
t.Logf("[mesh-issues] the issue counter could not be advanced: %v", err)
return 0, fmt.Errorf("the issue counter could not be advanced (%v); nothing was opened. If the bus took "+
"the write before it failed, one number stays unused", err)
}
// Others are opening at this moment: wait a little, longer each round, and try again.
t.Sleep(jitter(time.Duration(round+1) * 10 * time.Millisecond))
}
return 0, fmt.Errorf("so many issues are being opened at this moment that the counter was taken by another "+
"opener %d times; no number was taken and nothing was opened. Open it again", counterRounds*counterAttempts)
}
// seed starts the counter where the setting `first-number` says, made only when there is none, and never
// below an issue already kept: the first number handed out is the setting's, or the one after the highest
// kept, whichever is higher.
func (t *Tracker) seed() error {
s, err := t.Settings()
if err != nil {
return fmt.Errorf("the issue counter has not started and the settings cannot be read (%v); no number was taken", err)
}
if s.FirstNumber < 1 {
return errors.New("the issue counter has not started and the setting first-number is not set: it is the " +
"number the first issue gets, the one after the highest anywhere else. No number was taken")
}
start := s.FirstNumber - 1
keys, err := t.Store.Keys(IssuesState)
if err != nil {
return fmt.Errorf("the issue counter has not started and the issues kept cannot be listed (%v); no number was taken", err)
}
for _, k := range keys {
if n, ok := numberOfKey(k); ok && n > start {
start = n
}
}
if _, err := t.Store.Create(CountersState, IssueCounter, start); err != nil && !errors.Is(err, stdio.ErrChanged) {
return fmt.Errorf("the issue counter could not be started (%v); no number was taken", err)
}
// ErrChanged: another opener started it first, which is the same start.
return nil
}
// change reads an issue, applies f, and writes it only at the revision read; a write another writer won
// is read and applied again, so no note, link or move is lost and none is applied to a stale issue.
func (t *Tracker) change(n uint64, f func(*Issue) error) (*Issue, error) {
for i := 0; i < issueAttempts; i++ {
is, rev, err := read(t.Store, n)
if err != nil {
return nil, err
}
if err := f(is); err != nil {
return nil, err
}
raw, err := json.Marshal(is)
if err != nil {
return nil, err
}
if len(raw) > MostIssueBytes {
return nil, fmt.Errorf("issue %d would be %d bytes; one issue is kept in at most %d (256 KiB). Nothing "+
"was changed: say it shorter, or in a document the issue links", n, len(raw), MostIssueBytes)
}
_, err = t.Store.Update(IssuesState, keyOf(n), json.RawMessage(raw), rev)
if err == nil {
return is, nil
}
if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("issue %d could not be written (%v); nothing was changed", n, err)
}
t.Sleep(jitter(time.Duration(i+1) * 5 * time.Millisecond))
}
return nil, fmt.Errorf("issue %d was changed by another caller %d times while this change was made; nothing was "+
"changed. Try again", n, issueAttempts)
}
// Get is one issue whole.
func (t *Tracker) Get(n uint64) (*Issue, error) {
is, _, err := read(t.Store, n)
return is, err
}
// MoveAsk is what a move carries: the status, and what it needs.
type MoveAsk struct {
Number uint64
Status string
LocatedIn []string
FixedBy []string
AmendedDesign []string
Replay string
ReplayNone string
Reason string
}
// Moved is the answer to a move.
type Moved struct {
Number uint64 `json:"number"`
From string `json:"from"`
To string `json:"to"`
Changed []string `json:"changed,omitempty"`
By string `json:"by"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
func known(status string) bool {
for _, s := range Statuses {
if s == status {
return true
}
}
return false
}
// Move changes an issue's status under the cycle's rules (to-be 53 §5). A refused move changes nothing and
// says which rule refused it.
func (t *Tracker) Move(a MoveAsk) (*Moved, error) {
to := strings.TrimSpace(a.Status)
if !known(to) {
return nil, fmt.Errorf("status %q is not one of %s", to, strings.Join(Statuses, ", "))
}
owners, err := t.owners()
if err != nil {
return nil, err
}
located, err := entries("located-in", a.LocatedIn, owners.locatedIn)
if err != nil {
return nil, err
}
fixed, err := entries("fixed-by", a.FixedBy, func(e string) (string, error) { return singleLine("an entry of fixed-by", e, MostLine) })
if err != nil {
return nil, err
}
designs, err := entries("amended-design", a.AmendedDesign, checkDesign)
if err != nil {
return nil, err
}
var replay, replayNone, reason string
if strings.TrimSpace(a.Replay) != "" {
if replay, err = singleLine("replay", a.Replay, MostLine); err != nil {
return nil, err
}
if !replayID.MatchString(replay) {
return nil, fmt.Errorf("replay %q names no replay: an id from mesh-lab's replays register, R and a number, "+
"such as R361", replay)
}
}
if strings.TrimSpace(a.ReplayNone) != "" {
if replayNone, err = multiLine("replay-none", a.ReplayNone, MostReason); err != nil {
return nil, err
}
}
if strings.TrimSpace(a.Reason) != "" {
if reason, err = multiLine("reason", a.Reason, MostReason); err != nil {
return nil, err
}
}
if (replay != "" || replayNone != "") && to != StatusResolved {
return nil, fmt.Errorf("replay and replay-none are what resolving a core issue needs; this moves to %s", to)
}
if replay != "" && replayNone != "" {
return nil, errors.New("give replay or replay-none, not both: a replay proves the fix, replay-none says why none can")
}
if reason != "" && to != StatusWontfix {
return nil, fmt.Errorf("reason is what wontfix needs; this moves to %s", to)
}
var from string
var changed []string
is, err := t.change(a.Number, func(is *Issue) error {
changed = nil
if is.Record != "" {
return fmt.Errorf("issue %d keeps its status in its folder %s until the import of slice 3 has run: "+
"change it there. The tracker holds its number, title, notes and links", is.Number, is.Record)
}
from = is.Status
if from == to {
return fmt.Errorf("issue %d is already %s", is.Number, to)
}
set := func(name string, cond bool, apply func()) {
if cond {
apply()
changed = append(changed, name)
}
}
set("located-in", located != nil, func() { is.LocatedIn = located })
set("fixed-by", fixed != nil, func() { is.FixedBy = fixed })
set("amended-design", designs != nil, func() { is.AmendedDesign = designs })
set("replay", replay != "", func() { is.Replay, is.ReplayNone = replay, "" })
set("replay-none", replayNone != "", func() { is.ReplayNone, is.Replay = replayNone, "" })
set("reason", reason != "", func() { is.Reason = reason })
if err := cycleRules(is, to); err != nil {
return err
}
is.Status = to
if to == StatusResolved || to == StatusWontfix {
at := t.now()
is.Closed = &at
} else {
is.Closed = nil
}
return nil
})
if err != nil {
return nil, err
}
out := &Moved{Number: is.Number, From: from, To: to, Changed: changed, By: t.by(),
Said: fmt.Sprintf("Issue %d moved from %s to %s.", is.Number, from, to)}
body := map[string]any{"number": is.Number, "from": from, "to": to, "by": out.By, "at": t.now()}
for _, c := range changed {
switch c {
case "located-in":
body[c] = is.LocatedIn
case "fixed-by":
body[c] = is.FixedBy
case "amended-design":
body[c] = is.AmendedDesign
case "replay":
body[c] = is.Replay
case "replay-none":
body[c] = is.ReplayNone
case "reason":
body[c] = is.Reason
}
}
out.Announced, out.Warning = t.emit(EventMoved, body)
return out, nil
}
// cycleRules is what the development cycle asks of an issue at a status (hq cycle.py, playbook 03, to-be 53
// §5), said as the rule that refused it.
func cycleRules(is *Issue, to string) error {
switch to {
case StatusLocated:
if len(is.LocatedIn) == 0 {
return fmt.Errorf("issue %d cannot be located without located-in: name at least one registered owner "+
"(a repository, or \"mesh-catalog modules/<module>\")", is.Number)
}
case StatusResolved:
if len(is.LocatedIn) == 0 {
return fmt.Errorf("issue %d cannot be resolved without located-in: name the owner that was fixed", is.Number)
}
if len(is.FixedBy) == 0 {
return fmt.Errorf("issue %d cannot be resolved without fixed-by: name the pull requests or commits that fixed it", is.Number)
}
if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" {
return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+
"runner, the SDK, or the catalogue's bus, forge or build agent): it resolves with replay, the replay's id "+
"in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number)
}
case StatusWontfix:
if strings.TrimSpace(is.Reason) == "" {
return fmt.Errorf("issue %d cannot be closed as wontfix without a reason: say why it will not be fixed", is.Number)
}
}
return nil
}
// Noted is the answer to a note.
type Noted struct {
Number uint64 `json:"number"`
Notes int `json:"notes"`
By string `json:"by"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
// Note appends a note to an issue; a note is never edited.
func (t *Tracker) Note(n uint64, text string) (*Noted, error) {
text, err := multiLine("text", text, MostNote)
if err != nil {
return nil, err
}
note := Note{By: t.by(), At: t.now(), Text: text}
is, err := t.change(n, func(is *Issue) error {
is.Notes = append(is.Notes, note)
return nil
})
if err != nil {
return nil, err
}
out := &Noted{Number: n, Notes: len(is.Notes), By: note.By,
Said: fmt.Sprintf("Noted on issue %d (note %d).", n, len(is.Notes))}
out.Announced, out.Warning = t.emit(EventNoted, map[string]any{"number": n, "by": note.By, "at": note.At, "text": text})
return out, nil
}
// LinkAsk is what a link carries.
type LinkAsk struct {
Number uint64
Kind string
Target string
Remove bool
}
// Linked is the answer to a link.
type Linked struct {
Number uint64 `json:"number"`
Kind string `json:"kind"`
Target string `json:"target"`
Removed bool `json:"removed"`
Links int `json:"links"`
By string `json:"by"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
// Link adds or removes a typed link.
func (t *Tracker) Link(a LinkAsk) (*Linked, error) {
kind := strings.TrimSpace(a.Kind)
target, err := checkTarget(kind, a.Target)
if err != nil {
return nil, err
}
if (kind == LinkDuplicates || kind == LinkRelatesTo) && !a.Remove {
other, ok := numberOfKey(target)
if !ok {
return nil, fmt.Errorf("%q is not an issue's number", target)
}
if other == a.Number {
return nil, fmt.Errorf("issue %d cannot be linked to itself", a.Number)
}
if _, _, err := read(t.Store, other); err != nil {
return nil, err
}
}
by, at := t.by(), t.now()
is, err := t.change(a.Number, func(is *Issue) error {
for i, l := range is.Links {
if l.Kind == kind && l.Target == target {
if !a.Remove {
return fmt.Errorf("issue %d already has the %s link to %s", is.Number, kind, target)
}
is.Links = append(is.Links[:i:i], is.Links[i+1:]...)
return nil
}
}
if a.Remove {
return fmt.Errorf("issue %d has no %s link to %s to remove", is.Number, kind, target)
}
if len(is.Links) >= MostLinks {
return fmt.Errorf("issue %d has %d links, the most one issue keeps", is.Number, MostLinks)
}
is.Links = append(is.Links, Link{Kind: kind, Target: target, By: by, At: at})
return nil
})
if err != nil {
return nil, err
}
out := &Linked{Number: a.Number, Kind: kind, Target: target, Removed: a.Remove, Links: len(is.Links), By: by}
if a.Remove {
out.Said = fmt.Sprintf("Removed the %s link from issue %d to %s.", kind, a.Number, target)
} else {
out.Said = fmt.Sprintf("Linked issue %d to %s (%s).", a.Number, target, kind)
}
out.Announced, out.Warning = t.emit(EventLinked, map[string]any{"number": a.Number, "kind": kind, "target": target,
"removed": a.Remove, "by": by, "at": at})
return out, nil
}
// ListAsk narrows a list.
type ListAsk struct {
Status string
Owner string
Phrase string
Limit int
}
// Row is one issue in a list: short enough to read in a chat.
type Row struct {
Number uint64 `json:"number"`
Status string `json:"status"`
Owner string `json:"owner,omitempty"`
Title string `json:"title"`
}
// Listed is the answer to a list.
type Listed struct {
Rows []Row `json:"rows"`
Matched int `json:"matched"`
Said string `json:"said"`
}
// InFolder is the status a list shows for an issue whose folder in hq keeps its status.
const InFolder = "kept in its folder"
// List is the issues that match, newest first.
func (t *Tracker) List(a ListAsk) (*Listed, error) {
status := strings.TrimSpace(a.Status)
if status != "" && !known(status) {
return nil, fmt.Errorf("status %q is not one of %s", status, strings.Join(Statuses, ", "))
}
owner, phrase := "", ""
var err error
if strings.TrimSpace(a.Owner) != "" {
if owner, err = singleLine("owner", a.Owner, MostLine); err != nil {
return nil, err
}
}
if strings.TrimSpace(a.Phrase) != "" {
if phrase, err = singleLine("phrase", a.Phrase, MostPhrase); err != nil {
return nil, err
}
}
limit := a.Limit
if limit <= 0 {
limit = 50
}
limit = min(limit, MostListResult)
keys, err := t.Store.Keys(IssuesState)
if err != nil {
return nil, fmt.Errorf("the issues cannot be listed: %v", err)
}
var numbers []uint64
for _, k := range keys {
if n, ok := numberOfKey(k); ok {
numbers = append(numbers, n)
}
}
sort.Slice(numbers, func(i, j int) bool { return numbers[i] > numbers[j] })
out := &Listed{Rows: []Row{}}
lower := strings.ToLower(phrase)
for _, n := range numbers {
is, _, err := read(t.Store, n)
if errors.Is(err, ErrNoIssue) {
continue
}
if err != nil {
return nil, err
}
if status != "" && is.Status != status {
continue
}
if owner != "" && !locatedUnder(is.LocatedIn, owner) {
continue
}
if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) {
continue
}
out.Matched++
if len(out.Rows) < limit {
st := is.Status
if is.Record != "" {
st = InFolder
}
out.Rows = append(out.Rows, Row{Number: n, Status: st, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title})
}
}
switch {
case out.Matched == 0:
out.Said = "No issue matches."
case out.Matched > len(out.Rows):
out.Said = fmt.Sprintf("%d issues match; the newest %d are shown.", out.Matched, len(out.Rows))
default:
out.Said = fmt.Sprintf("%d issues match, newest first.", out.Matched)
}
return out, nil
}
// locatedUnder says whether an issue is located in the owner asked for: an entry that is the owner, or
// starts with it and a space.
func locatedUnder(located []string, owner string) bool {
for _, e := range located {
if e == owner || strings.HasPrefix(e, owner+" ") {
return true
}
}
return false
}
// newID is a random id for one open.
func newID() string {
var b [12]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// jitter is a random wait up to d, so openers that collided do not collide again in step.
func jitter(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(d)))
if err != nil {
return d
}
return time.Duration(n.Int64())
}