daemon: integrate QoL slices 1-5 (model/rename/catalog, history deletes, stats, repo prepare+imageUsed+built) — 95.3% cover green

This commit is contained in:
Raphael Westphal
2026-08-19 10:07:52 +02:00
parent 7287b831e4
commit 698b742cb3
16 changed files with 1506 additions and 36 deletions
+121
View File
@@ -51,6 +51,7 @@ type ContainerRow struct {
type RepoImageRow struct {
Repo string `json:"repo"`
Image string `json:"image"`
Built bool `json:"built"`
}
const dsnParams string = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
@@ -207,6 +208,31 @@ func (s *Store) SetOnline(sessionID string, online bool) error {
return err
}
// SetSessionName merges a new name into the persisted session info blob,
// leaving every other snapshot field untouched. Returns false when the
// session row does not exist.
func (s *Store) SetSessionName(sessionID, name string) (bool, error) {
var infoBlob string
err := s.db.QueryRow(`SELECT info FROM sessions WHERE id=?`, sessionID).Scan(&infoBlob)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
var info SessionInfo
if err := json.Unmarshal([]byte(infoBlob), &info); err != nil {
return false, fmt.Errorf("decode session info %s: %w", sessionID, err)
}
info.Name = &name
blob, err := json.Marshal(info)
if err != nil {
return true, err
}
_, err = s.db.Exec(`UPDATE sessions SET info=? WHERE id=?`, string(blob), sessionID)
return true, err
}
// Sessions returns every known session row ordered by session id.
func (s *Store) Sessions() ([]SessionRow, error) {
rows, err := s.db.Query(`SELECT id, info, lastSeq, lastEventAt, online FROM sessions ORDER BY id`)
@@ -233,6 +259,101 @@ func (s *Store) Sessions() ([]SessionRow, error) {
return out, rows.Err()
}
// DeleteSessionEvents drops every persisted event for the session and
// returns the number of rows removed (0 when the session had none).
func (s *Store) DeleteSessionEvents(sessionID string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM events WHERE sessionId=?`, sessionID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// DeleteSession removes a session entirely: its events, container row and
// the session row itself.
func (s *Store) DeleteSession(sessionID string) error {
if _, err := s.db.Exec(`DELETE FROM events WHERE sessionId=?`, sessionID); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM containers WHERE sessionId=?`, sessionID); err != nil {
return err
}
_, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, sessionID)
return err
}
// UsageStats aggregates token/cost usage over agent_end events.
type UsageStats struct {
Turns int64 `json:"turns"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalCost float64 `json:"totalCost"`
}
// StatsTotals is UsageStats aggregated across all sessions, plus how many
// sessions contributed agent_end events.
type StatsTotals struct {
UsageStats
SessionsCount int64 `json:"sessionsCount"`
}
// agentEndUsage is the usage sub-object of an agent_end payload; absent
// fields decode as zero.
type agentEndUsage struct {
Usage struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalCost float64 `json:"totalCost"`
} `json:"usage"`
}
// SessionStats aggregates usage over a session's persisted agent_end events.
// Each agent_end row counts as one turn; rows without (or with partial)
// usage payloads contribute zeros.
func (s *Store) SessionStats(sessionID string) (UsageStats, error) {
rows, err := s.db.Query(`SELECT sessionId, payload FROM events WHERE sessionId=? AND type=?`,
sessionID, evAgentEnd)
if err != nil {
return UsageStats{}, err
}
defer rows.Close()
totals, err := scanAgentEndUsage(rows)
return totals.UsageStats, err
}
// StatsTotals aggregates usage over every persisted agent_end event.
func (s *Store) StatsTotals() (StatsTotals, error) {
rows, err := s.db.Query(`SELECT sessionId, payload FROM events WHERE type=?`, evAgentEnd)
if err != nil {
return StatsTotals{}, err
}
defer rows.Close()
return scanAgentEndUsage(rows)
}
// scanAgentEndUsage folds agent_end rows into totals; unparseable payloads
// still count as turns but add no usage.
func scanAgentEndUsage(rows *sql.Rows) (StatsTotals, error) {
var out StatsTotals
seen := make(map[string]struct{})
for rows.Next() {
var sessionID, payload string
if err := rows.Scan(&sessionID, &payload); err != nil {
return out, err
}
seen[sessionID] = struct{}{}
out.Turns++
var u agentEndUsage
if json.Unmarshal([]byte(payload), &u) == nil {
out.InputTokens += u.Usage.InputTokens
out.OutputTokens += u.Usage.OutputTokens
out.TotalCost += u.Usage.TotalCost
}
}
out.SessionsCount = int64(len(seen))
return out, rows.Err()
}
// GetSetting returns a settings value and whether it exists.
func (s *Store) GetSetting(key string) (string, bool, error) {
var val string