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

board

package
v0.2.5 Latest Latest
Warning

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

Go to latest
Published: Aug 9, 2026 License: Apache-2.0 Imports: 9 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 (
	// 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 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 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.

Variables

This section is empty.

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.

Types

type Event

type Event struct {
	Kind     EventKind
	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 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 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.

func NewPersistence

func NewPersistence(kv KV) *Persistence

NewPersistence returns a Persistence over kv. A nil kv yields a nil Persistence, whose methods are no-ops.

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 RosterEntry

type RosterEntry struct {
	PaneID  string
	Persona string
	TS      int64
}

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 // total threads, provisional included
	Provisional int // threads still missing their root
	Posts       int // roots + replies currently held
	Duplicates  uint64
	Evicted     uint64
	// 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
}

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: two NATS subscriptions decoded onto one buffered channel.

func Subscribe

func Subscribe(nc *nats.Conn, codecs *msg.CodecRegistry, buf int, persist *Persistence) (*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) 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 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

	// Root is nil while the thread is Provisional.
	Root    *msg.MsgBoardPost
	Replies []*msg.MsgBoardPost

	// 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
}

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.

Jump to

Keyboard shortcuts

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