feat(desktop): add harness to NIP-AM agent-usage breakdown (#2790)

Stack: #2035 → this PR

## What

Group the per-agent model breakdown by `(harness, model)` instead of
`model` alone, so the same model running under two different harnesses
(e.g. `claude-sonnet` via `goose` vs `claude-code`) produces two
distinct rows rather than collapsing into one.

## Changes

**Rust / SQLite**
- `agent_metric_index`: add nullable `harness TEXT` column. Schema
migration M1: `ALTER TABLE ... ADD COLUMN` + delete-then-backfill
rebuild so all existing rows get `harness` populated from retained
`archived_events.raw_json` — no data loss; ingest and backfill share the
same `from_payload` parser (frozen-plan requirement preserved).
- `AgentMetricIndexRow`: parse `harness` from
`AgentTurnMetricPayload.harness` (REQUIRED field per NIP-AM), write/read
through all store paths (`ROW_COLUMNS`, INSERT, `row_from_sql`).
- `AgentScope.models` grouping key widened from `Option<String>` to
`(Option<String>, Option<String>)` i.e. `(harness, model)`. Sort
tiebreak: harness ascending → model ascending, `None` last in each.
- `ModelUsage` wire type: add `harness: Option<String>` field.

**Frontend**
- `tauriArchive.ts` / `bridge.ts`: add `harness` to `AgentUsageModel`
type.
- `AgentUsageFocusedView`: render `harness` as a dimmed sub-label next
to each model name on breakdown rows. `null` harness (pre-migration data
or unknown) renders no label — single-harness data is visually
unchanged.
- `agentUsage.ts` `sortModelsByKnownTotal`: tiebreak updated for
compound `(harness, model)` key.

**Tests**
- Rust: collapse-fix test (same model / two harnesses → two rows),
migration test (old-shape rows get harness populated after rebuild),
harness sort coverage.
- TS: `sortModelsByKnownTotal` harness tiebreak tests, same-model
two-harness sort test.
- E2E: `bridge.ts` fixture type updated, `agent-usage.spec.ts` harness
assertion, `agent-usage-screenshots.spec.ts` shot 02 harness-label
visibility check.

## Gates

All green locally:
- `just desktop-check` ✓
- `just desktop-typecheck` ✓
- `just desktop-build` ✓
- `just desktop-test` (3487 pass, 0 fail) ✓
- `cargo test` in `desktop/src-tauri` (1575 pass, 0 fail) ✓

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-07-25 12:04:11 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 2b18f1ae46
commit 194ceb64f5
15 changed files with 697 additions and 28 deletions
+5
View File
@@ -82,6 +82,11 @@ const overrides = new Map([
// command's SQLite core end to end. Test-only content; ratcheted
// 1208 -> 1424.
["src-tauri/src/archive/mod_tests.rs", 1424],
// agent-usage-harness: M1 migration (atomic transaction wrapper, PRAGMA
// guard, canonical-store worklist) + rebuild_metric_index_in_tx helper
// + apply_schema_migrations + store_migration_tests.rs #[path] include.
// Load-bearing correctness fix (Thufir IMPORTANT). M1 tests split out.
["src-tauri/src/archive/store.rs", 1044],
// unified-agent-model 1A.1: profile reconcile split to agents_profile.rs,
// ratcheting 1443 -> 1295. Queued to split further in the A2 fold.
// global-agent-config: resolve_deploy_model_provider + visibility exports
+57 -22
View File
@@ -125,6 +125,7 @@ pub struct SeriesBucket {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsage {
pub harness: Option<String>,
pub model: Option<String>,
pub usage: ReportedUsage,
pub report_count: i64,
@@ -456,6 +457,32 @@ fn assign_bucket_index(boundaries: &[i64], t: i64) -> Option<usize> {
(0..boundaries.len().saturating_sub(1)).find(|&i| t >= boundaries[i] && t < boundaries[i + 1])
}
// ── Sort helpers ─────────────────────────────────────────────────────────────
/// Compare two known-total values for the A2 ranking rule: known totals rank
/// before unknown totals (None); within known totals, descending; within
/// unknown totals, equal (callers chain further tiebreaks).
fn cmp_known_total(a: Option<u64>, b: Option<u64>) -> std::cmp::Ordering {
match (a, b) {
(Some(av), Some(bv)) => bv.cmp(&av), // descending
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
/// Ordinal (byte-order) comparison of two optional strings, with `None`
/// sorting after any `Some` value — used as a stable tiebreak for harness and
/// model names so ordering is locale-independent and matches the Rust backend.
fn cmp_option_str_none_last(a: &Option<String>, b: &Option<String>) -> std::cmp::Ordering {
match (a, b) {
(Some(av), Some(bv)) => av.cmp(bv),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
// ── compute_series ───────────────────────────────────────────────────────────
/// Per-agent accumulation scope, built while walking `window_rows` once.
@@ -464,7 +491,8 @@ struct AgentScope {
bucket_counts: Vec<i64>,
total: UsageAccumulator,
report_count: i64,
models: HashMap<Option<String>, (UsageAccumulator, i64)>,
/// Keyed by `(harness, model)` — same model via two harnesses → two rows.
models: HashMap<(Option<String>, Option<String>), (UsageAccumulator, i64)>,
}
/// Compute the full [`AgentUsageSeries`] from already-loaded rows.
@@ -551,7 +579,7 @@ pub(super) fn compute_series(
let model_entry = scope
.models
.entry(row.model.clone())
.entry((row.harness.clone(), row.model.clone()))
.or_insert_with(|| (UsageAccumulator::default(), 0i64));
model_entry.0.add(&outcome);
model_entry.1 += 1;
@@ -602,31 +630,43 @@ pub(super) fn compute_series(
})
.collect();
let mut model_rows: Vec<(Option<u64>, Option<String>, ModelUsage)> = scope
// Named sort key so the 4-tuple doesn't exceed clippy's type_complexity
// threshold and the ordering intent reads at a glance.
struct ModelSortKey {
total: Option<u64>,
harness: Option<String>,
model: Option<String>,
usage: ModelUsage,
}
let mut model_rows: Vec<ModelSortKey> = scope
.models
.into_iter()
.map(|(model, (acc, count))| {
let model_total = acc.total_tokens_value();
.map(|((harness, model), (acc, count))| {
let total = acc.total_tokens_value();
let has_unknown_usage = acc.has_unknown();
(
model_total,
model.clone(),
ModelUsage {
ModelSortKey {
total,
harness: harness.clone(),
model: model.clone(),
usage: ModelUsage {
harness,
model,
usage: acc.finish(),
report_count: count,
has_unknown_usage,
},
)
}
})
.collect();
model_rows.sort_by(|a, b| match (a.0, b.0) {
(Some(av), Some(bv)) => bv.cmp(&av).then_with(|| a.1.cmp(&b.1)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.1.cmp(&b.1),
// A2 ranking: known totalTokens descending, unknown-total after;
// tiebreak: harness ascending (None last), then model ascending
// (None last), for deterministic ordering.
model_rows.sort_by(|a, b| {
cmp_known_total(a.total, b.total)
.then_with(|| cmp_option_str_none_last(&a.harness, &b.harness))
.then_with(|| cmp_option_str_none_last(&a.model, &b.model))
});
let models = model_rows.into_iter().map(|(_, _, m)| m).collect();
let models = model_rows.into_iter().map(|k| k.usage).collect();
(
total_tokens_value,
@@ -642,12 +682,7 @@ pub(super) fn compute_series(
)
})
.collect();
agent_rows.sort_by(|a, b| match (a.0, b.0) {
(Some(av), Some(bv)) => bv.cmp(&av).then_with(|| a.1.cmp(&b.1)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.1.cmp(&b.1),
});
agent_rows.sort_by(|a, b| cmp_known_total(a.0, b.0).then_with(|| a.1.cmp(&b.1)));
let any_agent_unknown = agent_rows.iter().any(|(_, _, a)| a.has_unknown_usage);
let agents: Vec<AgentUsage> = agent_rows.into_iter().map(|(_, _, a)| a).collect();
@@ -18,6 +18,7 @@ fn row(id: &str, agent: &str, session: &str, seq: u64, reported_at: i64) -> Agen
reported_at: Some(reported_at),
session_id: Some(session.to_string()),
turn_seq: Some(seq),
harness: None,
model: None,
delta_reliable: Some(true),
turn_input_tokens: None,
@@ -590,6 +591,73 @@ fn compute_series_model_breakdown_attributes_per_event_model() {
assert_eq!(series.agents[0].models.len(), 2);
}
#[test]
fn compute_series_same_model_two_harnesses_produces_two_rows() {
// Collapse-fix requirement: same model via two different harnesses must
// NOT collapse into one row — (harness, model) is the grouping key.
let r1 = AgentMetricIndexRow {
harness: Some("goose".to_string()),
model: Some("claude-sonnet".to_string()),
turn_input_tokens: Some(100),
delta_reliable: Some(true),
..row("e1", "agent1", "s1", 1, 0)
};
let r2 = AgentMetricIndexRow {
harness: Some("claude-code".to_string()),
model: Some("claude-sonnet".to_string()),
turn_input_tokens: Some(200),
delta_reliable: Some(true),
..row("e2", "agent1", "s2", 1, 1)
};
let boundaries = boundaries_7();
let rows = vec![r1, r2];
let series = compute_series(&rows, &rows, 0, &boundaries, None, true);
assert_eq!(series.agents.len(), 1);
// Two distinct harnesses → two rows, not one collapsed row.
assert_eq!(
series.agents[0].models.len(),
2,
"same model under two harnesses must produce two rows"
);
let harneses: Vec<Option<&str>> = series.agents[0]
.models
.iter()
.map(|m| m.harness.as_deref())
.collect();
assert!(
harneses.contains(&Some("claude-code")),
"claude-code row must be present"
);
assert!(
harneses.contains(&Some("goose")),
"goose row must be present"
);
}
#[test]
fn compute_series_single_harness_data_looks_unchanged_with_harness_label() {
// Single-harness data: still exactly one row per model, harness label present.
let r = AgentMetricIndexRow {
harness: Some("goose".to_string()),
model: Some("claude-sonnet".to_string()),
turn_total_tokens: Some(500),
delta_reliable: Some(true),
..row("e1", "agent1", "s1", 1, 0)
};
let boundaries = boundaries_7();
let rows = vec![r];
let series = compute_series(&rows, &rows, 0, &boundaries, None, true);
assert_eq!(series.agents[0].models.len(), 1);
assert_eq!(
series.agents[0].models[0].harness,
Some("goose".to_string())
);
assert_eq!(
series.agents[0].models[0].model,
Some("claude-sonnet".to_string())
);
}
#[test]
fn compute_series_invalid_report_count_passed_through_and_not_bucketed() {
let boundaries = boundaries_7();
@@ -80,6 +80,7 @@ pub(super) struct AgentMetricIndexRow {
pub reported_at: Option<i64>,
pub session_id: Option<String>,
pub turn_seq: Option<u64>,
pub harness: Option<String>,
pub model: Option<String>,
pub delta_reliable: Option<bool>,
pub turn_input_tokens: Option<u64>,
@@ -141,6 +142,7 @@ impl AgentMetricIndexRow {
reported_at,
session_id: payload.session_id,
turn_seq: payload.turn_seq,
harness: Some(payload.harness),
model: payload.model,
delta_reliable: Some(payload.delta_reliable),
turn_input_tokens: turn.and_then(|t| t.input_tokens),
@@ -164,6 +166,7 @@ impl AgentMetricIndexRow {
reported_at: None,
session_id: None,
turn_seq: None,
harness: None,
model: None,
delta_reliable: None,
turn_input_tokens: None,
@@ -208,6 +211,7 @@ fn row_from_sql(row: &rusqlite::Row) -> rusqlite::Result<AgentMetricIndexRow> {
reported_at: row.get("reported_at")?,
session_id: row.get("session_id")?,
turn_seq: turn_seq_text.as_deref().and_then(decode_u64_sortable),
harness: row.get("harness")?,
model: row.get("model")?,
delta_reliable: delta_reliable_int.map(|v| v != 0),
turn_input_tokens: turn_input_text.as_deref().and_then(decode_u64_sortable),
@@ -223,7 +227,7 @@ fn row_from_sql(row: &rusqlite::Row) -> rusqlite::Result<AgentMetricIndexRow> {
}
const ROW_COLUMNS: &str = "id, agent_pubkey, event_created_at, archived_at, reported_at, \
session_id, turn_seq, model, delta_reliable, turn_input_tokens, turn_output_tokens, \
session_id, turn_seq, harness, model, delta_reliable, turn_input_tokens, turn_output_tokens, \
turn_total_tokens, turn_cost_usd, cumulative_input_tokens, cumulative_output_tokens, \
cumulative_total_tokens, cumulative_cost_usd, parse_status";
@@ -256,13 +260,13 @@ pub(super) fn insert_metric_index_row(
.execute(
"INSERT INTO agent_metric_index
(identity_pubkey, relay_url, id, agent_pubkey, event_created_at,
archived_at, reported_at, session_id, turn_seq, model,
archived_at, reported_at, session_id, turn_seq, harness, model,
delta_reliable, turn_input_tokens, turn_output_tokens,
turn_total_tokens, turn_cost_usd, cumulative_input_tokens,
cumulative_output_tokens, cumulative_total_tokens,
cumulative_cost_usd, parse_status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING",
params![
identity_pubkey,
@@ -274,6 +278,7 @@ pub(super) fn insert_metric_index_row(
row.reported_at,
row.session_id,
turn_seq,
row.harness,
row.model,
delta_reliable,
turn_input,
@@ -87,6 +87,15 @@ fn from_payload_parses_valid_row() {
assert_eq!(row.turn_input_tokens, Some(10));
assert_eq!(row.cumulative_input_tokens, Some(100));
assert_eq!(row.model, Some("claude".to_string()));
assert_eq!(row.harness, Some("goose".to_string()));
}
#[test]
fn from_payload_parses_harness_field() {
let json = r#"{"harness":"claude-code","model":"claude-sonnet","timestamp":"2026-07-01T20:11:03Z","turn":{"inputTokens":5,"outputTokens":null,"totalTokens":null,"costUsd":null}}"#;
let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200);
assert_eq!(row.parse_status, ParseStatus::Valid);
assert_eq!(row.harness, Some("claude-code".to_string()));
}
#[test]
@@ -577,3 +586,68 @@ fn predecessor_lookup_uses_session_index() {
"predecessor lookup must use the session index, plan was:\n{plan}"
);
}
// ── Migration: old-shape rows get harness populated on rebuild ────────────────
/// Simulate a pre-harness archive row by inserting an archived_event and an
/// index row with harness = NULL (as the old schema would have stored it),
/// then running the backfill rebuild path to verify harness is populated.
#[test]
fn migration_old_shape_rows_get_harness_populated_after_rebuild() {
let conn = in_memory();
let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z");
// Seed an archived event.
insert_archived_event(
&conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200,
);
// Seed an index row with harness explicitly NULL (simulates pre-migration row).
conn.execute(
"INSERT INTO agent_metric_index
(identity_pubkey, relay_url, id, agent_pubkey, event_created_at,
archived_at, reported_at, session_id, turn_seq, harness, model,
delta_reliable, turn_input_tokens, turn_output_tokens,
turn_total_tokens, turn_cost_usd, cumulative_input_tokens,
cumulative_output_tokens, cumulative_total_tokens,
cumulative_cost_usd, parse_status)
VALUES ('id','relay','eid1','agent1',100,200,1751414400000,
's1','00000000000000000001',NULL,'claude',
1,'00000000000000000010','00000000000000000020',
'00000000000000000030',0.01,
'00000000000000000100','00000000000000000200',
'00000000000000000300',0.1,'valid')",
[],
)
.unwrap();
// Verify the row is there with NULL harness.
let harness_before: Option<String> = conn
.query_row(
"SELECT harness FROM agent_metric_index WHERE id = 'eid1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
harness_before, None,
"old-shape row should start with NULL harness"
);
// Simulate the migration: delete index rows and re-run backfill.
conn.execute_batch("DELETE FROM agent_metric_index")
.unwrap();
backfill_agent_metric_index(&conn, "id", "relay").unwrap();
// The rebuilt row must have harness populated.
let harness_after: Option<String> = conn
.query_row(
"SELECT harness FROM agent_metric_index WHERE id = 'eid1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
harness_after,
Some("goose".to_string()),
"rebuilt row must have harness populated from raw_json"
);
}
+167
View File
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS agent_metric_index (
cumulative_output_tokens TEXT,
cumulative_total_tokens TEXT,
cumulative_cost_usd REAL,
harness TEXT,
parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')),
PRIMARY KEY (identity_pubkey, relay_url, id)
);
@@ -153,9 +154,172 @@ pub fn open_archive_db(path: &Path) -> Result<Connection, String> {
conn.execute_batch(SCHEMA)
.map_err(|e| format!("failed to initialize archive schema: {e}"))?;
apply_schema_migrations(&conn)?;
Ok(conn)
}
/// One-shot, idempotent schema migrations recorded in `archive_migrations`.
///
/// Each migration is guarded by a `SELECT 1 FROM archive_migrations WHERE
/// name = '...'` check so it is safe to call on every open — a migration
/// that already ran is a no-op.
fn apply_schema_migrations(conn: &Connection) -> Result<(), String> {
migrate_add_harness_to_metric_index(conn)
}
/// M1: add `harness TEXT` column to `agent_metric_index` and rebuild index
/// rows so pre-existing rows gain harness populated from `archived_events`.
///
/// The entire migration — column addition, full DELETE of stale index rows,
/// fresh re-insertion from `archived_events.raw_json`, and the
/// `archive_migrations` marker — is wrapped in a single `unchecked_transaction`
/// (BEGIN DEFERRED). A crash anywhere before COMMIT leaves the DB in its
/// pre-migration state and the marker absent, so the next open re-runs the
/// migration from scratch.
///
/// The worklist is built from `archived_events` (the canonical store) rather
/// than from `agent_metric_index` so that a partially-rebuilt or entirely
/// empty index never causes us to forget which (identity, relay) scopes exist.
fn migrate_add_harness_to_metric_index(conn: &Connection) -> Result<(), String> {
let already_run: bool = conn
.query_row(
"SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_harness_to_metric_index'",
[],
|r| r.get::<_, i64>(0),
)
.map_err(|e| format!("migration M1: guard check: {e}"))?
> 0;
if already_run {
return Ok(());
}
// All steps run inside one transaction so a crash before COMMIT leaves the
// DB fully pre-migration and the marker absent — the next open re-runs.
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("migration M1: begin transaction: {e}"))?;
// 1. Add the `harness` column only when it is genuinely absent, so we
// propagate unexpected DDL errors instead of swallowing them.
let harness_exists: bool = {
let mut stmt = tx
.prepare("PRAGMA table_info(agent_metric_index)")
.map_err(|e| format!("migration M1: PRAGMA table_info prepare: {e}"))?;
let names: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| format!("migration M1: PRAGMA table_info query: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("migration M1: PRAGMA table_info read: {e}"))?;
names.iter().any(|n| n == "harness")
};
if !harness_exists {
tx.execute_batch("ALTER TABLE agent_metric_index ADD COLUMN harness TEXT")
.map_err(|e| format!("migration M1: ALTER TABLE failed: {e}"))?;
}
// 2. Collect all (identity, relay) scopes from `archived_events` — the
// canonical store — so the worklist is correct even if the index was
// partially rebuilt or empty before this migration runs.
let scopes: Vec<(String, String)> = {
let mut stmt = tx
.prepare(
"SELECT DISTINCT identity_pubkey, relay_url
FROM archived_events
WHERE kind = 44200",
)
.map_err(|e| format!("migration M1: prepare scope query: {e}"))?;
let result = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| format!("migration M1: query scopes: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("migration M1: read scopes: {e}"))?;
result
};
if !scopes.is_empty() {
// 3. Delete all stale index rows; re-insert them with harness populated
// from the shared `from_payload` parser below. Both the DELETE and
// all inserts run inside the same outer transaction — no intermediate
// commit, so readers never observe an empty index.
tx.execute_batch("DELETE FROM agent_metric_index")
.map_err(|e| format!("migration M1: delete index rows: {e}"))?;
for (identity, relay) in &scopes {
rebuild_metric_index_in_tx(&tx, identity, relay)?;
}
}
// 4. Record migration as applied — written last so the marker is only
// present in a fully committed transaction.
tx.execute(
"INSERT OR IGNORE INTO archive_migrations (name, applied_at) \
VALUES ('add_harness_to_metric_index', ?1)",
params![std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64],
)
.map_err(|e| format!("migration M1: record marker: {e}"))?;
tx.commit()
.map_err(|e| format!("migration M1: commit: {e}"))?;
Ok(())
}
/// Re-insert all kind-44200 rows for `(identity, relay)` from
/// `archived_events.raw_json` through the shared `from_payload` parser.
///
/// Unlike `metric_store::backfill_agent_metric_index`, this function runs
/// entirely inside the caller's transaction — no nested `BEGIN`/`COMMIT`.
/// It is used exclusively by M1 where the outer transaction provides atomicity.
fn rebuild_metric_index_in_tx(
conn: &Connection,
identity_pubkey: &str,
relay_url: &str,
) -> Result<(), String> {
let mut stmt = conn
.prepare(
"SELECT id, pubkey, created_at, archived_at, raw_json
FROM archived_events
WHERE identity_pubkey = ?1
AND relay_url = ?2
AND kind = 44200",
)
.map_err(|e| format!("migration M1: prepare rebuild select: {e}"))?;
let rows: Vec<(String, String, i64, i64, String)> = stmt
.query_map(params![identity_pubkey, relay_url], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, String>(4)?,
))
})
.map_err(|e| format!("migration M1: query rebuild rows: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("migration M1: read rebuild row: {e}"))?;
drop(stmt);
for (id, pubkey, created_at, archived_at, raw_json) in &rows {
let parsed = super::metric_store::AgentMetricIndexRow::from_payload(
raw_json,
id,
pubkey,
*created_at,
*archived_at,
);
super::metric_store::insert_metric_index_row(conn, identity_pubkey, relay_url, &parsed)
.map_err(|e| format!("migration M1: insert rebuilt row: {e}"))?;
}
Ok(())
}
fn set_wal_mode(conn: &Connection) -> Result<(), String> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
@@ -871,6 +1035,9 @@ pub fn gc_orphaned_events(
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "store_migration_tests.rs"]
mod store_migration_tests;
#[cfg(test)]
#[path = "store_tests.rs"]
mod store_tests;
@@ -0,0 +1,248 @@
//! Migration tests for `archive/store.rs` — M1: harness column.
//!
//! Kept in a sibling file so `store_tests.rs` stays under the 1000-line gate;
//! `#[path]`-included from `store.rs`.
use super::*;
// ── Migration M1: harness column + open_archive_db reopen tests ──────────────
/// Build a DB file that looks like a pre-harness archive: schema without the
/// `harness` column, a seeded `archived_events` row and a corresponding
/// `agent_metric_index` row with `harness = NULL`, and no `archive_migrations`
/// marker. Return the path to the temp file.
fn build_old_schema_db(path: &std::path::Path) {
// Old schema has no `harness TEXT` column and no `archive_migrations` table.
const OLD_SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS archived_events (
identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL,
kind INTEGER NOT NULL, pubkey TEXT NOT NULL, created_at INTEGER NOT NULL,
raw_json TEXT NOT NULL, archived_at INTEGER NOT NULL,
PRIMARY KEY (identity_pubkey, relay_url, id)
);
CREATE TABLE IF NOT EXISTS archived_event_scopes (
identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL,
scope_type TEXT NOT NULL, scope_value TEXT NOT NULL, archived_at INTEGER NOT NULL,
PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value)
);
CREATE TABLE IF NOT EXISTS save_subscriptions (
identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL, kinds TEXT NOT NULL, created_at INTEGER NOT NULL,
PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value)
);
CREATE TABLE IF NOT EXISTS observer_channel_index (
identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL,
channel_id TEXT, created_at INTEGER NOT NULL,
PRIMARY KEY (identity_pubkey, relay_url, id)
);
CREATE TABLE IF NOT EXISTS agent_metric_index (
identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL,
agent_pubkey TEXT NOT NULL, event_created_at INTEGER NOT NULL,
archived_at INTEGER NOT NULL, reported_at INTEGER, session_id TEXT,
turn_seq TEXT, model TEXT, delta_reliable INTEGER,
turn_input_tokens TEXT, turn_output_tokens TEXT, turn_total_tokens TEXT,
turn_cost_usd REAL, cumulative_input_tokens TEXT, cumulative_output_tokens TEXT,
cumulative_total_tokens TEXT, cumulative_cost_usd REAL,
parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')),
PRIMARY KEY (identity_pubkey, relay_url, id)
);
";
let conn = Connection::open(path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "busy_timeout", 5000).unwrap();
conn.execute_batch(OLD_SCHEMA).unwrap();
// A valid payload that carries harness="goose".
let raw_json = r#"{"harness":"goose","model":"claude","channelId":null,"sessionId":"s1","turnId":null,"turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":10,"outputTokens":20,"totalTokens":30,"costUsd":0.01},"cumulative":{"inputTokens":100,"outputTokens":200,"totalTokens":300,"costUsd":0.1},"deltaReliable":true,"stopReason":"end_turn"}"#;
conn.execute(
"INSERT INTO archived_events
(identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at)
VALUES ('id', 'relay', 'eid1', 44200, 'agent1', 100, ?1, 200)",
rusqlite::params![raw_json],
)
.unwrap();
// Seed the index row with harness absent (old schema has no column).
conn.execute(
"INSERT INTO agent_metric_index
(identity_pubkey, relay_url, id, agent_pubkey, event_created_at,
archived_at, reported_at, session_id, turn_seq, model,
delta_reliable, turn_input_tokens, turn_output_tokens,
turn_total_tokens, turn_cost_usd, cumulative_input_tokens,
cumulative_output_tokens, cumulative_total_tokens,
cumulative_cost_usd, parse_status)
VALUES ('id','relay','eid1','agent1',100,200,1751414400000,
's1','00000000000000000001','claude',
1,'00000000000000000010','00000000000000000020',
'00000000000000000030',0.01,
'00000000000000000100','00000000000000000200',
'00000000000000000300',0.1,'valid')",
[],
)
.unwrap();
}
/// Opening a pre-harness DB via `open_archive_db` must:
///
/// 1. Add the `harness` column.
/// 2. Rebuild all index rows so harness is populated from `archived_events`.
/// 3. Record the migration marker in `archive_migrations`.
///
/// This proves M1 fires on a real legacy file, not just a hand-rolled
/// DELETE+backfill as in the metric_store migration test.
#[test]
fn migration_m1_reopen_old_schema_db_populates_harness_and_records_marker() {
use tempfile::NamedTempFile;
let db_file = NamedTempFile::new().unwrap();
let db_path = db_file.path().to_path_buf();
build_old_schema_db(&db_path);
// Reopen via the real migration path.
let conn = open_archive_db(&db_path).expect("open_archive_db must succeed on legacy DB");
// Harness must be populated.
let harness: Option<String> = conn
.query_row(
"SELECT harness FROM agent_metric_index WHERE id = 'eid1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
harness,
Some("goose".to_string()),
"M1: harness must be populated from raw_json after reopen"
);
// Marker must be recorded.
let marker_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM archive_migrations \
WHERE name = 'add_harness_to_metric_index'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(marker_count, 1, "M1: migration marker must be recorded");
}
/// Re-opening the same DB a second time must be a no-op: marker already present,
/// harness already populated, no error.
#[test]
fn migration_m1_reopen_twice_is_idempotent() {
use tempfile::NamedTempFile;
let db_file = NamedTempFile::new().unwrap();
let db_path = db_file.path().to_path_buf();
build_old_schema_db(&db_path);
// First open: runs M1.
open_archive_db(&db_path).expect("first open must succeed");
// Second open: M1 is already marked; must also succeed without error.
let conn2 = open_archive_db(&db_path).expect("second open must succeed (M1 is idempotent)");
let count: i64 = conn2
.query_row(
"SELECT COUNT(*) FROM agent_metric_index WHERE harness = 'goose'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count, 1,
"after idempotent second open, harness row must still be present"
);
}
/// If the migration is aborted after DELETE but before COMMIT (simulated by
/// manually applying each sub-step without committing), the DB must be in its
/// pre-migration state and the marker must be absent, so the next
/// `open_archive_db` can re-run M1 from scratch and complete successfully.
///
/// We simulate the "crash before commit" scenario by:
/// 1. Building a pre-harness DB on disk.
/// 2. Opening a raw connection, running ALTER + DELETE but rolling back before
/// inserting the marker — leaving the DB in pre-migration state.
/// 3. Calling `open_archive_db` on the same file and asserting M1 completes.
#[test]
fn migration_m1_crashed_before_commit_reruns_fully_on_next_open() {
use tempfile::NamedTempFile;
let db_file = NamedTempFile::new().unwrap();
let db_path = db_file.path().to_path_buf();
build_old_schema_db(&db_path);
// Simulate a crash: start a transaction, ALTER + DELETE, then ROLLBACK
// (leaving DB exactly as-built — pre-migration, no marker).
{
let conn = Connection::open(&db_path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
// ALTER inside the transaction.
conn.execute_batch("ALTER TABLE agent_metric_index ADD COLUMN harness TEXT")
.unwrap();
// DELETE index rows (simulating the mid-migration state).
conn.execute_batch("DELETE FROM agent_metric_index")
.unwrap();
// Rollback — no marker inserted, no new rows.
conn.execute_batch("ROLLBACK").unwrap();
}
// After rollback: marker must be absent and the original index row still there.
{
let verify = Connection::open(&db_path).unwrap();
// archive_migrations table may not exist at all (old schema). If it does,
// the marker must not be present.
let marker_count: i64 = verify
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type='table' AND name='archive_migrations'",
[],
|r| r.get(0),
)
.unwrap();
if marker_count > 0 {
let applied: i64 = verify
.query_row(
"SELECT COUNT(*) FROM archive_migrations \
WHERE name = 'add_harness_to_metric_index'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(applied, 0, "marker must be absent after rollback");
}
}
// The next open must run M1 fully and succeed.
let conn =
open_archive_db(&db_path).expect("open_archive_db must succeed after simulated crash");
// Harness must be populated.
let harness: Option<String> = conn
.query_row(
"SELECT harness FROM agent_metric_index WHERE id = 'eid1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
harness,
Some("goose".to_string()),
"M1 must re-run after simulated crash and populate harness"
);
// Marker must now be recorded.
let marker: i64 = conn
.query_row(
"SELECT COUNT(*) FROM archive_migrations \
WHERE name = 'add_harness_to_metric_index'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
marker, 1,
"M1 marker must be recorded after successful re-run"
);
}
@@ -49,6 +49,7 @@ function agentUsage(pubkey, totalTokensValue, overrides = {}) {
function modelUsage(model, totalTokensValue, overrides = {}) {
return {
harness: null,
model,
usage: reportedUsage({
totalTokens: usageField({ value: totalTokensValue }),
@@ -368,6 +369,32 @@ test("sortModelsByKnownTotal sorts null model ('Unknown model') last among ties"
);
});
test("sortModelsByKnownTotal tiebreaks harness before model when totals are equal", () => {
const models = [
modelUsage("m", "100", { harness: "z-harness" }),
modelUsage("m", "100", { harness: "a-harness" }),
modelUsage("m", "100", { harness: null }),
];
const sorted = sortModelsByKnownTotal(models);
assert.deepEqual(
sorted.map((m) => m.harness),
["a-harness", "z-harness", null],
);
});
test("sortModelsByKnownTotal same model two harnesses produces two rows in harness order", () => {
// Same model via two harnesses should be distinct rows; harness-ascending tiebreak.
const models = [
modelUsage("claude-sonnet", "500", { harness: "goose" }),
modelUsage("claude-sonnet", "500", { harness: "claude-code" }),
];
const sorted = sortModelsByKnownTotal(models);
assert.deepEqual(
sorted.map((m) => m.harness),
["claude-code", "goose"],
);
});
// ── isPartialField / isUnknownField ──────────────────────────────────────────
test("isPartialField is true only for a known value flagged incomplete", () => {
@@ -221,7 +221,12 @@ export function sortAgentsByKnownTotal(
);
}
/** Model rows use the same ranking rule as agents, tiebroken by model name (`null` model sorts last as "Unknown model"). */
/** Model rows use the same ranking rule as agents, tiebroken by harness name
* (null harness sorts last), then by model name (null model sorts last).
* Ordinal (`<`/`>`) comparators are used so ordering is locale-independent
* and matches the Rust backend's `String::cmp` byte order.
* Note: harness/model identifiers are ASCII in practice; UTF-16 vs UTF-8
* scalar divergence for astral code points is accepted and not a use case. */
export function sortModelsByKnownTotal(
models: readonly AgentUsageModel[],
): AgentUsageModel[] {
@@ -229,10 +234,23 @@ export function sortModelsByKnownTotal(
models,
(model) => model.usage.totalTokens,
(a, b) => {
// Harness tiebreak first (ordinal, None last).
const harnessCmp =
a.harness === b.harness
? 0
: a.harness === null
? 1
: b.harness === null
? -1
: a.harness < b.harness
? -1
: 1;
if (harnessCmp !== 0) return harnessCmp;
// Then model (ordinal, None last).
if (a.model === b.model) return 0;
if (a.model === null) return 1;
if (b.model === null) return -1;
return a.model.localeCompare(b.model);
return a.model < b.model ? -1 : 1;
},
);
}
@@ -244,10 +244,18 @@ function AgentUsageFocusedTotals({
{models.map((model) => (
<div
className="flex items-center justify-between gap-3 text-sm"
key={model.model ?? "unknown"}
key={`${model.harness ?? ""}:${model.model ?? "unknown"}`}
>
<span className="min-w-0 truncate text-muted-foreground">
{model.model ?? "Unknown model"}
{model.harness !== null ? (
<span
className="ml-1.5 text-xs text-muted-foreground/70"
data-testid="agent-usage-model-harness-label"
>
{model.harness}
</span>
) : null}
</span>
<span className="shrink-0 font-medium text-foreground">
{isUnknownField(model.usage.totalTokens)
+1
View File
@@ -28,6 +28,7 @@ export type AgentUsageSeriesBucket = {
};
export type AgentUsageModel = {
harness: string | null;
model: string | null;
usage: ReportedUsage;
reportCount: number;
+1
View File
@@ -147,6 +147,7 @@ type RawAgentUsageSeriesBucket = {
};
type RawAgentUsageModel = {
harness: string | null;
model: string | null;
usage: RawReportedUsage;
reportCount: number;
@@ -298,6 +298,7 @@ test.describe("agent usage screenshots", () => {
],
models: [
{
harness: "claude-code",
hasUnknownUsage: false,
model: "claude-opus-4-5",
reportCount: 4,
@@ -309,6 +310,7 @@ test.describe("agent usage screenshots", () => {
}),
},
{
harness: "goose",
hasUnknownUsage: false,
model: "claude-sonnet-4-5",
reportCount: 1,
@@ -351,6 +353,11 @@ test.describe("agent usage screenshots", () => {
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible();
// Shot 02 must render a visible harness label on the model breakdown rows.
await expect(
page.getByTestId("agent-usage-model-harness-label").first(),
).toBeVisible();
const panel = page.getByTestId("user-profile-panel");
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/02-focused-usage-view.png` });
+4
View File
@@ -257,6 +257,7 @@ test("clicking an agent row opens the profile panel's Usage focused view", async
mockAgentUsage(agentPubkey, {
models: [
{
harness: "goose",
hasUnknownUsage: false,
model: "claude-opus",
reportCount: 1,
@@ -288,6 +289,9 @@ test("clicking an agent row opens the profile panel's Usage focused view", async
await expect(page.getByTestId("agent-usage-focused-models")).toContainText(
"claude-opus",
);
await expect(page.getByTestId("agent-usage-focused-models")).toContainText(
"goose",
);
// Reaching the same view via the Info-tab ingress row lands on the same
// subview (covers the ProfileIngressRow entry point, not just the
+1
View File
@@ -150,6 +150,7 @@ export type MockAgentUsageSeriesBucket = {
};
export type MockAgentUsageModel = {
harness: string | null;
model: string | null;
usage: MockReportedUsage;
reportCount: number;