store

package
v0.23.0 Latest Latest
Warning

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

Go to latest
Published: Oct 17, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package store owns Muster's SQLite database: opening it in WAL mode, applying migrations, and the hand-written SQL for the kv and event tables. Nothing here knows about Claude Code's wire formats — callers pass in neutral values.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Migrate

func Migrate(ctx context.Context, db *sql.DB) error

Migrate applies every embedded migration not yet recorded in schema_migrations, in ascending version order, and records each as it lands. Forward-only: there is no down migration, and calling Migrate again once everything is applied is a no-op.

Types

type Event

type Event struct {
	ClaudeSessionID string
	Type            string
	PromptID        *string
	ToolUseID       *string
	MusterSession   *int64
	TmuxPane        *string
	Payload         []byte // verbatim inner payload JSON

	// SessionID is the Muster session (session.id) this event routed to, resolved by
	// the caller (internal/server/ingest.go, via internal/session's binding map)
	// before persistence. Nil means unrouted: an unknown Claude session id, or a
	// stale/absent envelope — never guessed at by cwd.
	SessionID *int64
}

Event is a fully-parsed, storage-ready ingest event. Callers (internal/server) are responsible for translating whatever Claude-Code-specific parsing produced into this neutral shape — this package never sees Claude Code's own hook/status field names.

type EventSummary

type EventSummary struct {
	FirstSeq       *int64
	LastSeq        *int64
	Count          int
	LastReceivedAt *time.Time // nil when Count == 0
	RecentTypes    []string   // last 10 event.type values, oldest-first
}

EventSummary is the per-Muster-session event-routing summary behind the issue-capture snapshot's allowlisted session.events fields (kb:adr/issue-payload-allowlist-never-dump) — bounds, a count and the last 10 event.type values, never a payload.

type GroupLayout added in v0.20.0

type GroupLayout struct {
	Groups    []GroupRow
	Ungrouped UngroupedLayout
}

GroupLayout is every group's mutable fields plus the Ungrouped section, written wholesale: a group list is never more than a dozen long, so each change rewrites it in one transaction rather than diffing rows.

type GroupRow added in v0.20.0

type GroupRow struct {
	ID        int64
	Name      string
	Pos       int64
	Collapsed bool
}

GroupRow is the persisted shape of a rail group (kb:ref/data-model), the storage twin of internal/session.Group.

type InsertSessionParams

type InsertSessionParams struct {
	RepoID          int64
	Directory       string
	Branch          *string
	IsWorktree      bool
	Title           *string
	PermissionMode  string
	Model           *string
	FirstLaunchHere bool

	// PendingResumeClaudeSessionID is the Claude session id a resume-from-list spawn was
	// launched to resume; nil for an ordinary launch.
	PendingResumeClaudeSessionID *string

	// RailPos is the manual rail position for the new session
	// (kb:adr/rail-order-daemon-owned-per-session-fields): the caller
	// (internal/session.Manager, under its lock) hands in its own monotonic nextRailPos
	// counter so the newest session lands at the bottom of the unpinned block. Pinned
	// always starts false.
	RailPos int64

	// GroupID is the rail group the new session joins; nil = Ungrouped. The group row must
	// already exist (the session.group_id foreign key).
	GroupID *int64

	// MinID floors the allocated id above this value (0 = no floor) — the launcher
	// passes its MaxSessionID probe of the tmux socket here, so a new row never lands on
	// an id an orphaned "muster-<N>" tmux session already owns
	// (kb:adr/lifecycle-session-ids-monotonic-never-reused).
	MinID int64

	EffortRequested *string
	EffortSeed      *string
}

InsertSessionParams seeds a new session row: state "started", the permission-mode latch seeded with source "seed", alive, created now. TmuxTarget is a placeholder ("") until the caller records the real tmux window via UpdateSession — the row must exist (and get an id) before the tmux spawn that needs that id in the pane environment.

type Repo

type Repo struct {
	ID                 int64
	Path               string
	Name               string
	IsGit              bool
	Pinned             bool
	LastLaunchedAt     time.Time
	LaunchCount        int
	LastModel          *string
	LastPermissionMode *string
	LastEffort         *string
	CreatedAt          time.Time
}

Repo is one row of the MRU directory picker (kb:anchor/repos.list), also the home of the per-directory launch defaults (kb:ref/data-model).

type SessionRow

type SessionRow struct {
	ID                           int64
	TmuxTarget                   string
	TmuxPane                     *string
	ClaudeSessionID              *string
	PendingResumeClaudeSessionID *string
	RepoID                       int64
	Directory                    string
	Branch                       *string
	IsWorktree                   bool
	Title                        *string
	State                        string
	StateSince                   time.Time
	PermissionMode               string
	PermissionModeSource         string
	Model                        *string
	ModelDisplayName             *string // NULL = derive the display name from Model (the raw id)
	Compactions                  int
	AttentionReason              *string
	AttentionSince               *time.Time
	FailureError                 *string
	FailureMessage               *string
	LastActivity                 *string
	Alive                        bool
	EndedAt                      *time.Time
	FirstLaunchHere              bool
	CreatedAt                    time.Time

	// Context gauge (kb:adr/usage-context-gauge-shows-tokens-and-compactions): always
	// all-nil or all-non-nil. NULL = unknown — a session that hasn't yet received a
	// post-first-response status post.
	ContextUsedPct          *float64
	ContextTotalInputTokens *int64
	ContextWindowSize       *int64

	CacheExpiresAt         *time.Time
	CacheTTLSeconds        *int64
	CacheRequests          *int64
	CacheReportedExpiresAt *time.Time

	// LastSnapshot/LastSnapshotAt (kb:anchor/sessions.pane): the last capture-pane text
	// and when it was captured — always both-nil or both-non-nil. Display source only,
	// never read by the state machine; never logged (may hold prompt text).
	LastSnapshot   *string
	LastSnapshotAt *time.Time

	// Pinned/RailPos (kb:adr/rail-order-daemon-owned-per-session-fields): user-owned
	// rail order. Display-only — never read by the state machine or the status path.
	Pinned  bool
	RailPos int64

	// TitleOverride (kb:adr/rename-muster-owned-title-override-wins): the user's rename
	// via PUT .../title. Display-only, nullable, never touched by the status path —
	// wins over Title in the wire "title" (internal/session.Session.DisplayTitle()).
	TitleOverride *string

	// TranscriptPath/PlanPath/PlanExists (docs/features/reader/spec.md): the latest
	// transcript path a routed hook named, and the plan derived from it. Display-only,
	// never read by the state machine. TranscriptPath is NULL until a hook sets it.
	// PlanPath is NULL until a transcript has named a plan; once set, a planless scan
	// keeps it rather than nulling it back out (kb:adr/reader-plan-sticky-once-named).
	// PlanExists defaults to 0.
	TranscriptPath *string
	PlanPath       *string
	PlanExists     bool

	// Unread (kb:adr/rail-unread-inferred-from-live-terminal-client) is true iff the
	// session's turn closed with no terminal client attached, since cleared by a
	// non-idle transition or an attach. LastPrompt
	// (kb:adr/rail-activity-line-turn-aware-default-with-pref) is the user's most recent
	// prompt, truncated to 200 chars, NULL until a first prompt or after /clear. Both
	// display-only, never read by the state machine.
	Unread     bool
	LastPrompt *string

	// BackgroundTasks (kb:adr/lifecycle-background-tasks-count-not-state) is the running
	// background work the latest Stop reported; AttentionAgent is the subagent id that
	// raised the current wait, NULL for the main agent or no wait. Neither is read by
	// anything but the state machine's own persistence round trip and the wire.
	BackgroundTasks int
	AttentionAgent  *string

	// ClaudeDir (kb:adr/lifecycle-card-shows-launch-directory-marks-claude-elsewhere) is the
	// working directory Claude last reported; NULL = nothing reported since the last launch
	// or resume. Display-only, never read by the state machine.
	ClaudeDir *string

	// GroupID (kb:spec/rail) is the rail group the session belongs to, nil = the Ungrouped
	// section. Display-only, never read by the state machine or the status path.
	GroupID *int64

	EffortKnown            bool
	EffortLevel            *string
	EffortRequested        *string
	EffortWarningRequested *string
	EffortWarningActual    *string
	EffortWarningModel     *string
}

SessionRow is the persisted shape of a session (kb:ref/data-model). It is the storage-level twin of internal/session.Session; internal/server converts between the two so this package stays free of the kb:anchor/state state machine's own vocabulary.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store owns the daemon's single SQLite connection.

func Open

func Open(ctx context.Context, path string, log zerolog.Logger) (*Store, error)

Open opens (creating if absent) the SQLite database at path, enables WAL mode, and applies any pending migrations. A single connection is used deliberately: it serialises every writer, so SQLite's own one-writer-at-a-time rule never surfaces as SQLITE_BUSY. log is scanSession's corrupt-row warnings sink (see decodeRowTime/decodeRowTimePtr) — wired at construction, the same way session.NewManager takes Config.Logger and every internal/server feature constructor takes a trailing log zerolog.Logger. The zero value (zerolog.Logger{}) is a working no-op, so a caller with nothing to wire (most tests) can pass one and a corrupt row just logs nowhere.

func (*Store) BumpIDWatermark added in v0.13.2

func (s *Store) BumpIDWatermark(ctx context.Context, minID int64) error

BumpIDWatermark raises the persisted session id watermark to at least minID, leaving it untouched if it's already higher: an unknown "muster-<N>" or "muster-<N>-shell" tmux session found on the socket during reconcile must still block id N from ever being allocated to a new row, even though no row names it (kb:adr/lifecycle-session-ids-monotonic-never-reused).

The read and the conditional write run in one transaction: Open's single connection (SetMaxOpenConns(1)) means a held *sql.Tx has exclusive use of it until Commit/Rollback, so this can never interleave with InsertSession's own watermark-reading transaction — a bump can never lower a watermark InsertSession has concurrently raised, whichever one actually runs first.

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database connection.

func (*Store) DeleteGroup added in v0.20.0

func (s *Store) DeleteGroup(ctx context.Context, id int64, layout GroupLayout) error

DeleteGroup removes group id and, in the same transaction, rewrites the layout that remains (layout.Groups excludes id). The session.group_id foreign key's ON DELETE SET NULL is only a guard: the caller moves members out before it deletes.

func (*Store) DeleteSession

func (s *Store) DeleteSession(ctx context.Context, id int64) error

DeleteSession removes a session row — the launch-failure rollback path: on a spawn failure the row is deleted rather than left behind.

func (*Store) EventSummary

func (s *Store) EventSummary(ctx context.Context, sessionID int64) (EventSummary, error)

EventSummary reads the routing summary for sessionID (event.session_id — the Muster session id, not claude_session_id). A session with no routed events yet returns a zero-value-ish EventSummary: Count 0, both seq bounds and LastReceivedAt nil, RecentTypes nil.

func (*Store) GetRepo

func (s *Store) GetRepo(ctx context.Context, id int64) (Repo, error)

GetRepo looks up a repo row by id. No production code calls this today — production always already has the row it needs from ListRepos or UpsertRepo's own return value. It exists for test setup that wants one row back after UpsertRepo without listing every repo.

func (*Store) GetSession

func (s *Store) GetSession(ctx context.Context, id int64) (SessionRow, error)

func (*Store) InsertEvent

func (s *Store) InsertEvent(ctx context.Context, ev Event) error

InsertEvent persists ev, assigning it the next seq for its ClaudeSessionID as part of the same insert (MAX(seq)+1 scoped to that session). Safe under the single ingest worker; it is not a general-purpose concurrent seq allocator.

func (*Store) InsertGroup added in v0.20.0

func (s *Store) InsertGroup(ctx context.Context, name string, pos int64, layout GroupLayout) (GroupRow, error)

InsertGroup inserts a new group at pos and, in the same transaction, rewrites the layout the insert displaced (every other group's pos and the Ungrouped section), so a crash can never leave two sections on one place. layout.Groups names the other groups, not the new one. The id is SQLite's rowid and may be reused after a delete.

func (*Store) InsertSession

func (s *Store) InsertSession(ctx context.Context, p InsertSessionParams) (SessionRow, error)

InsertSession allocates the new row's id as max(COALESCE(MAX(id) from session, 0), the persisted watermark, p.MinID) + 1, inserted with that id explicit, and persists the new watermark — all in one transaction (kb:adr/lifecycle-session-ids-monotonic-never-reused). This is what makes an id un-reissuable: SQLite's ROWID (no AUTOINCREMENT on this table) would otherwise reuse max(rowid)+1 the moment the highest row is deleted (launch rollback, Remove, reconcile's sweep), and that id doubles as both the tmux session name and the ingest bearer credential MUSTER_SESSION=<id>.

func (*Store) InsertUsageModelSamples

func (s *Store) InsertUsageModelSamples(ctx context.Context, rows []UsageModelSampleRow) error

InsertUsageModelSamples persists rows in one transaction sharing a single receipt timestamp — one row per window, all stamped `at` the same instant. An empty slice is a no-op, not an error — Record only calls this when the list actually changed and non-empty input is never guaranteed by callers in general.

func (*Store) InsertUsageSample

func (s *Store) InsertUsageSample(ctx context.Context, r UsageSampleRow) error

InsertUsageSample persists one usage_sample row, stamping its receipt time itself (RFC3339Nano UTC) rather than trusting a caller-supplied timestamp.

func (*Store) KVGet

func (s *Store) KVGet(ctx context.Context, key string) (string, bool, error)

KVGet reads a key from the kv table. The bool return reports whether the key existed.

func (*Store) KVSet

func (s *Store) KVSet(ctx context.Context, key, value string) error

KVSet upserts a key in the kv table.

func (*Store) ListGroups added in v0.20.0

func (s *Store) ListGroups(ctx context.Context) ([]GroupRow, error)

ListGroups returns every group row (order unspecified — the caller sorts by pos), for daemon startup reload.

func (*Store) ListRepos

func (s *Store) ListRepos(ctx context.Context) ([]Repo, error)

ListRepos returns every repo row ordered `pinned DESC, last_launched_at DESC` (kb:anchor/repos.list), for the launch modal's MRU list.

func (*Store) ListSessions

func (s *Store) ListSessions(ctx context.Context) ([]SessionRow, error)

ListSessions returns every session row (order unspecified — the client sorts, kb:anchor/ws.snapshot), for daemon startup reload.

func (*Store) ReadUngroupedLayout added in v0.20.0

func (s *Store) ReadUngroupedLayout(ctx context.Context) (layout UngroupedLayout, ok bool, err error)

ReadUngroupedLayout returns the persisted Ungrouped layout; ok is false when none was ever written, which the caller reads as last place, expanded. An unparseable value is treated as absent: a corrupt kv row must never stop the daemon loading its groups.

func (*Store) SaveGroups added in v0.20.0

func (s *Store) SaveGroups(ctx context.Context, layout GroupLayout) error

SaveGroups rewrites every group in layout and the Ungrouped section in one transaction: the one write behind rename, collapse, reorder and collapse-all.

func (*Store) TouchRepo added in v0.19.0

func (s *Store) TouchRepo(ctx context.Context, p TouchRepoParams) (Repo, bool, error)

TouchRepo is UpsertRepo's sibling for a resume-from-list launch: it advances launch_count and last_launched_at exactly like UpsertRepo, but never writes last_model or last_permission_mode — a resume from the list carries no model or Start-in choice of its own, and must not clobber whatever a real launch last remembered there. A never- before-seen directory's row is created with both columns null (the Schema Changes note's "a query, not a migration"). Same single-statement-atomic reasoning as UpsertRepo: "was this call's branch the INSERT" is read off the returned launch_count, not a follow-up existence check.

func (*Store) UpdateSession

func (s *Store) UpdateSession(ctx context.Context, row SessionRow) error

UpdateSession writes back the full row (whole-object, matching the whole-object sessionUpsert design — kb:anchor/ws.session) after any mutation.

func (*Store) UpdateSnapshot

func (s *Store) UpdateSnapshot(ctx context.Context, id int64, text string, at time.Time) error

UpdateSnapshot persists only the last captured pane screen for id (kb:anchor/sessions.pane), separate from UpdateSession's whole-row write since it happens on every liveness tick for every alive session — called only when the captured text actually changed (same pattern as usage_sample). Never logged (may hold prompt text).

func (*Store) UpsertRepo

func (s *Store) UpsertRepo(ctx context.Context, p UpsertRepoParams) (Repo, bool, error)

UpsertRepo creates the repo row for p.Path if absent (launch_count starts at 1) or updates the launch defaults and increments launch_count if present. The bool return is true iff the row was newly created — the source of the Session's firstLaunchHere field (kb:anchor/ws.session).

A single `INSERT ... ON CONFLICT(path) DO UPDATE ... RETURNING` statement, not a SELECT followed by an INSERT/UPDATE: the prior two-step check-then-insert let two concurrent launches into the same never-before-seen directory both see "not found" and both attempt an INSERT, so the loser hit `UNIQUE constraint failed: repo.path` and sessionLauncher.Launch surfaced a plain 500 (daemon-tests, TestLauncher_ConcurrentLaunchesForTheSameDirectoryProduceTwoDistinctRows). The store already serializes all writes onto one connection (SetMaxOpenConns(1), this package's CLAUDE.md), so a single statement is atomic with respect to every other caller; two separate round trips through the connection pool were not. "Was this call's branch the INSERT" is read off the returned launch_count rather than a follow-up existence check (which would reopen the same race): every pre-existing row already has launch_count >= 1 from its own first insert, so the UPDATE branch's `+ 1` can never produce 1 — a returned launch_count of 1 is only reachable via the INSERT branch.

type TouchRepoParams added in v0.19.0

type TouchRepoParams struct {
	Path  string
	Name  string
	IsGit bool
}

TouchRepoParams are the values a resume-from-list launch records on the repo row — MRU and launch count only.

type UngroupedLayout added in v0.20.0

type UngroupedLayout struct {
	Pos       int64 `json:"pos"`
	Collapsed bool  `json:"collapsed"`
}

UngroupedLayout is the Ungrouped section's place among every section and its collapsed flag.

type UpsertRepoParams

type UpsertRepoParams struct {
	Path           string
	Name           string
	IsGit          bool
	Model          string
	PermissionMode string
	Effort         *string
}

UpsertRepoParams are the values a launch records on the repo row.

type UsageModelSampleRow

type UsageModelSampleRow struct {
	DisplayName string
	Pct         float64
	ResetsAt    time.Time
	Source      string
}

UsageModelSampleRow is one persisted usage_model_sample row (kb:adr/usage-model-window-polled-from-oauth-api, kb:ref/data-model) — internal/usage's storage-level twin, keeping this package free of internal/usage's own domain vocabulary. At is not part of this shape: every row in one InsertUsageModelSamples batch shares a single receipt timestamp, stamped by the store itself (mirrors InsertUsageSample), not trusted from a caller.

type UsageSampleRow

type UsageSampleRow struct {
	ModelID          string
	ModelDisplayName string
	FiveHourPct      float64
	FiveHourResetsAt time.Time
	SevenDayPct      float64
	SevenDayResetsAt time.Time
	Source           string
}

UsageSampleRow is one persisted usage_sample row (kb:ref/data-model) — internal/usage's storage-level twin, keeping this package free of internal/usage's own domain vocabulary. A row is only ever inserted for a complete sample; a partial sample is never persisted, so every field here is required.

Jump to

Keyboard shortcuts

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