turn_stats: create the session_id index after the column exists

This commit is contained in:
damocles 2026-06-13 11:26:21 +02:00 committed by mara
commit 3af3c3a613

View file

@ -51,8 +51,11 @@ CREATE TABLE IF NOT EXISTS turn_stats (
);
CREATE INDEX IF NOT EXISTS idx_turn_stats_started
ON turn_stats (started_at DESC);
CREATE INDEX IF NOT EXISTS idx_turn_stats_session
ON turn_stats (session_id);
-- NOTE: the index on session_id is created in MIGRATIONS, not here. On an
-- existing pre-session db the `CREATE TABLE IF NOT EXISTS` above is a no-op
-- (the old table has no session_id column), so indexing session_id in this
-- batch would fail with `no such column` and abort the whole SCHEMA apply
-- which disables the stats sink. The column is added by MIGRATIONS first.
-- One row per fresh claude session (minted when --continue is suppressed).
-- turn_stats.session_id FKs here so per-session stats (first-turn tokens,
-- per-session totals, turn count, duration) are one GROUP BY away.
@ -75,6 +78,9 @@ const MIGRATIONS: &[&str] = &[
// Nullable FK to sessions.id — no default; pre-migration rows stay NULL
// (the surface treats NULL as "no session", inert until capture lands).
"ALTER TABLE turn_stats ADD COLUMN session_id INTEGER",
// Index on session_id — must run AFTER the column is added, so it lives
// here rather than in SCHEMA (see the note there). Idempotent.
"CREATE INDEX IF NOT EXISTS idx_turn_stats_session ON turn_stats (session_id)",
];
/// One row to be inserted. `Option`-wrapped fields default to NULL
@ -306,3 +312,97 @@ impl TurnStats {
fn default_path() -> PathBuf {
crate::paths::harness_dir().join("hyperhive-turn-stats.sqlite")
}
#[cfg(test)]
mod tests {
use super::*;
/// A `turn_stats` db in the *pre-session* shape: the original table with no
/// `session_id` column, plus the `started_at` index. This is what every
/// agent created before the sessions feature has on disk.
fn seed_pre_session_db(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE turn_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
model TEXT NOT NULL,
wake_from TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
last_input_tokens INTEGER NOT NULL DEFAULT 0,
last_output_tokens INTEGER NOT NULL DEFAULT 0,
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
tool_call_count INTEGER NOT NULL DEFAULT 0,
tool_call_breakdown_json TEXT,
open_threads_count INTEGER,
open_reminders_count INTEGER,
result_kind TEXT NOT NULL,
note TEXT
);
CREATE INDEX idx_turn_stats_started ON turn_stats (started_at DESC);",
)
.unwrap();
}
fn sample_row() -> TurnStatRow {
TurnStatRow {
started_at: 100,
ended_at: 101,
duration_ms: 1_000,
model: "opus".to_owned(),
wake_from: "recv".to_owned(),
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
last_input_tokens: 10,
last_output_tokens: 5,
last_cache_read_input_tokens: 0,
last_cache_creation_input_tokens: 0,
tool_call_count: 1,
tool_call_breakdown_json: None,
open_threads_count: None,
open_reminders_count: None,
result_kind: "ok",
note: None,
session_id: None,
}
}
/// Regression: a pre-session db must `open()` cleanly (the `session_id`
/// index used to live in `SCHEMA` and aborted the apply with
/// `no such column`, silently disabling the stats sink), get the column
/// added by `MIGRATIONS`, and then accept writes.
#[test]
fn open_upgrades_pre_session_db_and_writes() {
let path = std::env::temp_dir().join("hyperhive-turnstats-pre-session-regression.sqlite");
let _ = std::fs::remove_file(&path);
seed_pre_session_db(&path);
let stats = TurnStats::open(&path).expect("open() must succeed on a pre-session db");
stats.record(&sample_row());
let conn = Connection::open(&path).unwrap();
let rows: i64 = conn
.query_row("SELECT COUNT(*) FROM turn_stats", [], |r| r.get(0))
.unwrap();
assert_eq!(rows, 1, "the row must be written once the column is added");
let has_session_id: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('turn_stats') \
WHERE name = 'session_id'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(has_session_id, 1, "MIGRATIONS must add session_id");
let _ = std::fs::remove_file(&path);
}
}