Help us shape the future of Go! Take the 2026 Go Developer Survey

board

package
v0.2.11 Latest Latest
Warning

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

Go to latest
Published: Oct 19, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

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

View Source
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).

View Source
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.

View Source
const (
	FormatHTML = "html"
	FormatPDF  = "pdf"
)

Output formats.

View Source
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.

View Source
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.
View Source
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.

View Source
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.

View Source
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.

View Source
const FlowEventCap = 200

FlowEventCap is how many events the model keeps: the newest ones.

View Source
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.

View Source
const OutputDirEnv = "RYSH_BOARD_OUTPUT_DIR"

OutputDirEnv overrides where output files go.

Variables

View Source
var ErrNeedsFormat = errors.New("output needs a format: html or pdf (e.g. ##board output 5 pdf)")

ErrNeedsFormat is `output` without html or pdf.

View Source
var ErrNoPDFConverter = errors.New("no PDF converter found (install Google Chrome/Chromium or wkhtmltopdf)")

ErrNoPDFConverter means neither Chrome/Chromium nor wkhtmltopdf is installed.

View Source
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.

View Source
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

func BucketName(session string) string

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

func ConvertPDF(htmlPath, pdfPath string) error

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

func CountViews(ts []Thread) (status, prompt int)

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

func FlowBoardNodeID(b string) string

FlowBoardNodeID is the node id of board b.

func FormatItems added in v0.2.11

func FormatItems(items []Item) string

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

func IsPromptThread(t Thread) bool

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

func IsStandaloneKey(key string) bool

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

func ItemByWorker(it Item, worker string) bool

ItemByWorker is Select's worker predicate.

func ItemClock added in v0.2.11

func ItemClock(ts int64) string

ItemClock is the full local date-time of a post, the poster's clock.

func NoMessagesLine added in v0.2.11

func NoMessagesLine(worker string) string

NoMessagesLine is the refusal for an empty selection.

func NormaliseComm added in v0.2.11

func NormaliseComm(c string) string

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

func OpenFile(path string) error

OpenFile opens path with the platform opener, without waiting for it.

func OutputFileName added in v0.2.11

func OutputFileName(board, worker string, n int, now time.Time, ext string) string

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

func OutputSummary(picked, matched int, worker, path string) string

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

func RenderFlowDeps(d FlowDeps) []string

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

func RenderFlowDepsFit(d FlowDeps, maxRows int) []string

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

func RenderHTML(p HTMLPage) ([]byte, error)

RenderHTML draws the page.

func RenderTeam added in v0.2.11

func RenderTeam(f Flow, nowMS int64, depRows int) []string

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

func SanitiseBody(s string) string

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

func SanitiseInline(s string) string

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

func StatusTone(value string) string

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

func TeamCounts(f Flow) (members, blocked int)

TeamCounts is what the team view's header says: members and how many wait.

func TeamGoalCell added in v0.2.11

func TeamGoalCell(r TeamRow, nowMS int64) string

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

func TeamGoalReport(f Flow) string

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

type CopyResult struct {
	Text           string
	Picked         []Item
	Matched, Total int
	Worker         string
}

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 EventKind

type EventKind int

EventKind distinguishes the two board subjects.

const (
	// EventPost carries a message for the stream.
	EventPost EventKind = iota
	// EventRegister carries a persona announcement.
	EventRegister
)

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).

func BuildFlow added in v0.2.11

func BuildFlow(boardID string, threads []Thread, panes []FlowPane) Flow

BuildFlow builds the flow model of board from its threads and the session's panes (design 052 §3).

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

func BuildFlowDeps(f Flow) FlowDeps

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

func Select(items []Item, worker string, n int) (picked []Item, matched, total int)

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

func ParseItemArgs(args []string, output bool) (ItemArgs, error)

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

type LivePanesFunc func() (map[string]bool, error)

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

func Ask(nc *nats.Conn, board string, q Query, timeout time.Duration) (*QueryReply, error)

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 New

func New(max int) *Store

New returns an empty Store bounded at max posts. A max <= 0 means MaxPosts.

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

func (s *Store) ApplyEvent(ev Event)

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

func (s *Store) RetainRoster(livePaneIDs map[string]bool)

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) Stats

func (s *Store) Stats() Stats

Stats reports the counters a view needs to disclose what it is not showing.

func (*Store) Threads

func (s *Store) Threads() []Thread

Threads returns every thread in arrival order — roots by their own arrival, replies in the order they arrived under the root.

func (*Store) VersionCounts

func (s *Store) VersionCounts() map[int]int

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.

func TeamRows added in v0.2.11

func TeamRows(f Flow) []TeamRow

TeamRows lists the board's fleet members in chain order (each parent before its children). A member is a pane that carries a fleet role or a ledger row; a pane that only relayed a message (a human's coordinator) is not one.

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

func FilterThreads(ts []Thread, v View) []Thread

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"
)

func ParseView added in v0.2.11

func ParseView(s string) (View, error)

ParseView accepts status|prompt|all|flow|team (and the one-letter keys s|p|a|f|t).

func (View) Keeps added in v0.2.11

func (v View) Keeps(t Thread) bool

Keeps reports whether v shows thread t. The zero View shows everything, so a caller that never chose a view loses nothing.

Jump to

Keyboard shortcuts

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