Documentation
¶
Overview ¶
Package board is the agents-board store (design 025): the in-memory model of a threaded, push-based stream of what every agent in a session is doing.
THE PACKAGE BOUNDARY IS THE POINT. This package must not import internal/tui or internal/actors. The founder has not yet ruled on whether the board is a ##mode or a PaneType (design 025 §8.1), and a store that knows nothing about bubbletea, panes or modes keeps that decision a small change in the view layer instead of a rewrite. Everything here is pure: no NATS, no I/O, no clock. The only dependency is the message contract in internal/msg.
Index ¶
- Constants
- Variables
- func BucketConfig(session string) *nats.KeyValueConfig
- func BucketName(session string) string
- func ConvertPDF(htmlPath, pdfPath string) error
- func CountViews(ts []Thread) (status, prompt int)
- func DefaultOutputDir() string
- func FlowBoardNodeID(b string) string
- func FormatItems(items []Item) string
- func IsPromptThread(t Thread) bool
- func IsStandaloneKey(key string) bool
- func ItemByWorker(it Item, worker string) bool
- func ItemClock(ts int64) string
- func NoMessagesLine(worker string) string
- func NormaliseComm(c string) string
- func OpenFile(path string) error
- func OutputFileName(board, worker string, n int, now time.Time, ext string) string
- func OutputSummary(picked, matched int, worker, path string) string
- func PDFConverter() (kind, path string)
- func RenderFlowDeps(d FlowDeps) []string
- func RenderFlowDepsFit(d FlowDeps, maxRows int) []string
- func RenderHTML(p HTMLPage) ([]byte, error)
- func RenderTeam(f Flow, nowMS int64, depRows int) []string
- func SanitiseBody(s string) string
- func SanitiseInline(s string) string
- func ServeQueries(nc *nats.Conn, storeFor func(board string) *Store, live LivePanesFunc) ([]*nats.Subscription, error)
- func StatusTone(value string) string
- func TeamCounts(f Flow) (members, blocked int)
- func TeamGoalCell(r TeamRow, nowMS int64) string
- func TeamGoalReport(f Flow) string
- func TruncationNotes(r *QueryReply) string
- func WriteOutput(req OutputRequest) (string, error)
- type CopyResult
- type ErrNoMessages
- type Event
- type EventKind
- type Flow
- type FlowDepCycle
- type FlowDepNode
- type FlowDeps
- type FlowEdge
- type FlowEvent
- type FlowNode
- type FlowPane
- type FlowStats
- type HTMLPage
- type InboxItem
- type Item
- type ItemArgs
- type KV
- type LivePanesFunc
- type OutputRequest
- type Persistence
- type PersistenceFor
- type Query
- type QueryReply
- type RosterEntry
- type Stats
- type Store
- func (s *Store) Apply(p *msg.MsgBoardPost) bool
- func (s *Store) ApplyEvent(ev Event)
- func (s *Store) Register(r *msg.MsgBoardRegister)
- func (s *Store) RetainRoster(livePaneIDs map[string]bool)
- func (s *Store) Roster() []RosterEntry
- func (s *Store) Stats() Stats
- func (s *Store) Threads() []Thread
- func (s *Store) VersionCounts() map[int]int
- type Subscriber
- type TeamRow
- type Thread
- type View
Constants ¶
const ( FlowPrompt = "prompt" FlowAck = "ack" FlowUndelivered = "undelivered" FlowReply = "reply" FlowStatus = "status" FlowChain = "chain" // an edge type only // FlowBlocks is an edge type only (design 054): a blocked member → each // member it is blocked by, from the fleet status ledger mirrored on pane // meta. A dependency, not traffic: it has no events and no count beyond 1. FlowBlocks = "blocks" )
Event types (design 052 §3.2).
const ( FlowNodeBoard = "board" FlowNodePane = "pane" FlowNodeCourier = "courier" FlowNodeListener = "listener" // FlowNodeFleet is the fleet actor (design 054): it posts check-ins and // alerts as the board identity "fleet". FlowNodeFleet = "fleet" )
Node kinds.
const ( FormatHTML = "html" FormatPDF = "pdf" )
Output formats.
const ( CopyUsage = "copy [worker] [N] copy agent messages to the clipboard (one item = one message)" OutputUsage = "output [worker] [N] html|pdf write messages to an HTML page or a PDF and open it" )
CopyUsage / OutputUsage are the grammar lines, shown together whenever either command is given no or bad arguments.
const ( // RetentionTTL is how long one board entry lives in the KV. RetentionTTL = 7 * 24 * time.Hour // RetentionMaxBytes bounds the bucket on disk. RetentionMaxBytes = 64 << 20 )
Durability (founder gate 2, 2026-08-08): the board SURVIVES A DAEMON RESTART.
It rides the JetStream KV that internal/bus already provisions — no second NATS server, no second store, no sidecar file. One KV entry per post, keyed by a zero-padded arrival ordinal so that listing the keys in lexical order replays the board in the order it actually happened.
THE RETENTION BOUND, because an unbounded board is a defect:
- RetentionTTL = 7 days per entry, enforced by JetStream itself. A fleet that ran last month is archaeology, not monitoring; a week survives a weekend outage and any plausible restart.
- RetentionMaxBytes = 64 MiB on the bucket, so a runaway poster bounds the disk rather than the disk bounding the machine.
- Restore replays at most MaxPosts (5000) — the newest entries. The store's in-memory cap is the same number, so *what you can see is what is kept*, and a restore cannot be slower than a cold board is big.
WHAT IS DURABLE AND WHAT IS STILL LOST — stated exactly, because the earlier non-goal "no persistence beyond the session" is now wrong and must not be replaced by an over-claim:
- DURABLE: every post a running board process receives. The KV write is UNCONDITIONAL on the NATS callback — it is never inside the render buffer's drop branch — so a full render buffer costs a live update, never the record. Subscriber.Dropped therefore means "not shown live", not "lost": the view can recover those from the KV.
- STILL LOST, and this limit stands: posts published while NO board process is running. Publishing is plain-NATS fire-and-forget (msg.SendBoardPost → nats.Conn.Publish), which has no store-and-forward; nobody subscribed means nobody wrote. Also lost: anything NATS itself drops when it declares a slow consumer.
- STILL LOST: everything older than RetentionTTL, and everything past RetentionMaxBytes or the MaxPosts restore window.
const CopyMaxBytes = 256 * 1024
CopyMaxBytes is the largest clipboard copy any surface makes. It matches internal/web/clipboard.go's clipboardMaxBytes (pinned by a test there). It bounds the CLIPBOARD only; `output` writes a file and has no cap.
const DefaultBufferSize = 1024
DefaultBufferSize is the event buffer between the NATS callbacks and the consumer.
Sized for the burst this epic exists for: a 44-agent fleet where every agent reports a milestone at once, plus headroom for a consumer that is mid-render. email_view.go uses 256 for a stream whose losses heal themselves; a board post's loss does not heal, so this is 4x that. Beyond it, drops are counted rather than hidden — see Subscriber.Dropped.
const DefaultView = ViewStatus
DefaultView is what a board pane opens on: the agents' own status, the stream a human watches to know how the fleet is doing.
const FlowEventCap = 200
FlowEventCap is how many events the model keeps: the newest ones.
const MaxPosts = 5000
MaxPosts bounds the store so a long session cannot grow it without limit.
The number is sized from the case that motivated the epic: a 44-agent fleet. At 44 agents this is ~110 messages each, which is more than a full day of milestone-grade posting. Eviction is by whole thread, oldest first (see evictLocked), because evicting a root while keeping its replies would turn a coherent thread into a pile of orphans — the one shape this store exists to avoid.
EVICTION IS A DOCUMENTED LOSS FROM THE VIEW, not from the record. Since founder gate 2 the board persists (persist.go): an evicted message is out of the in-memory board but still in the KV until its retention expires. The earlier non-goal "no persistence beyond the session" is dead — do not reintroduce it in a comment. Store.Stats().Evicted counts evictions so a view can say "N older messages are no longer shown" rather than silently shortening history.
const OutputDirEnv = "RYSH_BOARD_OUTPUT_DIR"
OutputDirEnv overrides where output files go.
Variables ¶
var ErrNeedsFormat = errors.New("output needs a format: html or pdf (e.g. ##board output 5 pdf)")
ErrNeedsFormat is `output` without html or pdf.
var ErrNoPDFConverter = errors.New("no PDF converter found (install Google Chrome/Chromium or wkhtmltopdf)")
ErrNoPDFConverter means neither Chrome/Chromium nor wkhtmltopdf is installed.
var ErrNoRecorder = errors.New("board: the recorder did not answer")
ErrNoRecorder means nobody answered the query.
It is a distinct error and not an empty result on purpose. "The recorder did not answer" and "the recorder answered, and the board is empty" are different facts about the world, and a caller that cannot tell them apart will print silence as health. Every caller must branch on this.
var UsageLines = []string{
"copy all messages on the board",
"copy 3 the last 3 messages",
"copy issue-314 all of issue-314's messages",
"copy issue-314 3 issue-314's last 3 messages",
"output issue-314 html all of issue-314's messages as HTML",
"output 5 pdf the last 5 messages as PDF",
"output issue-314 3 pdf issue-314's last 3 messages as PDF",
"flags: --board <id> (another board), --no-open and --out <dir> (output)",
}
UsageLines are the examples every surface prints under the grammar.
Functions ¶
func BucketConfig ¶
func BucketConfig(session string) *nats.KeyValueConfig
BucketConfig is the bucket the bus should create for the board. It is here, next to the retention constants, so the numbers and the thing they configure cannot drift apart.
func BucketName ¶
BucketName is the KV bucket for a session's board. Namespaced by session exactly like the buckets in internal/bus, so several sessions can share one NATS server without sharing a board.
func ConvertPDF ¶ added in v0.2.11
ConvertPDF prints htmlPath to pdfPath. On failure the error says why and always names the HTML file, which exists either way.
func CountViews ¶ added in v0.2.11
CountViews counts a board's threads per view: (status, prompt).
func DefaultOutputDir ¶ added in v0.2.11
func DefaultOutputDir() string
DefaultOutputDir is $RYSH_BOARD_OUTPUT_DIR, else $TMPDIR/rysh-board-output.
func FlowBoardNodeID ¶ added in v0.2.11
FlowBoardNodeID is the node id of board b.
func FormatItems ¶ added in v0.2.11
FormatItems is the clipboard text: per item a header line and the raw body, a blank line between items, oldest first, no trailing newline (a stray newline pasted into a shell runs whatever came before it).
● <persona> [<kind>] <YYYY-MM-DD HH:MM:SS> <original post text>
func IsPromptThread ¶ added in v0.2.11
IsPromptThread reports whether a thread is a prompt conversation: its root is a command. A provisional thread (its root not arrived yet) is judged by what it holds: any command or ack in it makes it a prompt thread.
func IsStandaloneKey ¶ added in v0.2.6
IsStandaloneKey reports whether a thread key is one the store minted for a post that arrived with no ThreadID — a root no agent can reply to, because no agent can name it.
Exported for the read path (query.go / `rysh board tail`). A renderer needs this for two reasons, and the second is the real one: the key's first byte is a NUL, which a terminal swallows, so printing it raw emits an invisible control character; and the key is an ADDRESS a reader would reasonably try to pass back to `board reply`, where it can never work. Showing it at all is the mistake — this is what lets a renderer not show it.
func ItemByWorker ¶ added in v0.2.11
ItemByWorker is Select's worker predicate.
func ItemClock ¶ added in v0.2.11
ItemClock is the full local date-time of a post, the poster's clock.
func NoMessagesLine ¶ added in v0.2.11
NoMessagesLine is the refusal for an empty selection.
func NormaliseComm ¶ added in v0.2.11
NormaliseComm maps a fleet.comm meta value to queue | grid | input-stream: polling-stream is grid's old name (design 051).
func OpenFile ¶ added in v0.2.11
OpenFile opens path with the platform opener, without waiting for it.
func OutputFileName ¶ added in v0.2.11
OutputFileName is board-<board>[-<worker>][-last<N>]-<YYYYMMDD-HHMMSS>.<ext>. The worker is reduced to a filename-safe form; it is agent-chosen text.
func OutputSummary ¶ added in v0.2.11
OutputSummary is the confirmation every output surface prints.
func PDFConverter ¶ added in v0.2.11
func PDFConverter() (kind, path string)
PDFConverter reports which converter would be used ("" when none).
func RenderFlowDeps ¶ added in v0.2.11
RenderFlowDeps draws the dependency tree as plain lines (no styling), the same for the TUI's flow view and `rysh board tail --view flow`. Empty when nobody is blocked.
dependencies · 3 blocked · bottleneck: mgr-a (3 waiting)
▣ mgr-a {progress·working} ← 3 waiting
├─ wk-a1 {blocked·idle} ← 1 waiting
│ └─ ceo {blocked·idle}
└─ wk-a3 {blocked·idle} · also blocked by human
⟳ DEADLOCK a → b → a · 1 waiting on it
func RenderFlowDepsFit ¶ added in v0.2.11
RenderFlowDepsFit draws the tree in at most maxRows lines. When the full tree does not fit it falls back to one line per root and per deadlock — each root with its direct waiters inline — because the roots ARE the answer to "where is the bottleneck", and a truncated tree hides the later roots and every deadlock (found on a live board: the human blocker and the deadlock were both below the cut). Only then is the list itself cut.
func RenderHTML ¶ added in v0.2.11
RenderHTML draws the page.
func RenderTeam ¶ added in v0.2.11
RenderTeam draws the team view as plain lines: the table, a health summary, then the dependency tree in at most depRows lines (0 = no limit). nowMS dates the LAST POST column.
MEMBER WORK ACTIVITY HEALTH LAST POST TASK / BLOCKING
lead blocked idle ok 12s ⧗ wkr-2 · waiting for SDK
└─ wkr-1 progress working ok 40s schema v3
health: 2 ok · 1 blocked
dependencies · 1 blocked · bottleneck: wkr-2 (1 waiting)
▣ wkr-2 {progress·working} ← 1 waiting
└─ lead {blocked·idle}
func SanitiseBody ¶ added in v0.2.11
SanitiseBody makes an agent-written body safe to copy, print or render while keeping it otherwise byte for byte: newlines and tabs stay, CRLF and lone CR become LF, and every terminal escape sequence is removed WHOLE — not just its ESC byte, which would leave `[31m` litter — along with the other C0/C1 controls and DEL.
func SanitiseInline ¶ added in v0.2.11
SanitiseInline is SanitiseBody for a one-line value (persona, kind).
func ServeQueries ¶ added in v0.2.6
func ServeQueries(nc *nats.Conn, storeFor func(board string) *Store, live LivePanesFunc) ([]*nats.Subscription, error)
ServeQueries answers board queries from s for as long as the returned subscription lives.
The caller owns the subscription's lifetime, and that is the whole liveness contract: ABLA unsubscribes when it stops, so a dead recorder GOES QUIET and a caller's timeout is the honest answer rather than a stale artifact's. Same property as msg.BoardAliveSubject, for the same reason.
live may be nil, in which case the roster is served unreconciled and says so. storeFor resolves the board a query names. Returning nil is an ANSWER — "I record that board and it is empty" — and not a refusal, because the recorder hears every board in the session through one wildcard (subscriber.go). A board nobody has posted to yet is empty, and saying so is true.
It must not be used to mean "I do not serve that board": a recorder that refuses a board it is in fact recording would make an honest reader report ErrNoRecorder for a board that works.
func StatusTone ¶ added in v0.2.11
StatusTone is the badge class for a STATUS value: blocked → bad (red), waiting → warn (amber), progress → info (blue), done/milestone → good (green), idle or anything else → muted (grey).
func TeamCounts ¶ added in v0.2.11
TeamCounts is what the team view's header says: members and how many wait.
func TeamGoalCell ¶ added in v0.2.11
TeamGoalCell renders a member's goal verdict: "blocker 40% eta 12m", "STRESSED", "progressing OVERDUE 5m". The conditions that need someone to act shout, like unhealthy health does.
func TeamGoalReport ¶ added in v0.2.11
TeamGoalReport is the first line of the newest goal-report on the board (design 055), "" when no goal executor has reported.
func TruncationNotes ¶ added in v0.2.11
func TruncationNotes(r *QueryReply) string
TruncationNotes says what a recorder window left out, or "".
func WriteOutput ¶ added in v0.2.11
func WriteOutput(req OutputRequest) (string, error)
WriteOutput renders, writes, converts and opens. It returns the file that was produced. An empty selection is refused before anything is written.
Types ¶
type CopyResult ¶ added in v0.2.11
CopyResult is a built copy.
func BuildCopy ¶ added in v0.2.11
func BuildCopy(r *QueryReply, worker string, n int) (CopyResult, error)
BuildCopy runs the whole copy pipeline on a recorder answer. A nil answer is ErrNoRecorder — nobody answering is not an empty board.
func BuildCopyItems ¶ added in v0.2.11
func BuildCopyItems(items []Item, worker string, n int) (CopyResult, error)
BuildCopyItems is BuildCopy over items already in hand (the pane's y / Y).
func (CopyResult) Summary ¶ added in v0.2.11
func (c CopyResult) Summary() string
Summary is the confirmation every copy surface prints.
type ErrNoMessages ¶ added in v0.2.11
type ErrNoMessages struct{ Worker string }
ErrNoMessages means the recorder answered and nothing matched. Callers refuse on it rather than copying "" or writing an empty page.
func (ErrNoMessages) Error ¶ added in v0.2.11
func (e ErrNoMessages) Error() string
type Event ¶
type Event struct {
Kind EventKind
// Board is the board this message was DELIVERED TO, read from the subject
// (design 028). It is never read from the payload: a post carries no board
// id, which is what keeps founder gate 4 closed. A consumer holding several
// boards routes on this field.
Board string
Post *msg.MsgBoardPost
Register *msg.MsgBoardRegister
}
Event is one decoded board message on its way to a Store.
type Flow ¶ added in v0.2.11
type Flow struct {
Board string `json:"board"`
Nodes []FlowNode `json:"nodes"`
Edges []FlowEdge `json:"edges"`
Events []FlowEvent `json:"events"`
Stats FlowStats `json:"stats"`
}
Flow is the model (design 052 §3.7).
type FlowDepCycle ¶ added in v0.2.11
type FlowDepCycle struct {
Members []string // labels, in edge order: each is blocked by the next
// Waiting is how many agents outside the ring wait on it.
Waiting int
}
FlowDepCycle is one deadlock.
type FlowDepNode ¶ added in v0.2.11
type FlowDepNode struct {
ID string
Label string
Work string
Activity string
Health string
// Waiting is how many distinct agents wait on this one, directly or
// transitively.
Waiting int
// Also are this agent's OTHER blockers — it is drawn once under each, and
// a reader must know that unblocking one parent is not enough.
Also []string
// Repeat marks a node already drawn higher in the same root's tree (a
// diamond): its subtree is not drawn twice.
Repeat bool
Children []FlowDepNode
}
FlowDepNode is one agent (or outside blocker) in the dependency tree.
type FlowDeps ¶ added in v0.2.11
type FlowDeps struct {
// Roots are the blockers nobody blocks, most-waited-on first.
Roots []FlowDepNode
// Cycles are deadlocks: agents blocked on each other in a ring. No root
// reaches them, so they are listed apart.
Cycles []FlowDepCycle
// Blocked is how many agents are blocked at all.
Blocked int
}
FlowDeps is the whole dependency picture of a board.
func BuildFlowDeps ¶ added in v0.2.11
BuildFlowDeps derives the dependency tree from a flow model.
func (FlowDeps) Bottleneck ¶ added in v0.2.11
func (d FlowDeps) Bottleneck() *FlowDepNode
Bottleneck is the root most agents wait on, or nil when nobody is blocked.
type FlowEdge ¶ added in v0.2.11
type FlowEdge struct {
Type string `json:"type"`
From string `json:"from"`
To string `json:"to"`
Count int `json:"count"`
Answered *int `json:"answered,omitempty"`
Undelivered *int `json:"undelivered,omitempty"`
Open *int `json:"open,omitempty"`
LatencyMS *int64 `json:"latency_ms,omitempty"`
LastTS int64 `json:"last_ts,omitempty"`
}
FlowEdge aggregates one (type, from, to). Answered/Undelivered/Open and LatencyMS are meaningful on prompt edges only, but answered/undelivered/open are always present on those (the fixture says so), hence pointers.
type FlowEvent ¶ added in v0.2.11
type FlowEvent struct {
TS int64 `json:"ts"`
Type string `json:"type"`
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
Thread string `json:"thread"`
CmdID string `json:"cmd_id,omitempty"`
Summary string `json:"summary"`
}
FlowEvent is one classified post.
type FlowNode ¶ added in v0.2.11
type FlowNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Role string `json:"role,omitempty"`
Parent string `json:"parent,omitempty"`
Comm string `json:"comm,omitempty"`
Posts int `json:"posts"`
// The fleet status ledger (design 054), from pane meta; omitted for a
// pane in no watched fleet. Work is declared, Activity observed, Health
// derived; BlockedBy are pane ids (or "human" / "ext:<what>").
Work string `json:"work,omitempty"`
Task string `json:"task,omitempty"`
Activity string `json:"activity,omitempty"`
Health string `json:"health,omitempty"`
BlockedBy []string `json:"blocked_by,omitempty"`
// The goal executor's verdict (design 055), from pane meta: the primary
// condition (progressing, blocker, stressed, strayed, …), declared
// progress and ETA (unix millis). Omitted with no executor, so a board
// without one renders byte-identically to before.
GoalState string `json:"goal_state,omitempty"`
GoalPct *int `json:"goal_pct,omitempty"`
GoalEta int64 `json:"goal_eta,omitempty"`
}
FlowNode is a board, a pane, the courier or a listener.
type FlowPane ¶ added in v0.2.11
type FlowPane struct {
ID string `json:"id"`
Name string `json:"name"`
Meta map[string]string `json:"meta"`
}
FlowPane is one session pane as the model needs it: id, display name, meta.
func FlowPanesFromSnapshot ¶ added in v0.2.11
func FlowPanesFromSnapshot(s *domain.WorkspaceSnapshot) []FlowPane
FlowPanesFromSnapshot lists the panes of a workspace snapshot as BuildFlow takes them: name is the given-name, else the auto-title (msg.BoardPersona's order, without its pane-<8> fallback, which BuildFlow applies itself).
Only panes that can host an agent: a board pane carries `board.id` too (it names the board it shows), and would otherwise appear in its own flow as an agent that never posts. Same predicate the roster seed uses.
type FlowStats ¶ added in v0.2.11
type FlowStats struct {
Panes int `json:"panes"`
Prompts int `json:"prompts"`
Answered int `json:"answered"`
Undelivered int `json:"undelivered"`
Open int `json:"open"`
Status int `json:"status"`
// Blocked is how many pane nodes declare blocked (design 054).
Blocked int `json:"blocked,omitempty"`
}
FlowStats are the header counts.
type HTMLPage ¶ added in v0.2.11
type HTMLPage struct {
Board string
Worker string
N int // the count asked for; 0 = all
Picked []Item // oldest first
Matched int // the worker's items before N
Total int // every item on the board window
Now time.Time
Loc *time.Location // nil = time.Local
}
HTMLPage is what RenderHTML draws.
type InboxItem ¶ added in v0.2.11
type InboxItem struct {
CmdID string `json:"cmd_id"`
ThreadID string `json:"thread_id"`
From string `json:"from"` // sender persona, display only
FromPaneID string `json:"from_pane_id"` // sender identity
TS int64 `json:"ts"`
Text string `json:"text"`
File string `json:"file,omitempty"`
// Answered is true once the reader acked the command or posted anything
// on its thread. A reply is an answer: design 050 §5.4 makes
// `fleetctl report` the ack, and a report is a post on that thread.
Answered bool `json:"answered"`
AckTS int64 `json:"ack_ts,omitempty"` // the first answer's time
}
InboxItem is one command addressed to the reader.
func FindCommand ¶ added in v0.2.11
func FindCommand(r *QueryReply, paneID, cmdID string) (InboxItem, bool)
FindCommand returns the command cmdID addressed to paneID, if the board holds it. When two senders used the same id, the newest wins — `board ack` names only an id, and the newest is the one the reader was just handed.
func Inbox ¶ added in v0.2.11
func Inbox(r *QueryReply, paneID string) []InboxItem
Inbox lists the commands on a board addressed to paneID (a FULL pane uuid), newest first. A command counts once even if a retried send put it on the board twice: the identity is (sender, CmdID).
type Item ¶ added in v0.2.11
type Item struct {
Post *msg.MsgBoardPost // a sanitised copy; never the store's own pointer
ThreadKey string
Reply bool
}
Item is one agent message.
func Flatten ¶ added in v0.2.11
func Flatten(r *QueryReply) []Item
Flatten turns a recorder answer into items, oldest first by the poster's clock (ties keep the board's arrival order). Every post is a sanitised COPY: Store.Threads() hands out the store's own pointers, and scrubbing them in place would scrub the live board.
func FlattenWhere ¶ added in v0.2.11
func FlattenWhere(r *QueryReply, keep func(*msg.MsgBoardPost) bool) []Item
FlattenWhere is Flatten keeping only the posts keep accepts, judged on the ORIGINAL post (so a caller can select by identity, e.g. the posts a pane has on screen). A nil keep keeps everything.
func Select ¶ added in v0.2.11
Select picks items. worker "" is everyone; otherwise it must match a persona EXACTLY — `issue-31` never matches `issue-314`, the mistake a prefix match makes on any fleet with more than nine units — or a pane id, whole or by a prefix of at least 8 characters (optionally spelt `pane-<prefix>`, the persona fallback's spelling). n == 0 is all; otherwise the last n matched.
matched and total are returned so every caller can say "k of m": matched counts the worker's items, total the whole window.
type ItemArgs ¶ added in v0.2.11
type ItemArgs struct {
Worker string
N int
Format string // output only: html | pdf
Board string // --board / --fleet; "" = the caller's own board
Out string // output only: --out <dir>
NoOpen bool // output only: --no-open
}
ItemArgs is a parsed `copy` / `output` request.
func ParseItemArgs ¶ added in v0.2.11
ParseItemArgs parses `copy` (output=false) or `output` (output=true) arguments. Positionals may come in any order: a bare integer is N, html or pdf is the format (output only), anything else is the worker. A second worker, a second N, or an unknown flag is REFUSED — a silently dropped argument copies something other than what was asked for.
type KV ¶
type KV interface {
Put(key string, value []byte) (uint64, error)
Get(key string) (nats.KeyValueEntry, error)
Keys(opts ...nats.WatchOpt) ([]string, error)
}
KV is the slice of nats.KeyValue this package uses. Narrow on purpose: it keeps internal/board out of the bus's dependency tree and makes the store testable against a fake as well as against a real JetStream.
type LivePanesFunc ¶ added in v0.2.6
LivePanesFunc reports the panes that exist in the session RIGHT NOW, as a set of pane ids.
THE ERROR IS NOT DECORATION AND MUST NOT BE SWALLOWED INTO AN EMPTY MAP. "I could not ask" and "I asked and nobody is here" are different facts, and only one of them licenses deleting anything. Collapsing them is the whole of F-26's danger: reconciling against a failed round trip would wipe the entire roster the first time the workspace was busy, which is strictly worse than the ghost it fixes.
type OutputRequest ¶ added in v0.2.11
type OutputRequest struct {
Page HTMLPage
Format string // html | pdf
Dir string // "" = DefaultOutputDir()
Open bool
}
OutputRequest is one `output` run.
type Persistence ¶
type Persistence struct {
// contains filtered or unexported fields
}
Persistence writes board messages to a KV and replays them on restart.
The zero value is not usable; the nil *Persistence is, and does nothing — that is what a board running without JetStream gets, and it degrades to a live-only board rather than refusing to start. ONE PERSISTENCE PER BOARD, AND THE ORDINAL IS PER BOARD TOO (design 028).
Two Persistence values over one board that read the KV at the same moment agree on the next free ordinal and both write that key: the second Put OVERWRITES the first, so a second writer does not duplicate history, it destroys it (TestTwoWritersThatBothPrimedFirstStillDestroyHistory).
prime() narrows the hazard to that concurrent case — a writer created LATER continues where the previous one stopped instead of restarting at 1 — but it cannot close it, and nothing here should be read as making a second writer safe. Board ids do not weaken the rule either, they SCOPE it: the invariant is one writer per BOARD, which is why the key prefix and the ordinal move together.
func NewPersistence ¶
func NewPersistence(kv KV, board string) *Persistence
NewPersistence returns a Persistence over kv for one board. A nil kv yields a nil Persistence, whose methods are no-ops.
board is a board id (msg.DefaultBoardID / "" for the session board). An INVALID id is treated as the default board rather than being allowed to become a key prefix, on the same defence-in-depth argument as the subject builders: the edges validate, and this bounds what happens if one did not.
func (*Persistence) Restore ¶
func (p *Persistence) Restore(s *Store) (posts int, registrations int, err error)
Restore replays the persisted board into s and returns how many posts and registrations were applied.
Posts replay in key order, which is arrival order, so threading and orphan re-parenting reconstruct exactly as they happened live. Only the newest MaxPosts entries are read: the store would evict anything older on the way in anyway, so fetching them would be work whose only product is garbage.
A key that fails to fetch or decode is SKIPPED, not fatal. A board that refuses to start because one entry went bad is worse than a board missing one message, and the count returned tells the caller what it really got.
func (*Persistence) SavePost ¶
func (p *Persistence) SavePost(post *msg.MsgBoardPost) error
SavePost persists one post. Called unconditionally from the subscriber's NATS callback — never from inside the render buffer's drop branch — so a full render buffer never costs durability.
func (*Persistence) SaveRegister ¶
func (p *Persistence) SaveRegister(r *msg.MsgBoardRegister) error
SaveRegister persists a persona announcement, keyed by pane so the roster does not grow with every re-announcement.
type PersistenceFor ¶ added in v0.2.6
type PersistenceFor func(board string) *Persistence
PersistenceFor resolves the single writer for a board. Returning nil for a board makes the feed READ-ONLY for it — which is what every TUI passes, and what keeps the session's memory out of a process that may not be running.
The function is called on the NATS callback goroutine, so an implementation that creates a Persistence on demand must not do I/O beyond what Persistence.prime already does on the first write.
func SingleBoardPersistence ¶ added in v0.2.6
func SingleBoardPersistence(board string, p *Persistence) PersistenceFor
SingleBoardPersistence is the PersistenceFor of a caller that records exactly one board and ignores the rest. It exists so that "one board" cannot be spelled as "return p for anything", which would write every board's posts into one board's keys.
type Query ¶ added in v0.2.6
type Query struct {
// Since keeps only threads with activity at or after this unix-millis
// instant. It is compared against the poster's clock (MsgBoardPost.TS), so
// it inherits that field's honest limit: arrival order, not causal order.
Since int64 `json:"since,omitempty"`
// Limit bounds how many THREADS come back, keeping the most recent — this
// is `tail`, so the end of the board is what you want. Zero means unbounded.
//
// Threads, not posts, because the store's own eviction is by whole thread
// (store.go evictLocked): splitting a thread to hit a post count would
// manufacture exactly the orphan pile both bounds exist to avoid.
Limit int `json:"limit,omitempty"`
}
Query is a read request against the recorder's board.
Both bounds are optional and the zero Query means "everything you have".
type QueryReply ¶ added in v0.2.6
type QueryReply struct {
Threads []Thread `json:"threads"`
Roster []RosterEntry `json:"roster"`
Stats Stats `json:"stats"`
// Filtered counts threads dropped by Since; Withheld counts threads dropped
// by Limit. Both are here so a truncated answer can say so out loud.
Filtered int `json:"filtered"`
Withheld int `json:"withheld"`
// RosterReconciled reports whether Roster was checked against the panes
// that actually exist, or is being served as recorded.
//
// False is not an error and does not invalidate the answer — it means the
// recorder could not confirm who is alive, so the roster may still contain
// panes that have closed (F-26). It is here because the alternative to
// disclosing it is a caller that cannot tell an authoritative roster from a
// best-effort one, which is the same shape of defect as the ghost itself:
// something that looks certain and is not.
RosterReconciled bool `json:"roster_reconciled"`
// Err carries a refusal from the recorder. A recorder that cannot answer
// says so; it never answers with an empty board, for the reason at the top
// of this file. Ask turns a non-empty Err into an error.
Err string `json:"err,omitempty"`
}
QueryReply is what the recorder answers.
Stats describe the WHOLE board; Threads is the window Since/Limit asked for. That is deliberate — the counters are how a caller says "N more exist that I am not showing you" instead of silently presenting a window as the whole thing (the discipline design 025 §7.1a puts on eviction).
func Answer ¶ added in v0.2.6
func Answer(s *Store, q Query) QueryReply
Answer builds the reply to a Query from a Store. Pure: no NATS, no clock, so the filtering rules are testable without standing anything up.
KNOWN LIMIT, stated rather than implied: this is NOT an atomic snapshot. Threads, Roster and Stats each take the store's lock separately, so a post arriving mid-answer can be counted in Stats and absent from Threads. The board is a monitoring view and not an audit log (design 025 §7.2), and the alternative — holding the store's lock across the whole answer — would let a reader stall the collector that feeds it. A reader that sees Stats.Posts one ahead of what it can enumerate is seeing a live board, not a broken one.
func Ask ¶ added in v0.2.6
Ask puts a Query to the recorder and returns its answer.
It returns (nil, ErrNoRecorder) when nobody answers — no responder, a timeout, or no connection at all. It NEVER returns a usable-looking empty reply alongside an error, so a caller that forgets to check err has nothing to render rather than an empty board to render confidently. board names which board to read; "" is the session board.
func FilterReply ¶ added in v0.2.11
func FilterReply(r *QueryReply, v View) *QueryReply
FilterReply returns a copy of r showing only v's threads. Stats, roster and counters still describe the whole board — a view narrows what is listed, never what is claimed about the board.
func ItemsReply ¶ added in v0.2.11
func ItemsReply(r *QueryReply, picked []Item) QueryReply
ItemsReply rebuilds a QueryReply holding only the picked items, grouped back into their threads in thread order — the JSON a filtered `rysh board tail --json` prints, in the shape scripts already parse.
type RosterEntry ¶
type RosterEntry struct {
PaneID string `json:"pane_id"`
Persona string `json:"persona"`
TS int64 `json:"ts"`
}
RosterEntry is one agent the board knows about, from MsgBoardRegister.
Deliberately just pane + persona + clock. There is no Fleet, Role or Unit here because gate 4 removed them from the contract: a board post is CHAT — who spoke, under which thread — and the fleet's FROM/TO routing envelope is a fleet concern. Their absence is what makes gate 3 ("every claude in the session may post, not only fleet members") real rather than aspirational: with no fleet fields at all, there is no field that can be missing for a non-fleet claude, so nothing downstream can drift into treating a fleet poster as more first-class than any other.
type Stats ¶
type Stats struct {
Threads int `json:"threads"` // total threads, provisional included
Provisional int `json:"provisional"` // threads still missing their root
Posts int `json:"posts"` // roots + replies currently held
Duplicates uint64 `json:"duplicates"`
Evicted uint64 `json:"evicted"`
// UnknownVersion counts posts whose V is not BoardSchemaVersion. They are
// KEPT, not dropped — a board that discards a message because it was
// written by a newer agent is worse than one that renders it plainly.
UnknownVersion uint64 `json:"unknown_version"`
}
Stats are the counters a view needs to be honest with the human about what it is not showing.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store holds the board. It is safe for concurrent use: a Subscriber pump may feed it from the NATS callback goroutine while a view reads it from the render goroutine, and that is the intended deployment.
func (*Store) Apply ¶
func (s *Store) Apply(p *msg.MsgBoardPost) bool
Apply files one post. It reports whether the post was accepted; false means it was a duplicate of one already held (or nil).
Nothing is rejected for being unfamiliar. An unknown Kind renders as-is — Kind is free-form by contract, because fleetctl's --kind already is. An unknown schema version is counted and kept.
func (*Store) ApplyEvent ¶
ApplyEvent files whichever message an Event carries, so a consumer draining a Subscriber does not have to switch on the kind itself.
func (*Store) Register ¶
func (s *Store) Register(r *msg.MsgBoardRegister)
Register records a persona announcement. Registration is ADVISORY: a post from a pane that never registered is still rendered (see Apply). A board that drops messages from agents which forgot to introduce themselves is worse than one with a thin roster.
Last announcement wins — a pane that is renamed re-announces.
func (*Store) RetainRoster ¶
RetainRoster drops roster entries for panes that no longer exist.
Registration is persistent (founder gate 2), so without this the roster accumulates GHOSTS: a pane that registered, then closed — or that belonged to an earlier session entirely — keeps its entry forever. Observed live before this existed: a board reporting "3 agents" in a session with two, the third being a pane id from a previous run.
A roster that overcounts is worse than no roster, by the same argument that a stale name is worse than no name — it looks authoritative and is wrong. Live pane ids are therefore authoritative for WHO EXISTS, while registrations stay authoritative for WHAT THEY ARE CALLED.
An empty or nil set is treated as "caller does not know" and retains everything, so a momentarily empty snapshot cannot wipe the roster.
func (*Store) Roster ¶
func (s *Store) Roster() []RosterEntry
Roster returns the announced agents, ordered by pane id so a view renders a stable list.
func (*Store) Threads ¶
Threads returns every thread in arrival order — roots by their own arrival, replies in the order they arrived under the root.
func (*Store) VersionCounts ¶
VersionCounts reports how many posts arrived at each schema version, so a view (or an operator) can see a fleet mid-upgrade rather than guess.
type Subscriber ¶
type Subscriber struct {
// contains filtered or unexported fields
}
Subscriber is the live board feed: EVERY board in the session, decoded onto one buffered channel and tagged with the board it arrived on.
Four subscriptions, not two, and the pairing is the point (design 028): the wildcard `board.*.post` hears every named board, and the legacy `board.post` hears the default one, whose subject is one token shorter and therefore does not match the wildcard. One connection and one callback path per family, no per-board subscription bookkeeping, and a board that appears mid-session is heard without anyone being told it exists.
func Subscribe ¶
func Subscribe(nc *nats.Conn, codecs *msg.CodecRegistry, buf int, persist PersistenceFor) (*Subscriber, error)
Subscribe wires the board subjects onto a buffered channel. buf <= 0 means DefaultBufferSize. persist may be nil, which gives a live-only board rather than a refusal to start.
The caller drains Events() and must Close when done.
Subjects come from msg.BoardPostSubject / msg.BoardRegisterSubject, which are built with msg.T(...) — never literals, because T's prefix is the SESSION NAME and a literal "rysh.board.post" breaks the moment a session is called anything else (rysh-shared/msg/topics.go).
func (*Subscriber) Close ¶
func (s *Subscriber) Close()
Close unsubscribes. The event channel is left open: a consumer draining it in a select would otherwise spin on a closed channel, and an abandoned buffered channel is collected with the Subscriber.
func (*Subscriber) Dropped ¶
func (s *Subscriber) Dropped() uint64
Dropped is how many messages the render buffer refused. With persistence configured these are NOT lost — the KV write is unconditional, so a dropped event was still recorded — and a view reports them as "not shown live", recoverable by re-reading. Without persistence they are gone, and the view must say so.
func (*Subscriber) DroppedFor ¶ added in v0.2.6
func (s *Subscriber) DroppedFor(board string) uint64
DroppedFor is the same count for ONE board, which is what a board view must render: a session-wide number shown on one fleet's board is a claim about that fleet that is not true.
func (*Subscriber) Events ¶
func (s *Subscriber) Events() <-chan Event
Events is the feed. A consumer drains it and calls Store.ApplyEvent.
func (*Subscriber) WriteErrors ¶
func (s *Subscriber) WriteErrors() uint64
WriteErrors counts messages the KV refused. A non-zero value means the board is live-only for those messages: they were delivered but will not survive a restart. Surfaced rather than logged-and-forgotten, because "the board persists" quietly becoming false is the failure mode worth seeing.
type TeamRow ¶ added in v0.2.11
type TeamRow struct {
ID string
Label string
Role string
Work string
Activity string
Health string
Task string
BlockedBy []string // labels, not ids
LastPost int64 // unix millis of the member's newest post; 0 = none
// The goal executor's verdict (design 055); empty with no executor.
GoalState string
GoalPct *int
GoalEta int64
}
TeamRow is one fleet member as the team table shows it.
type Thread ¶
type Thread struct {
// Key addresses the thread. For a normal thread it is the poster-minted
// "<pane-uuid>/<n>" that replies carry. For a standalone post (one that
// arrived with no ThreadID and can therefore never be replied to) it is an
// internal key that no agent can mint — see standaloneKey.
Key string `json:"key"`
// Root is nil while the thread is Provisional.
Root *msg.MsgBoardPost `json:"root"`
Replies []*msg.MsgBoardPost `json:"replies"`
// Provisional marks a thread whose replies arrived before their root.
// This is expected, not an error: thread ids are minted agent-side with no
// round trip (design 025 §4.3), so ordering between two panes is not
// guaranteed. The view renders a provisional thread as a root; when the
// real root lands the thread stops being provisional and re-positions
// itself to where the ROOT's arrival puts it.
Provisional bool `json:"provisional"`
}
Thread is one root and its replies, as a view renders it.
The messages are owned by the Store and MUST be treated as read-only by callers; the slices are copies, so appending to them is safe, but mutating a *msg.MsgBoardPost mutates what every other reader sees. Copying ~5000 messages on every render to prevent that would cost more than the discipline does. The json tags exist because a Thread now travels: the board's read path (query.go) marshals it as the recorder's answer to `rysh board tail`. They are lower-case and stable so `rysh board tail --json` is a contract a script can be written against, not a rendering of Go field names.
func FilterThreads ¶ added in v0.2.11
FilterThreads keeps the threads v shows, in order.
type View ¶ added in v0.2.11
type View string
View selects which threads a reader sees.
const ( ViewStatus View = "status" ViewPrompt View = "prompt" ViewAll View = "all" // ViewFlow is not a filter but a different rendering of the whole board: // who orders whom, and in which order (design 052, flow.go). As a filter // it keeps every thread. ViewFlow View = "flow" // ViewTeam is the fleet status table and the dependency tree (team.go): // who is progressing, idle or blocked, and on whom. Like flow, not a // filter: as one it keeps every thread. ViewTeam View = "team" )