Documentation
¶
Overview ¶
Package store is muster's SQLite persistence layer.
Index ¶
- Constants
- Variables
- type Agent
- type Entry
- type Event
- type EventQuery
- type KVPair
- type Store
- func (s *Store) AppendEntry(threadID int64, fromAgent, body, statusChange string) (int64, error)
- func (s *Store) AppendEvent(e Event) error
- func (s *Store) ClaimTask(threadID int64, byAgent string) error
- func (s *Store) Close() error
- func (s *Store) CreateThread(t Thread, firstBody string) (int64, error)
- func (s *Store) DB() *sql.DB
- func (s *Store) DeleteAgent(alias string) error
- func (s *Store) DepartAgent(alias string) error
- func (s *Store) DepartStaleSiblings(socketPath, sessionID string, created int64, keepAlias string) ([]string, error)
- func (s *Store) Events(q EventQuery) ([]Event, error)
- func (s *Store) GetAgent(alias string) (Agent, bool, error)
- func (s *Store) GetThread(id int64) (Thread, []Entry, error)
- func (s *Store) Inbox(alias string) ([]Thread, error)
- func (s *Store) KVGet(key string) (KVPair, bool, error)
- func (s *Store) KVSet(key, value, updatedBy string) error
- func (s *Store) ListAgents() ([]Agent, error)
- func (s *Store) MarkRead(alias string) error
- func (s *Store) MaxEventID() (int64, error)
- func (s *Store) PruneEvents(olderThanMillis int64) (int64, error)
- func (s *Store) RegisterAgent(a Agent) error
- func (s *Store) SessionUnread(socketPath, sessionID string) (total, action int, err error)
- func (s *Store) SetSessionLabel(socketPath, sessionID, label string, manual bool) (int64, error)
- func (s *Store) Threads(limit int) ([]Thread, error)
- func (s *Store) TouchAgent(alias string) error
- func (s *Store) TransitionTask(threadID int64, byAgent, newStatus, note string) error
- func (s *Store) UnreadCount(alias string) (int, error)
- type Thread
Constants ¶
const ( IntentFYI = "fyi" IntentReply = "reply-requested" IntentAction = "action-requested" )
Intent vocabulary for threads. "" (unspecified) is also valid — CreateThread accepts it and effectiveIntent (threads.go) derives the operative value.
Variables ¶
var ErrNotClaimable = errors.New("task not claimable")
ErrNotClaimable is returned when claiming a task that is not open.
var ErrThreadNotFound = errors.New("thread not found")
ErrThreadNotFound is returned when an operation targets a threadID that does not exist.
var TaskStates = map[string]bool{ "open": true, "claimed": true, "needs_info": true, "blocked": true, "completed": true, "declined": true, "cancelled": true, }
TaskStates is the set of valid task statuses.
Functions ¶
This section is empty.
Types ¶
type Agent ¶
type Agent struct {
Alias string `json:"alias"`
Role string `json:"role"`
ModelType string `json:"model_type"`
SocketPath string `json:"socket_path"`
PaneID string `json:"pane_id"`
SessionName string `json:"session_name"`
SessionID string `json:"session_id"`
// SessionCreated is the tmux session's creation time (#{session_created},
// unix seconds) captured at register time — the incarnation half of the
// identity tuple. tmux recycles session IDs from $0 across server
// restarts, so (SocketPath, SessionID) alone cannot distinguish a
// registration from a dead server incarnation from one on today's session
// that reused its ID; creation time is immutable per session (unlike its
// name) so a mismatch proves the recorded session is gone. 0 = unknown
// (registered outside tmux, or before this column existed) — liveness
// then falls back to bare session existence. See tmuxenv.IsSessionAlive
// and Store.DepartStaleSiblings.
SessionCreated int64 `json:"session_created"`
Project string `json:"project"`
Label string `json:"label"`
LabelManual bool `json:"label_manual"`
RegisteredAt int64 `json:"registered_at"`
LastSeen int64 `json:"last_seen"`
// LastReadEntryID is the entry-ID read watermark (see MarkRead/UnreadCount
// in agents.go): the highest entries.id visible the last time this
// agent's inbox was read. Supersedes the wall-clock last_read_at for
// unread math; last_read_at is retained internally for display only.
LastReadEntryID int64 `json:"last_read_entry_id"`
// Departed is true once this agent has been deregistered (see
// Store.DepartAgent) — a tombstone, not a delete: identity, project,
// label, and read-state (LastReadEntryID/last_read_at) all survive.
// RegisterAgent's upsert always resets this to false, so a returning
// session revives the row cleanly rather than needing a fresh one.
// Addressing/resolution semantics are UNCHANGED for a departed alias — it
// remains addressable exactly like a tmux-dead agent (mail waits; they
// may return); only notifyForThread and station's roster rendering treat
// Departed specially. muster gc's default reap no longer deletes any
// row — it sets Departed instead; `gc --purge-agents` hard-deletes
// departed/dead rows the old way.
Departed bool `json:"departed"`
}
Agent is a registered participant on the bus.
type Entry ¶
type Entry struct {
ID int64 `json:"id"`
ThreadID int64 `json:"thread_id"`
FromAgent string `json:"from_agent"`
Body string `json:"body"`
StatusChange string `json:"status_change"` // "" means none
CreatedAt int64 `json:"created_at"`
}
Entry is one append-only message within a thread.
type Event ¶ added in v0.4.0
type Event struct {
ID int64 `json:"id"`
TS int64 `json:"ts"`
Kind string `json:"kind"` // 'send' | 'task' | 'reply' | 'claim' | 'transition' | 'nudge' | 'notify' | 'read'
Agent string `json:"agent"`
Target string `json:"target"` // 'agent:x' / 'role:r' / 'broadcast' / bare alias (nudge)
ThreadID int64 `json:"thread_id"` // 0 = no thread
Count int `json:"count"`
Detail string `json:"detail"` // 'lit' | 'cleared' | 'skipped: …' | 'error: …'
// Subject is joined from the event's thread at query time (empty for
// thread-less events). Never stored on the row.
Subject string `json:"subject"`
// Intent is the event's thread's EFFECTIVE intent (effectiveIntent in
// threads.go), joined at query time exactly like Subject (empty for
// thread-less events). Never stored on the row.
Intent string `json:"intent"`
}
Event is one bus journal record: a bus action (send, task, reply, claim, transition, nudge) or a wake-layer outcome (mailbox notify, inbox read). The daemon appends these so "who did what, and who was lit when" is answerable after the fact instead of reconstructed from thread timestamps.
type EventQuery ¶ added in v0.5.0
type EventQuery struct {
Agent string // exact-alias concern filter ("" = all)
Kind string // exact kind ("" = all)
ThreadID int64 // >0 filters to one thread
AfterID int64 // follow mode: id > AfterID, oldest-first
Limit int // backlog mode row cap; 0 in backlog mode = no rows
Backlog bool // true: newest-first LIMIT; false: follow mode
}
EventQuery selects journal rows. Mode is explicit: Backlog=true reads newest-first up to Limit; otherwise follow mode reads id > AfterID oldest-first. Agent matches events the alias is CONCERNED in — as actor, as exact 'agent:<alias>' target, as bare-alias target (nudge), or via the event's thread satisfying threadConcerns (what makes replies on your threads match despite their empty target). Role matching uses the alias's current role, same as threadConcerns everywhere else.
type KVPair ¶
type KVPair struct {
Key string `json:"key"`
Value string `json:"value"`
UpdatedBy string `json:"updated_by"`
UpdatedAt int64 `json:"updated_at"`
}
KVPair is a shared blackboard fact.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store wraps the SQLite database.
func Open ¶
Open opens (creating if needed) the database at dbPath, enables WAL, and applies the schema idempotently.
func (*Store) AppendEntry ¶
AppendEntry adds an entry and advances the thread's updated_at.
func (*Store) AppendEvent ¶ added in v0.4.0
AppendEvent records one observability event, stamped now. Callers treat event logging as best-effort: an append failure must never fail the bus operation it describes.
func (*Store) CreateThread ¶
CreateThread inserts a thread and its first entry atomically.
func (*Store) DeleteAgent ¶
DeleteAgent hard-deletes an agent's registration by alias — irreversible: identity, project, label, and read-state are all gone, not just flagged. Unknown alias is a no-op (no error). Message/task history is unaffected — threads store the alias as text, not a foreign key. This is now reserved for `muster gc --purge-agents` (the daemon's purge_agent op); plain deregistration goes through DepartAgent instead.
func (*Store) DepartAgent ¶ added in v0.6.0
DepartAgent tombstones alias (spec: deregistration must survive so departed history stays drillable): sets departed=1 in place. Identity, project, label, and read-state (last_read_entry_id/last_read_at) are all preserved — this is the deregister_agent op's normal path now, replacing the old hard DELETE. Unknown alias is a no-op (no error), mirroring DeleteAgent's own contract. RegisterAgent's upsert is the only way back to departed=0 (a returning session revives the row).
func (*Store) DepartStaleSiblings ¶ added in v0.7.2
func (s *Store) DepartStaleSiblings(socketPath, sessionID string, created int64, keepAlias string) ([]string, error)
DepartStaleSiblings tombstones every OTHER non-departed alias registered to the same (socketPath, sessionID) tuple whose session_created differs from created — ghosts from a previous tmux server incarnation whose session ID was recycled. The inference needs no tmux access (the daemon stays tmux-agnostic): creation time is immutable for a session's lifetime, so two rows claiming one session ID with different non-zero creation times cannot both be live, and the caller vouches that created is the CURRENT session's (it just captured it from the live pane it is registering from). Rows with session_created 0 are spared — a pre-upgrade registration on the same still-running session is indistinguishable from a ghost, and it self-heals to a real value the next time that agent re-registers. No-op (0, nil) when any tuple component is empty/zero. Returns the tombstoned aliases so the caller can reconcile their badges.
func (*Store) Events ¶ added in v0.5.0
func (s *Store) Events(q EventQuery) ([]Event, error)
Events runs q against the journal (see EventQuery for mode semantics). Each row's Subject and Intent are joined from the event's thread at query time (both "" for thread-less events) — Intent is the thread's EFFECTIVE intent (effectiveIntent in threads.go), the same value Threads/GetThread/Inbox return, so the renderer's intent tag agrees with every other surface.
func (*Store) GetAgent ¶
GetAgent looks up a single agent by alias. ok is false if no such agent is registered at all — a departed (tombstoned) agent still reports ok=true, with Departed set (see DepartAgent).
func (*Store) GetThread ¶
GetThread returns the thread and its entries (ordered by id). The returned Thread.Intent is the EFFECTIVE intent (threadColsEffectiveIntent), matching Threads() and Inbox() — one vocabulary across every read surface.
func (*Store) Inbox ¶
Inbox returns every thread that concerns alias (see threadConcerns): addressed to it directly, to its role, broadcast, or originated by it — so replies on threads the agent started show up here too. Thread.Intent is the EFFECTIVE intent (effectiveIntent), matching Threads() and GetThread. Like Threads(), each row's LastFrom/LastAt/EntryCount come from the thread's last entry (threadLastEntryCTE) — so a caller can tell "a peer replied" (LastFrom != alias) from "my own last send" (LastFrom == alias) without a second get_thread round trip. Unread carries the caller-relative count of entries after alias's read watermark not written by alias (the UnreadCount predicate, scoped per thread) — this is the fix for the production defect where get_inbox exposed only thread metadata and its own MarkRead cleared the unread signal before an agent could act on it. The daemon MUST call Inbox() before MarkRead so callers see a non-zero count before their own read clears it (see TestGetInboxUnreadSurvivesOwnMarkRead in the daemon package).
func (*Store) ListAgents ¶
ListAgents returns all agents ordered by alias — departed (tombstoned) agents included: their rows are history, not gone (see DepartAgent).
func (*Store) MarkRead ¶
MarkRead records that alias has read its inbox up to the highest entry ID that exists right now: the read and the watermark snapshot happen in one transaction, so an entry appended concurrently (even in the same millisecond) is never mistaken for already read. last_read_at is also stamped, for display purposes only — it is no longer consulted by any unread predicate.
func (*Store) MaxEventID ¶ added in v0.5.0
MaxEventID returns the journal high-water mark (0 on an empty journal).
func (*Store) PruneEvents ¶ added in v0.5.0
PruneEvents deletes journal rows with ts < olderThanMillis (a row exactly at the cutoff survives), returning the count deleted.
func (*Store) RegisterAgent ¶
RegisterAgent upserts by Alias: inserts on first sight (stamping RegisteredAt), and on conflict refreshes the tuple + LastSeen while preserving RegisteredAt. departed is always reset to 0 by both the insert and the conflict update, so re-registering a previously-departed alias (a returning session) revives it cleanly — read-state (last_read_entry_id/last_read_at) is untouched by either branch, so it survives the roundtrip intact.
func (*Store) SessionUnread ¶ added in v0.6.0
SessionUnread is the ONE canonical session-level unread query (spec §3): all aliases sharing the exact (socketPath, sessionID) tuple are one actor identity for unread math and actor exclusion. total is the count of distinct threads concerning ANY alias of the session (threadConcernsJoin — semantically threadConcerns, re-expressed as a join; see TestThreadConcernsSessionJoinEquivalence) that have an entry newer than that alias's own watermark written by someone who is NOT any alias of the session — so a session's own writes under either alias never make its own threads unread, and a broadcast concerning two sibling aliases counts once, never twice (no summing of per-alias counts). action is the subset whose effective intent (effectiveIntent) is action-requested. An empty socketPath or sessionID never groups: it matches no agents, so both results are 0 (per-alias identity is UnreadCount's job for such agents, e.g. one registered without a live tmux pane).
func (*Store) SetSessionLabel ¶ added in v0.7.2
SetSessionLabel updates the STORED label for every non-departed alias registered to the (socketPath, sessionID) tuple — a label is a session-level property, so all sibling aliases move together. This is the daemon-side half of `muster label` (the set_label op): the CLI writes the live tmux option and pushes the same value here in the same command, so the stored copy the daemon's own resolver reads (resolveAgentTarget — tmux-agnostic by rule, it never re-reads tmux) never drifts from what a CLI caller resolving against live tmux sees. Clearing is label="", manual=false. Returns how many rows changed; 0 with an empty tuple component (nothing addressable to update — matches SessionUnread's never-group-on-empty rule).
func (*Store) Threads ¶ added in v0.6.0
Threads returns the most recently updated threads (updated_at DESC, ties broken by id DESC), limit clamped via clampThreadsLimit. Each thread's Intent field is overridden with the effective intent (effectiveIntent), and LastFrom/LastAt/EntryCount are populated from its last entry — identified by MAX(entries.id) (append order), never MAX(created_at), so two entries landing in the same millisecond never mis-pick the last one. The entry annotation aggregates only over the already-limited thread set (the "recent" CTE is computed first, entries join against it), never the full entries table — this runs on a polling cadence (station, once a second).
func (*Store) TouchAgent ¶
TouchAgent bumps last_seen. No error if the agent is unknown.
func (*Store) TransitionTask ¶
TransitionTask sets a new (validated) status and records the change as an entry.
func (*Store) UnreadCount ¶
UnreadCount returns how many threads concerning alias (threadConcerns — matching Inbox exactly) contain an entry with id greater than the agent's entry-ID read watermark (last_read_entry_id) that was written by someone else. Judging entries rather than the thread's updated_at means an agent's own reply never re-flags its own inbox, and a peer's reply on a thread the agent originated does. The watermark is an entry ID, not a wall-clock timestamp, so two entries landing in the same millisecond never race a strict "after last read" comparison (see MarkRead).
type Thread ¶
type Thread struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
FromAgent string `json:"from_agent"`
ToKind string `json:"to_kind"`
ToTarget string `json:"to_target"`
Subject string `json:"subject"`
Ref string `json:"ref"`
Status string `json:"status"` // "" means NULL (message)
// Intent is validated by CreateThread against the raw stored vocabulary
// (""/fyi/reply-requested/action-requested), but every READ surface —
// Threads, GetThread, Inbox — returns the EFFECTIVE intent (see
// effectiveIntent in threads.go), never the raw stored value: one
// vocabulary everywhere a Thread is read, so an old task row (stored
// intent "") reads as action-requested consistently across all three.
Intent string `json:"intent"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
// OriginProject is the SENDER's registered project at thread-creation
// time (iteration-4 orphan-thread fix): the daemon resolves the sender's
// agent record when it calls CreateThread and stamps its Project here —
// "" when the sender was unregistered at creation time. Additive and
// backfilled best-effort for pre-existing rows (see store.migrate); it
// exists so a thread survives every participant later deregistering —
// the roster-only project mapping that made ghost-site's threads vanish
// (spec iteration-4 queue item 4) has this as its durable fallback.
OriginProject string `json:"origin_project"`
// LastFrom, LastAt, and EntryCount are query-time only, populated by
// Threads() and Inbox() from the thread's last entry (by MAX(id), never
// MAX(created_at) — same-millisecond entries must not tie-break on
// timestamp) and its total entry count. GetThread/CreateThread leave
// them zero.
LastFrom string `json:"last_from"`
LastAt int64 `json:"last_at"`
EntryCount int `json:"entry_count"`
// Unread is query-time only, populated by Inbox(alias): the count of
// this thread's entries after alias's last_read_entry_id watermark that
// were NOT written by alias (the same predicate as UnreadCount, scoped
// to one thread). It answers "for the alias Inbox was called with," not
// a thread-global property — the defect this fixes was an agent unable
// to tell "a peer replied on my thread" from "my own last send" without
// drilling into get_thread. Threads()/GetThread/CreateThread leave it
// zero.
Unread int `json:"unread"`
}
Thread is a conversation: a message (no status) or a task (status set).