package main // store.go — SQLite persistence: sessions, events, settings, containers. import ( "database/sql" "encoding/json" "fmt" _ "modernc.org/sqlite" ) // SessionInfo mirrors the hello/session_info payload snapshot in PROTOCOL.md. type SessionInfo struct { ID string `json:"id"` Name *string `json:"name"` Cwd string `json:"cwd"` Model string `json:"model"` Provider string `json:"provider"` Agent bool `json:"agent"` Repo *string `json:"repo"` StartedAt int64 `json:"startedAt"` } // SessionRow is a persisted session with its bookkeeping columns. type SessionRow struct { ID string `json:"id"` Info SessionInfo `json:"-"` LastSeq int64 `json:"-"` LastEventAt int64 `json:"lastEventAt"` OnlineDB bool `json:"-"` } // Event is a persisted agent event; Payload is the raw JSON payload object. type Event struct { SessionID string Seq int64 TS int64 Type string Payload json.RawMessage } // ContainerRow tracks a daemon-spawned worker container per session. type ContainerRow struct { SessionID string ContainerID string Repo string } const dsnParams string = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" const schema string = ` CREATE TABLE IF NOT EXISTS events( sessionId TEXT NOT NULL, seq INTEGER NOT NULL, ts INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(sessionId, seq)); CREATE TABLE IF NOT EXISTS sessions( id TEXT PRIMARY KEY, info TEXT NOT NULL, lastSeq INTEGER NOT NULL DEFAULT 0, lastEventAt INTEGER, online INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS containers( sessionId TEXT PRIMARY KEY, containerId TEXT NOT NULL, repo TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_events_session ON events(sessionId, seq); ` // OpenStore opens (creating if needed) the SQLite database at path with WAL. func OpenStore(path string) (*Store, error) { db, err := sql.Open("sqlite", path+dsnParams) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // modernc sqlite serializes writes; a single conn avoids SQLITE_BUSY churn. db.SetMaxOpenConns(1) if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("apply schema: %w", err) } return &Store{db: db}, nil } // Store wraps the SQLite handle. type Store struct { db *sql.DB } // Close closes the database. func (s *Store) Close() error { return s.db.Close() } // AppendEvent persists one event keyed (sessionId, seq). Duplicates are ignored. func (s *Store) AppendEvent(e Event) error { payload := e.Payload if len(payload) == 0 { payload = json.RawMessage("{}") } _, err := s.db.Exec( `INSERT OR IGNORE INTO events(sessionId, seq, ts, type, payload) VALUES(?,?,?,?,?)`, e.SessionID, e.Seq, e.TS, e.Type, string(payload)) return err } // LastSeq returns the highest persisted seq for the session, 0 if none. func (s *Store) LastSeq(sessionID string) (int64, error) { var seq sql.NullInt64 err := s.db.QueryRow(`SELECT MAX(seq) FROM events WHERE sessionId=?`, sessionID).Scan(&seq) if err != nil { return 0, err } return seq.Int64, nil } // EventsAfter returns persisted events for the session with seq > after, // ascending, at most limit rows. func (s *Store) EventsAfter(sessionID string, after int64, limit int) ([]Event, error) { rows, err := s.db.Query( `SELECT sessionId, seq, ts, type, payload FROM events WHERE sessionId=? AND seq>? ORDER BY seq LIMIT ?`, sessionID, after, limit) if err != nil { return nil, err } defer rows.Close() var out []Event for rows.Next() { var e Event var payload string if err := rows.Scan(&e.SessionID, &e.Seq, &e.TS, &e.Type, &payload); err != nil { return nil, err } e.Payload = json.RawMessage(payload) out = append(out, e) } return out, rows.Err() } // EventsLatest returns the newest limit events for the session, ascending. func (s *Store) EventsLatest(sessionID string, limit int) ([]Event, error) { return s.eventsDesc(sessionID, ` WHERE sessionId=? ORDER BY seq DESC LIMIT ?`, limit, sessionID) } // EventsBefore returns the newest window of at most limit events with // seq < before, ascending. func (s *Store) EventsBefore(sessionID string, before int64, limit int) ([]Event, error) { return s.eventsDesc(sessionID, ` WHERE sessionId=? AND seq