store

package
v0.7.6 Latest Latest
Warning

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

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package store is muster's SQLite persistence layer.

Index

Constants

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

View Source
var ErrNotClaimable = errors.New("task not claimable")

ErrNotClaimable is returned when claiming a task that is not open.

View Source
var ErrThreadNotFound = errors.New("thread not found")

ErrThreadNotFound is returned when an operation targets a threadID that does not exist.

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

func Open(dbPath string) (*Store, error)

Open opens (creating if needed) the database at dbPath, enables WAL, and applies the schema idempotently.

func (*Store) AppendEntry

func (s *Store) AppendEntry(threadID int64, fromAgent, body, statusChange string) (int64, error)

AppendEntry adds an entry and advances the thread's updated_at.

func (*Store) AppendEvent added in v0.4.0

func (s *Store) AppendEvent(e Event) error

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

func (s *Store) ClaimTask(threadID int64, byAgent string) error

ClaimTask atomically moves a task from open → claimed and records it.

func (*Store) Close

func (s *Store) Close() error

Close closes the database.

func (*Store) CreateThread

func (s *Store) CreateThread(t Thread, firstBody string) (int64, error)

CreateThread inserts a thread and its first entry atomically.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB exposes the underlying handle (tests + store methods).

func (*Store) DeleteAgent

func (s *Store) DeleteAgent(alias string) error

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

func (s *Store) DepartAgent(alias string) error

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

func (s *Store) GetAgent(alias string) (Agent, bool, error)

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

func (s *Store) GetThread(id int64) (Thread, []Entry, error)

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

func (s *Store) Inbox(alias string) ([]Thread, error)

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

func (s *Store) KVGet(key string) (KVPair, bool, error)

KVGet returns the pair for key; ok is false if the key is absent.

func (*Store) KVSet

func (s *Store) KVSet(key, value, updatedBy string) error

KVSet upserts a shared fact.

func (*Store) ListAgents

func (s *Store) ListAgents() ([]Agent, error)

ListAgents returns all agents ordered by alias — departed (tombstoned) agents included: their rows are history, not gone (see DepartAgent).

func (*Store) MarkRead

func (s *Store) MarkRead(alias string) error

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

func (s *Store) MaxEventID() (int64, error)

MaxEventID returns the journal high-water mark (0 on an empty journal).

func (*Store) PruneEvents added in v0.5.0

func (s *Store) PruneEvents(olderThanMillis int64) (int64, error)

PruneEvents deletes journal rows with ts < olderThanMillis (a row exactly at the cutoff survives), returning the count deleted.

func (*Store) RegisterAgent

func (s *Store) RegisterAgent(a Agent) error

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

func (s *Store) SessionUnread(socketPath, sessionID string) (total, action int, err error)

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

func (s *Store) SetSessionLabel(socketPath, sessionID, label string, manual bool) (int64, error)

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

func (s *Store) Threads(limit int) ([]Thread, error)

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

func (s *Store) TouchAgent(alias string) error

TouchAgent bumps last_seen. No error if the agent is unknown.

func (*Store) TransitionTask

func (s *Store) TransitionTask(threadID int64, byAgent, newStatus, note string) error

TransitionTask sets a new (validated) status and records the change as an entry.

func (*Store) UnreadCount

func (s *Store) UnreadCount(alias string) (int, error)

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

Jump to

Keyboard shortcuts

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