board

package
v0.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

backend.go is the seam. The FORMAT is the five event lines in event.go; a backend decides only where a line is appended and how the lines are read back, and it decides nothing about what a line means. The two backends must produce identical listings from identical histories — that is a test, not an aspiration — and this interface is what makes that cheap to keep true: nothing above it can learn which backend it has.

THE READ IS THE WHOLE LOG. There is no window. The prototype read the last forty comments, which means a card older than forty events VANISHES, and a vanished card makes the count fall without any work being done — the one thing a board may not do. A board is small by construction: it is what is owed, and what is owed is closed.

A CACHE MAY AVOID RE-READING; IT MAY NEVER TRUNCATE, and it may never outlive the run. The prototype kept a twenty-second time-to-live cache in a temporary directory, which is exactly wrong for the duplicate-filing failure this tool exists to close: two reviewers filing nine seconds apart both read the board as it was before either wrote. The cache here is a field in memory, held for one invocation, and INVALIDATED BY THIS TOOL'S OWN APPEND, so a close's read-before-append can never be answered from bytes read before this run wrote. There is no cache file anywhere, and rule 8 forbids one.

derive.go is events in, cards out. Nothing about a card is stored: the state, the owner, the counts and staleness are all derived from the log on every read, which is why the board can say the same thing to every line that reads it.

THE FOLD ORDER IS CAUSAL FIRST, THEN TOTAL, AND IT IS THE SAME IN EVERY CLONE. The after= edges form a directed acyclic graph rooted at the card event, and the fold walks it topologically: every predecessor before every event that names it. Among events that are eligible at one step — and eligible events are exactly the ones with no after= path between them, which is what concurrent means — the one that goes first is the smallest by the stable total key (at, as, id, verb, the line's bytes). The key is never applied across a causal edge: an event whose writer SAW another event folds after it whatever its clock says, because a clock behind is a clock and not a cause.

Two clones that merged the same concurrent appends in opposite textual orders therefore derive the same owner, and so do the two backends, which is the property the whole tool rests on. Never by the order the lines appear in a file or a comment thread.

WHAT IS COUNTED RATHER THAN HIDDEN. Two events with the same after= are concurrent: the fold chooses one and counts the pair in conflicts=, so a take that won a tie is visible as a tie rather than as the only take. Two card lines with one id and different fields are a conflict and neither silently wins. An event whose after= names no event of this card, one naming an event of another card, and one on a cycle are QUARANTINED: not folded, counted, named once, and never guessed at.

STALENESS IS THE ONLY CLOCK-DEPENDENT DERIVATION HERE, and it is an annotation made at read time from the take's own stamp. The board writes nothing for it: it has no way to know whether a line is silent, it knows only when the take was written.

dir.go is the directory backend: one file per card, `<dir>/<id>.board`, created by add and appended to by take and close. The board is a FOLD over every file in the directory.

ONE FILE PER CARD, ONE WRITER PER CARD, AND NO LOCK. There is no shared file and no index, so there is no serial step for two lines to queue behind: an add creates a file whose name is the tool-owned id, so two adds of one thing are two files; a take or a close appends one line to one file, and a second line appending to the same file appends a DIFFERENT line, which a union merge keeps in either order and the fold order sorts. The tool holds no lock because it needs none.

CREATION IS EXCLUSIVE AGAINST HAND-MADE FILES. The card file is created with O_EXCL and is never truncated. An id that already exists is a refusal at exit 1 and no file is ever replaced.

THE DIRECTORY BACKEND APPENDS AND NEVER RUNS GIT. Committing and pushing it is the caller's, and saying so is more honest than a push hidden inside add. That is the one asymmetry between the backends: an add --dir is durable when the caller lands it, and an add --issue is durable when the command returns.

Package board is the owed-work layer: a board is the list of things a group of lines owes, kept as an append-only log of events, and the list of open cards is DERIVED from that log rather than stored anywhere. This file is the format — the five event lines, rendered and parsed — and the two identities a board uses.

Two rules live here and nowhere else, because everything above them depends on both.

THE ID IS A DRAW, NEVER A DERIVATION. add reads 128 bits from the operating system's random source and renders them as thirty-two lower-case hex characters. It is computed from nothing — not the stamp, not the filer, not the text, not a count — so two adds from two clones, under one name, at one second, with one text, get two ids without reading anything first. An earlier draft derived the id from a hash over an unsynchronized sequence number, which is a read two writers can both make before either writes; a creation identity may depend on nothing of that kind.

PARSING IS ANCHORED AND BY ID. A line binds to a card only when it BEGINS with one of the five verbs and the id is the token immediately after it. The prototype matched `closed[ :]*<sid>` anywhere in any comment body, so on a board about a repository full of issue numbers a card whose text held a six-digit number could be closed by a sentence about something else.

Every key=value value on an event line goes through oneline.Field and the free text after the ": " through oneline.Escape, so an event is one line whatever a filer pastes and a whitespace-splitting scanner counts exactly the fields the tool wrote. The values a parsed event carries are the ESCAPED ones — that is what the board holds — and rendering them again is a fixed point, which is what makes a parsed event render back to the bytes it was read from.

issue.go is the issue-comment backend: one event per comment, in comment order, read and appended with `gh`. It is where today's board lives, and it is chosen for a good reason — every line in this family can already read and write it, from any machine, with no clone and no write access to a repo's branches.

`gh` stays, NAMED, under a timeout: a subprocess that never returns is a tool that has stopped saying anything, which from the outside is indistinguishable from a tool that is working. `jq` does not stay; the prototype had it as a second hard dependency and this is encoding/json.

The read is the WHOLE thread, fully paginated. The prototype's `.[-40:]` is the window this spec forbids.

Index

Constants

View Source
const (
	IDHex = 32
	EvHex = 12
)

IDHex and EvHex are the two identity widths. A card's id is 128 bits because two draws of it meeting is not an event this family will see; an event's is 48, which is enough to name a predecessor inside one card's history and short enough to read.

View Source
const Version = "BOARD v1"

Version is the first line of every card file: a later format read as this one would be entries nobody wrote.

Variables

View Source
var ErrExists = errors.New("id exists")

ErrExists is an append of a card whose id already exists. With a random id that is a hand-made file, a copied one, or a broken random source — never an overwrite.

View Source
var ErrNoCard = errors.New("no card with that id")

ErrNoCard is an append of a later event to a card the backend does not hold.

View Source
var Verbs = []string{"card", "taken", "closed", "landed", "probed"}

Verbs are the five event lines, and that is the whole format.

Functions

func Closing

func Closing(verb string) bool

Closing names the three closing events and the only three.

func Dur

func Dur(d time.Duration) string

Dur renders an age. A clock a hair behind another line's reads as zero rather than as a negative number, which is a number no reader has a use for.

func HashOf

func HashOf(renderedText string) string

HashOf is the content hash: the first twelve hex characters of a SHA-256 over the card's text AS RENDERED. The text only — not the leg, owner, deadline, default or evidence — and it is never an identity. It has one use: a note beside a filing that looks like one already open.

func Hex

func Hex(s string, n int) bool

Hex reports whether s is exactly n lower-case hex characters.

func NewEv

func NewEv(r io.Reader) (string, error)

NewEv draws an event's id: 48 bits from the source, twelve lower-case hex.

func NewID

func NewID(r io.Reader) (string, error)

NewID draws a card's id: 128 bits from the source, thirty-two lower-case hex.

func ParseIssue

func ParseIssue(spec string) (owner, repo string, number int, err error)

ParseIssue reads the `<owner>/<repo>#<n>` a caller names.

The three parts are checked rather than pasted: they become gh's own argv, so a value beginning with a dash is an OPTION to gh and not a repository, and this tool would run it. The charset is deliberately narrow — the cost of being narrow is a refusal a person reads, and the cost of being wide is a shell.

func Quote added in v0.13.0

func Quote(s string) string

Quote wraps a value for a shell line this tool prints to be pasted -- quickstart's pair and the mkdir remedy above. One quoting rule for every printed line, so a reader who pastes one has pasted them all.

func Render

func Render(e Event) string

Render writes the event as its one line, without a newline.

func SetGHBinary

func SetGHBinary(path string) (restore func())

SetGHBinary points this backend at one gh and returns the restore, for a test to hand to t.Cleanup. It is exported for the tests of the command that drives this backend in-process; nothing in the tool itself calls it.

func Stamp

func Stamp(t time.Time) string

Stamp is the one time format on a board: RFC 3339 in UTC, to the second.

func Tail

func Tail(s string) string

Tail prepares a free-text tail for an event line: capped at oneline.TailBytes with the mark that says so, then escaped. Cap runs BEFORE the escape, on a rune boundary, so an escape sequence is never halved. A caller builds a tail once, at creation; Render does not cap, so that re-rendering a parsed event cannot cut a tail twice.

Types

type Backend

type Backend interface {
	// Events reads the WHOLE log.
	Events() (Log, error)
	// Append adds one event line. The line is already rendered and already one line.
	Append(line string) error
}

Backend is where a board's lines live.

Two methods, and deliberately no third: nothing above this can ask which backend it has, so a rule cannot be written that holds in one and not the other.

type Board

type Board struct {
	Cards            []*Card
	Unparsed         int
	UnparsedFiles    int
	Quarantined      int
	Conflicts        int
	FirstQuarantined string
	Orphans          int // events naming a card no card event creates
}

Board is the whole fold.

func Derive

func Derive(log Log, now time.Time, stale time.Duration) *Board

Derive folds a log into cards. now and stale are the two clock inputs, injected rather than read, so that every derivation in the tests is a pure function of its arguments.

func (*Board) Card

func (b *Board) Card(id string) *Card

Card returns the card with this id.

func (*Board) Counts

func (b *Board) Counts() Counts

Counts sums the board.

func (*Board) Legs

func (b *Board) Legs() []Leg

Legs returns one row per leg the board holds rows on, in first-seen order.

func (*Board) Lines

func (b *Board) Lines() []Line

Lines returns one row per owner of an open card, in the order the cards are in, so the output is deterministic without being alphabetised into a shape the fold did not choose.

func (*Board) Next

func (b *Board) Next() (kind string, card *Card, leg string)

Next is the one thing to do first, by a fixed order: the oldest overdue card, else the oldest stale card, else the leg with the most owed rows, else the oldest ordinary OPEN card, else — and only when open is zero — nothing owed. A board with one fresh card and a future deadline HAS something to do first, and a tool that said "nothing owed" over it would be the count falling for the wrong reason, said in words.

type Card

type Card struct {
	ID        string
	Text      string
	Hash      string
	Owner     string // the latest take in the fold order, else the add's --owner, else the filer
	CardOwner string // the owner= ON THE CARD LINE, which a take never moves
	Filer     string
	By        string // the deadline as the add stored it
	Default   string
	Thing     string
	Leg       string
	Evidence  string

	Since    time.Time
	SinceRaw string
	State    string // OPEN or CLOSED, derived and stored nowhere
	Close    *Event // the FIRST close in the fold order; the later ones stay in the log

	LatestAt time.Time
	TakenAt  time.Time
	HasTake  bool

	Conflicts   int
	Quarantined int
	Conflict    bool
	Stale       bool
	Overdue     bool
	Row         bool
	Probed      bool

	// Age and Taken are rendered here because they are derivations from the injected
	// clock, and the printing side has no clock of its own.
	Age   string
	Taken string

	Events []Event // the card's events in the fold order, quarantined ones excluded
}

Card is one thing owed, as the log says it stands right now.

func (*Card) After

func (c *Card) After() string

After is the ev a writer appending to this card names as what it saw: the newest event in ITS OWN fold, and the card's id when there was none. A writer never names an event its fold does not hold — a quarantined event is not in the fold, so it is never named.

func (*Card) Holds

func (c *Card) Holds(ev string) bool

Holds reports whether this fold holds the event an after= names. take, close, land and probe refuse to append an after= their own fold does not hold.

func (*Card) Open

func (c *Card) Open() bool

Open reports whether this card is still owed.

func (*Card) Owed

func (c *Card) Owed() bool

Owed reports whether this card is a row of the owed ledger that has not been probed.

type Counts

type Counts struct {
	Cards, Open, Closed, Stale, Overdue, Owed, Lines, Conflicts, Quarantined int
}

Counts are the nine facts BOARD OK carries. They are the truth about the BOARD and never about the output: the listing is capped, the counting never is.

type Dir

type Dir struct {
	// contains filtered or unexported fields
}

Dir is the directory backend.

func MakeDir added in v0.13.0

func MakeDir(path string) (*Dir, bool, error)

MakeDir is NewDir for QUICKSTART, and it is the one entry here that creates the directory. Glenn ruled it on nova-tools #109, on Emma's report that quickstart refused a --dir that was not there: "It is best to do the right thing if a friend uses it a certain way, or to correct docs to show only right way. Pick one." The right thing is the one a friend already reached for -- `nova-swarm quickstart --pool ./pool` makes its pool, with MkdirAll and 0755, and this makes the board directory the same way.

IT IS QUICKSTART'S ALONE. Every other verb goes through NewDir and still refuses, because quickstart is the verb whose whole job is a first run and a first run has nowhere to write yet, while a `list` or a `take` against a directory that is not there is a caller who named the wrong path -- making it for them would answer a typo with an empty board. The created bool goes back to the caller because a verb that makes a directory silently leaves a reader unable to tell a new board from the wrong one.

A path that exists and is a FILE is untouched and keeps NewDir's refusal: MkdirAll would not fix that one, and a board is not a file this tool replaces.

func NewDir

func NewDir(path string) (*Dir, error)

NewDir returns the backend for a directory of card files. The directory must exist for every verb but quickstart, which makes it (MakeDir below). For the rest: a tool that made one would be guessing at where a caller meant to keep a board -- the board lives in somebody's repository, and which directory is tracked, and by which clone, is the caller's decision and not this tool's. SPEC-BOARD grants this backend one creation and names it: "One file per card, <dir>/<id>.board, created by add."

So the refusal CARRIES THE REMEDY. Emma, dogfooding v0.12.0 (nova-tools #104): a quickstart against a directory that did not exist exited 2 with a stat error and no way forward, while nova-swarm's quickstart makes its own pool. The asymmetry is deliberate -- a swarm pool is a layout this family owns, a board directory is one directory in somebody's repository -- but a refusal that does not say `mkdir -p` makes a reader guess at a tool that refuses to. The path is QUOTED in that remedy: a --dir with a space printed raw is two operands to the shell, so the paste makes two wrong directories and the next verb refuses again on the same path. A remedy that cannot be pasted is a second mistake, not a way forward.

func (*Dir) Append

func (d *Dir) Append(line string) error

Append writes one event line. A card event creates its file with O_EXCL and never truncates; every other event appends to the file its id names.

func (*Dir) Events

func (d *Dir) Events() (Log, error)

Events reads every card file whole and returns their lines. A file that is not `<thirty-two hex>.board` is COUNTED and never read as a card; a card file without its version line is an error, because a later format read as this one would be entries nobody wrote.

func (*Dir) Source

func (d *Dir) Source() string

Source is what the listing's source= field carries, so a listing cannot be mistaken for a different board's.

type Event

type Event struct {
	Verb     string // card, taken, closed, landed, probed
	ID       string // the card's thirty-two hex id
	Ev       string // this event's own twelve hex id; empty on card
	After    string // the newest ev in the writer's fold, or the card id; empty on card
	As       string // the --as of the verb that appended it: a label, never an identity
	At       time.Time
	AtRaw    string // what the line carried, kept when it will not parse
	AtOK     bool
	Override bool // true exactly when --anyway went over a refusal

	Hash     string // card: twelve hex over the rendered text, for a duplicate note only
	Owner    string // card: --owner, else the filer
	By       string // card: the deadline, stored as given
	Default  string // card: what happens if nobody closes it
	Thing    string // card: a row of the owed ledger
	Leg      string // card: the leg the row is owed on
	Evidence string // card: a path the filer named, carried and never opened
	In       string // landed: <repo>#<n>

	Tail string // card text, a close's how, a probe's evidence
	Line string // the bytes this event was parsed from, empty when it was built
}

Event is one line of a board's log.

A field holds the value as it appears ON THE WIRE once it has been through Render: the creating caller hands raw text and Render escapes it, and a parsed event holds the escaped form, which Render leaves alone. So Parse(Render(e)).Render() == Render(e).

func Parse

func Parse(line string) (Event, bool)

Parse reads one line of a board's log. It returns false for anything that is not exactly one of the five events — a blank line, a comment, prose, a line in the shape an earlier draft used — and the caller COUNTS those rather than guessing at them.

func (Event) CreationFields

func (e Event) CreationFields() string

CreationFields is everything on a card line but the stamp: what an --id retry must match for the retry to be the same filing rather than a different one.

func (Event) Render

func (e Event) Render() string

Render writes the event as its one line, without a newline. Every value goes through oneline.Field and the tail through oneline.Escape, so nothing a filer supplies can add a second line or pose as a field this tool did not write.

type Issue

type Issue struct {
	Reads int // how many times the thread was actually fetched, for the cache's test
	// contains filtered or unexported fields
}

Issue is the issue-comment backend.

func NewIssue

func NewIssue(spec string, timeout time.Duration) (*Issue, error)

NewIssue returns the backend for one issue thread.

func (*Issue) Append

func (i *Issue) Append(line string) error

Append posts one comment carrying one event line. The body goes in on STDIN rather than on the command line, so nothing an event holds is ever an argument to gh.

CREATION IS EXCLUSIVE HERE TOO. A card line is appended only after the thread is RE-READ — not from this run's cache — and the id looked for: the forge append is the only serial point this backend has, so this re-read is its half of the O_EXCL the directory backend gets from the filesystem. An id that already exists is a hand-made comment, a copied one, or a broken random source, and it is ErrExists with nothing posted, never a second card under one id folded silently into the first.

func (*Issue) Events

func (i *Issue) Events() (Log, error)

Events reads the whole thread, fully paginated, one event per comment.

func (*Issue) Source

func (i *Issue) Source() string

Source is what the listing's source= field carries.

type Leg

type Leg struct {
	Name         string
	Owed, Probed int
}

Leg is one leg of the owed ledger: the BOARD LEG row. Done means owed is zero, and the tool prints that number rather than a word.

type Line

type Line struct {
	Name                 string
	Open, Overdue, Stale int
}

Line is one owner's open work: the BOARD LINE row.

type Log

type Log struct {
	Lines         []string
	UnparsedFiles int
}

Log is what a backend hands back: the event lines it holds, and how many files in it were not cards at all. The second number is here rather than behind a type assertion so that nothing above the Backend interface learns which backend it has.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL