package backthink import ( "context" "database/sql" "fmt" "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // defaultListLimit is the number of most-recent records returned by List when the // caller passes a non-positive limit. const defaultListLimit = 50 // DDL for the thinking record store. at/created_at follow the project timestamp // convention (RFC3339 with a timezone); trigger and conclusion are the free-text // fields the panel renders; the push_* columns carry a decomposed PushResult so // List need not parse a blob. The primary key is the project-standard auto id. const createRecordsSQL = ` CREATE TABLE IF NOT EXISTS backthink_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, trigger TEXT NOT NULL, at TEXT NOT NULL, conclusion TEXT NOT NULL, should_push INTEGER NOT NULL, push_pushed INTEGER NOT NULL DEFAULT 0, push_route TEXT NOT NULL, push_detail TEXT NOT NULL DEFAULT '', push_error TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) )` const createRecordsIndexSQL = ` CREATE INDEX IF NOT EXISTS idx_backthink_records_at ON backthink_records(at)` const insertRecordSQL = ` INSERT INTO backthink_records (trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` const selectRecordsSQL = ` SELECT id, trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error FROM backthink_records ORDER BY id DESC LIMIT ?` // Record is one persisted thinking pass (design/12 ยง4), returned by Store.List // and the service's List. Trigger is the source subsystem label, At the decision // timestamp, Conclusion the runtime's output, ShouldPush the interrupt decision // and PushResult how (or whether) the push went out. type Record struct { ID int64 Trigger string At time.Time Conclusion string ShouldPush bool PushResult PushResult } // Store persists thinking records in SQLite. It creates its table and index in // NewStore so the module is self-contained (no migration bookkeeping), mirrors // how channel/session creates its own table. It is concurrency-safe by virtue of // the underlying *sql.DB. type Store struct { db *sql.DB log *slog.Logger } // NewStore builds a Store, ensuring the backthink_records table and index exist. // It returns ErrNilDB when db is nil. ctx is used for the DDL. func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { if db == nil { return nil, ErrNilDB } log = logger.WithSource(baseLog(log), "services/backthink") if _, err := db.ExecContext(ctx, createRecordsSQL); err != nil { return nil, fmt.Errorf("backthink: ensure backthink_records: %w", err) } if _, err := db.ExecContext(ctx, createRecordsIndexSQL); err != nil { return nil, fmt.Errorf("backthink: ensure backthink_records index: %w", err) } return &Store{db: db, log: log}, nil } // Append persists r and returns the new record id. func (s *Store) Append(ctx context.Context, r Record) (int64, error) { res, err := s.db.ExecContext(ctx, insertRecordSQL, r.Trigger, r.At.UTC().Format(time.RFC3339Nano), r.Conclusion, boolInt(r.ShouldPush), boolInt(r.PushResult.Pushed), string(r.PushResult.Route), r.PushResult.Detail, r.PushResult.Err, ) if err != nil { return 0, fmt.Errorf("backthink: insert record: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("backthink: record id: %w", err) } if s.log != nil { s.log.Debug("backthink record appended", "id", id, "trigger", r.Trigger, "should_push", r.ShouldPush, "route", r.PushResult.Route) } return id, nil } // List returns the most recent records newest-first. A non-positive limit selects // defaultListLimit. func (s *Store) List(ctx context.Context, limit int) ([]Record, error) { if limit <= 0 { limit = defaultListLimit } if err := ctx.Err(); err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, selectRecordsSQL, limit) if err != nil { return nil, fmt.Errorf("backthink: list records: %w", err) } defer rows.Close() records := make([]Record, 0, limit) for rows.Next() { var ( r Record at string route string pushed int shouldPush int ) if err := rows.Scan( &r.ID, &r.Trigger, &at, &r.Conclusion, &shouldPush, &pushed, &route, &r.PushResult.Detail, &r.PushResult.Err, ); err != nil { return nil, fmt.Errorf("backthink: scan record: %w", err) } r.ShouldPush = intBool(shouldPush) r.PushResult.Pushed = intBool(pushed) r.PushResult.Route = Route(route) r.At = parseTime(at) records = append(records, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("backthink: iterate records: %w", err) } return records, nil } // boolInt maps a bool to the SQLite INTEGER 1/0 representation. func boolInt(b bool) int { if b { return 1 } return 0 } // intBool maps a SQLite INTEGER to a bool. func intBool(n int) bool { return n != 0 } // parseTime parses a stored RFC3339(nano/with-zone) timestamp, falling back to // the zero time so a malformed row never breaks a listing. func parseTime(s string) time.Time { for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { if t, err := time.Parse(layout, s); err == nil { return t } } return time.Time{} }