Skip to main content

nyx_agent_core/report/
run_card.rs

1//! Per-run summary card.
2//!
3//! [`build_run_card`] reads the persisted store and synthesises a
4//! [`RunCard`] aggregating:
5//!
6//! * counts: findings split by status, by cap, by origin, by language
7//!   (derived from each finding's `payloads.lang` when present,
8//!   otherwise the file extension), plus a per-repo total.
9//! * spend: AI spend in micros split by `TaskKind` and folded into
10//!   `one_shot` vs `agent_loop` buckets matching the [`crate::store::BudgetKind`]
11//!   shape persisted on the wire.
12//! * timing: wall-clock per phase computed by min-start / max-finish
13//!   across the agent_trace rows for each [`TaskKind`], plus the
14//!   static-pass duration derived from the run's own
15//!   `started_at` / `finished_at`.
16//!
17//! [`render_html`] / [`render_markdown`] produce export-friendly
18//! representations of the same card. JSON falls out of `serde` on
19//! [`RunCard`] directly.
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24use sqlx::SqlitePool;
25use thiserror::Error;
26
27use crate::store::trace::TaskKind;
28use crate::store::StoreError;
29
30/// Task kinds that map to the `agent_loop` budget bucket. Everything
31/// else (PayloadSynthesis / SpecDerivation / ChainReasoning /
32/// NovelFindings) is `one_shot`. Mirrors the producer-side split in
33/// `nyx-agent-ai`.
34fn is_agent_loop(task_kind: &str) -> bool {
35    task_kind == TaskKind::Exploration.as_str()
36}
37
38/// One bucket of a histogram-style breakdown. Stored as a sorted vec so
39/// the wire output is deterministic.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct BySplit {
42    pub key: String,
43    pub count: i64,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SpendSplit {
48    pub one_shot_usd_micros: i64,
49    pub agent_loop_usd_micros: i64,
50    /// Per-task breakdown so the operator can read "PayloadSynthesis
51    /// cost $1.20" at a glance without doing arithmetic against the
52    /// task list.
53    pub by_task_kind: Vec<BySplit>,
54}
55
56impl SpendSplit {
57    pub fn total_usd_micros(&self) -> i64 {
58        self.one_shot_usd_micros + self.agent_loop_usd_micros
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct PhaseDuration {
64    pub phase: String,
65    pub wall_clock_ms: i64,
66    pub call_count: i64,
67}
68
69/// Aggregated summary for a single run.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct RunCard {
72    pub run_id: String,
73    pub started_at: i64,
74    pub finished_at: Option<i64>,
75    pub status: String,
76    pub triggered_by: String,
77    pub wall_clock_ms: Option<i64>,
78    pub total_findings: i64,
79    pub by_status: Vec<BySplit>,
80    pub by_cap: Vec<BySplit>,
81    pub by_origin: Vec<BySplit>,
82    pub by_lang: Vec<BySplit>,
83    pub by_repo: Vec<BySplit>,
84    pub spend: SpendSplit,
85    /// Wall-clock per phase. `static` is derived from the run row's
86    /// own start/finish; everything else is derived from
87    /// `agent_traces` rows for the matching `TaskKind`.
88    pub phase_durations: Vec<PhaseDuration>,
89    /// Total AI-proposed candidates (`candidate_findings` rows) bound
90    /// to this run, irrespective of their `status`. Sums every variant
91    /// in [`Self::by_candidate_status`]; surfaced as a separate field
92    /// so a consumer that does not care about the per-status breakdown
93    /// can read one integer.
94    pub candidate_findings_total: i64,
95    /// Per-status breakdown of `candidate_findings` rows for this run
96    /// (`Pending` / `Promoted` / `Dismissed`). Empty when the run
97    /// produced no candidates.
98    pub by_candidate_status: Vec<BySplit>,
99    pub business_logic_templates_considered: i64,
100    pub business_logic_candidates_generated: i64,
101    pub business_logic_templates_skipped: i64,
102}
103
104#[derive(Debug, Error)]
105pub enum RunCardError {
106    #[error("run `{0}` not found")]
107    NotFound(String),
108    #[error(transparent)]
109    Store(#[from] StoreError),
110    #[error("database error: {0}")]
111    Sqlx(#[from] sqlx::Error),
112}
113
114/// Aggregate every persisted source of run-level signal into a
115/// [`RunCard`] for `run_id`. Reads the `runs`, `findings`,
116/// `agent_traces`, and `payloads` tables.
117pub async fn build_run_card(pool: &SqlitePool, run_id: &str) -> Result<RunCard, RunCardError> {
118    let run_row: Option<RunRow> = sqlx::query_as::<_, RunRow>(
119        "SELECT id, started_at, finished_at, status, triggered_by, wall_clock_ms \
120         FROM runs WHERE id = ?",
121    )
122    .bind(run_id)
123    .fetch_optional(pool)
124    .await?;
125    let run = run_row.ok_or_else(|| RunCardError::NotFound(run_id.to_string()))?;
126
127    let findings: Vec<FindingRow> = sqlx::query_as::<_, FindingRow>(
128        "SELECT id, repo, path, cap, status, finding_origin FROM findings WHERE run_id = ?",
129    )
130    .bind(run_id)
131    .fetch_all(pool)
132    .await?;
133
134    let payloads: Vec<PayloadRow> = sqlx::query_as::<_, PayloadRow>(
135        "SELECT p.finding_id, p.lang FROM payloads p \
136         JOIN findings f ON f.id = p.finding_id WHERE f.run_id = ?",
137    )
138    .bind(run_id)
139    .fetch_all(pool)
140    .await?;
141    let mut finding_lang: BTreeMap<String, String> = BTreeMap::new();
142    for p in payloads {
143        finding_lang.entry(p.finding_id).or_insert(p.lang);
144    }
145
146    // Per-run `candidate_findings` aggregate. Reads off the
147    // `idx_candidate_findings_run_id` index added by migration
148    // `0001_v1.sql`, so the GROUP BY scan is bounded to one run's worth
149    // of rows. A denormalised column on `runs` would save the join, but
150    // the index already keeps this read sub-millisecond on the realistic
151    // candidate-count-per-run cardinality.
152    let candidate_rows: Vec<CandidateRow> = sqlx::query_as::<_, CandidateRow>(
153        "SELECT status, COUNT(*) AS count \
154         FROM candidate_findings WHERE run_id = ? \
155         GROUP BY status",
156    )
157    .bind(run_id)
158    .fetch_all(pool)
159    .await?;
160    let mut by_candidate_status_map: BTreeMap<String, i64> = BTreeMap::new();
161    let mut candidate_findings_total: i64 = 0;
162    for row in candidate_rows {
163        by_candidate_status_map.insert(row.status, row.count);
164        candidate_findings_total += row.count;
165    }
166    let business_logic_row: BusinessLogicSummaryRow = sqlx::query_as::<_, BusinessLogicSummaryRow>(
167        "SELECT COUNT(*) AS templates_considered, \
168                COALESCE(SUM(generated_count), 0) AS candidates_generated, \
169                COALESCE(SUM(CASE WHEN skipped_count > 0 THEN 1 ELSE 0 END), 0) AS templates_skipped \
170         FROM business_logic_template_runs WHERE run_id = ?",
171    )
172    .bind(run_id)
173    .fetch_one(pool)
174    .await?;
175
176    let traces: Vec<TraceRow> = sqlx::query_as::<_, TraceRow>(
177        "SELECT t.task_kind, t.cost_usd_micros, t.started_at, t.finished_at \
178         FROM agent_traces t \
179         LEFT JOIN findings f ON f.id = t.finding_id \
180         WHERE f.run_id = ? \
181            OR t.started_at BETWEEN ? AND ?",
182    )
183    .bind(run_id)
184    .bind(run.started_at)
185    .bind(run.finished_at.unwrap_or(i64::MAX))
186    .fetch_all(pool)
187    .await?;
188
189    let mut by_status: BTreeMap<String, i64> = BTreeMap::new();
190    let mut by_cap: BTreeMap<String, i64> = BTreeMap::new();
191    let mut by_origin: BTreeMap<String, i64> = BTreeMap::new();
192    let mut by_repo: BTreeMap<String, i64> = BTreeMap::new();
193    let mut by_lang: BTreeMap<String, i64> = BTreeMap::new();
194    for f in &findings {
195        *by_status.entry(f.status.clone()).or_default() += 1;
196        *by_cap.entry(f.cap.clone()).or_default() += 1;
197        *by_origin.entry(f.finding_origin.clone()).or_default() += 1;
198        *by_repo.entry(f.repo.clone()).or_default() += 1;
199        let lang =
200            finding_lang.get(&f.id).cloned().unwrap_or_else(|| lang_from_path(&f.path).to_string());
201        *by_lang.entry(lang).or_default() += 1;
202    }
203
204    let mut spend = SpendSplit::default();
205    let mut by_task: BTreeMap<String, i64> = BTreeMap::new();
206    let mut phase_min: BTreeMap<String, i64> = BTreeMap::new();
207    let mut phase_max: BTreeMap<String, i64> = BTreeMap::new();
208    let mut phase_calls: BTreeMap<String, i64> = BTreeMap::new();
209    for t in &traces {
210        if is_agent_loop(&t.task_kind) {
211            spend.agent_loop_usd_micros += t.cost_usd_micros;
212        } else {
213            spend.one_shot_usd_micros += t.cost_usd_micros;
214        }
215        *by_task.entry(t.task_kind.clone()).or_default() += t.cost_usd_micros;
216        *phase_calls.entry(t.task_kind.clone()).or_default() += 1;
217        let start = t.started_at;
218        let finish = t.finished_at.unwrap_or(start);
219        phase_min.entry(t.task_kind.clone()).and_modify(|e| *e = (*e).min(start)).or_insert(start);
220        phase_max
221            .entry(t.task_kind.clone())
222            .and_modify(|e| *e = (*e).max(finish))
223            .or_insert(finish);
224    }
225    spend.by_task_kind = into_sorted_split(by_task);
226
227    let mut phase_durations: Vec<PhaseDuration> = Vec::new();
228    phase_durations.push(PhaseDuration {
229        phase: "static".to_string(),
230        wall_clock_ms: run.wall_clock_ms.unwrap_or(0),
231        call_count: 1,
232    });
233    for (phase, min) in phase_min {
234        let max = phase_max.get(&phase).copied().unwrap_or(min);
235        let calls = phase_calls.get(&phase).copied().unwrap_or(0);
236        phase_durations.push(PhaseDuration {
237            phase,
238            wall_clock_ms: (max - min).max(0),
239            call_count: calls,
240        });
241    }
242    phase_durations.sort_by(|a, b| a.phase.cmp(&b.phase));
243
244    Ok(RunCard {
245        run_id: run.id,
246        started_at: run.started_at,
247        finished_at: run.finished_at,
248        status: run.status,
249        triggered_by: run.triggered_by,
250        wall_clock_ms: run.wall_clock_ms,
251        total_findings: findings.len() as i64,
252        by_status: into_sorted_split(by_status),
253        by_cap: into_sorted_split(by_cap),
254        by_origin: into_sorted_split(by_origin),
255        by_lang: into_sorted_split(by_lang),
256        by_repo: into_sorted_split(by_repo),
257        spend,
258        phase_durations,
259        candidate_findings_total,
260        by_candidate_status: into_sorted_split(by_candidate_status_map),
261        business_logic_templates_considered: business_logic_row.templates_considered,
262        business_logic_candidates_generated: business_logic_row.candidates_generated,
263        business_logic_templates_skipped: business_logic_row.templates_skipped,
264    })
265}
266
267fn into_sorted_split(map: BTreeMap<String, i64>) -> Vec<BySplit> {
268    let mut out: Vec<BySplit> =
269        map.into_iter().map(|(key, count)| BySplit { key, count }).collect();
270    out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
271    out
272}
273
274/// Lightweight per-extension language guess. Matches the
275/// `ai_pipeline::infer_lang` table so the run card's lang split lines
276/// up with what the AI pipeline saw. Unknown extensions surface as
277/// `"unknown"`.
278fn lang_from_path(path: &str) -> &'static str {
279    let lower = path.to_ascii_lowercase();
280    let ext = lower.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
281    match ext {
282        "rs" => "rust",
283        "py" => "python",
284        "js" | "mjs" | "cjs" => "javascript",
285        "ts" | "tsx" => "typescript",
286        "go" => "go",
287        "java" => "java",
288        "rb" => "ruby",
289        "php" => "php",
290        "c" | "h" => "c",
291        "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
292        "cs" => "csharp",
293        "swift" => "swift",
294        "kt" | "kts" => "kotlin",
295        _ => "unknown",
296    }
297}
298
299/// Render a run card as a self-contained HTML fragment suitable for
300/// export. Returns one `<section>` per topic; the caller is expected
301/// to wrap it in a `<!doctype html>` boilerplate if a standalone
302/// document is needed.
303pub fn render_html(card: &RunCard) -> String {
304    let mut out = String::new();
305    out.push_str(&format!(
306        "<section><h2>Run {}</h2><dl><dt>Status</dt><dd>{}</dd>\
307         <dt>Triggered by</dt><dd>{}</dd>\
308         <dt>Started</dt><dd>{}</dd><dt>Finished</dt><dd>{}</dd>\
309         <dt>Wall clock</dt><dd>{} ms</dd>\
310         <dt>Total findings</dt><dd>{}</dd>\
311         <dt>Candidate findings</dt><dd>{}</dd>\
312         <dt>Business-logic templates</dt><dd>{} considered, {} generated candidate(s), {} skipped</dd></dl></section>",
313        escape_html(&card.run_id),
314        escape_html(&card.status),
315        escape_html(&card.triggered_by),
316        card.started_at,
317        card.finished_at.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()),
318        card.wall_clock_ms.unwrap_or(0),
319        card.total_findings,
320        card.candidate_findings_total,
321        card.business_logic_templates_considered,
322        card.business_logic_candidates_generated,
323        card.business_logic_templates_skipped,
324    ));
325    push_html_split(&mut out, "Status", &card.by_status);
326    push_html_split(&mut out, "Capability", &card.by_cap);
327    push_html_split(&mut out, "Origin", &card.by_origin);
328    push_html_split(&mut out, "Language", &card.by_lang);
329    push_html_split(&mut out, "Repository", &card.by_repo);
330    push_html_split(&mut out, "Candidate status", &card.by_candidate_status);
331    out.push_str(&format!(
332        "<section><h3>AI spend</h3>\
333         <p>One-shot: ${:.6} · Agent loop: ${:.6} · Total: ${:.6}</p>",
334        usd_from_micros(card.spend.one_shot_usd_micros),
335        usd_from_micros(card.spend.agent_loop_usd_micros),
336        usd_from_micros(card.spend.total_usd_micros()),
337    ));
338    out.push_str("<ul>");
339    for split in &card.spend.by_task_kind {
340        out.push_str(&format!(
341            "<li>{}: ${:.6}</li>",
342            escape_html(&split.key),
343            usd_from_micros(split.count),
344        ));
345    }
346    out.push_str("</ul></section>");
347    out.push_str("<section><h3>Phase wall clock</h3><ul>");
348    for phase in &card.phase_durations {
349        out.push_str(&format!(
350            "<li>{}: {} ms ({} call{})</li>",
351            escape_html(&phase.phase),
352            phase.wall_clock_ms,
353            phase.call_count,
354            if phase.call_count == 1 { "" } else { "s" },
355        ));
356    }
357    out.push_str("</ul></section>");
358    out
359}
360
361fn push_html_split(out: &mut String, title: &str, splits: &[BySplit]) {
362    out.push_str(&format!("<section><h3>{}</h3>", escape_html(title)));
363    if splits.is_empty() {
364        out.push_str("<p>-</p></section>");
365        return;
366    }
367    out.push_str("<ul>");
368    for s in splits {
369        out.push_str(&format!("<li>{}: {}</li>", escape_html(&s.key), s.count,));
370    }
371    out.push_str("</ul></section>");
372}
373
374fn escape_html(input: &str) -> String {
375    let mut out = String::with_capacity(input.len());
376    for ch in input.chars() {
377        match ch {
378            '&' => out.push_str("&amp;"),
379            '<' => out.push_str("&lt;"),
380            '>' => out.push_str("&gt;"),
381            '"' => out.push_str("&quot;"),
382            '\'' => out.push_str("&#39;"),
383            _ => out.push(ch),
384        }
385    }
386    out
387}
388
389/// Render a run card as Markdown. Mirrors the HTML structure so the
390/// two outputs stay in sync.
391///
392/// AI-controlled identifiers (run id, status, triggered_by, by-split
393/// keys, phase names) get wrapped in a CommonMark code span via
394/// `markdown_code` so a renderer with raw HTML enabled cannot lift an
395/// `<img onerror=...>` straight into the operator's DOM.
396pub fn render_markdown(card: &RunCard) -> String {
397    let mut out = String::new();
398    out.push_str(&format!("# Run {}\n\n", markdown_code(&card.run_id)));
399    out.push_str(&format!("- **Status**: {}\n", markdown_code(&card.status)));
400    out.push_str(&format!("- **Triggered by**: {}\n", markdown_code(&card.triggered_by)));
401    out.push_str(&format!("- **Started**: {}\n", card.started_at));
402    out.push_str(&format!(
403        "- **Finished**: {}\n",
404        card.finished_at.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string())
405    ));
406    out.push_str(&format!("- **Wall clock**: {} ms\n", card.wall_clock_ms.unwrap_or(0)));
407    out.push_str(&format!("- **Total findings**: {}\n", card.total_findings));
408    out.push_str(&format!("- **Candidate findings**: {}\n", card.candidate_findings_total));
409    out.push_str(&format!(
410        "- **Business-logic templates**: {} considered, {} generated candidate(s), {} skipped\n\n",
411        card.business_logic_templates_considered,
412        card.business_logic_candidates_generated,
413        card.business_logic_templates_skipped,
414    ));
415
416    push_markdown_split(&mut out, "Status", &card.by_status);
417    push_markdown_split(&mut out, "Capability", &card.by_cap);
418    push_markdown_split(&mut out, "Origin", &card.by_origin);
419    push_markdown_split(&mut out, "Language", &card.by_lang);
420    push_markdown_split(&mut out, "Repository", &card.by_repo);
421    push_markdown_split(&mut out, "Candidate status", &card.by_candidate_status);
422
423    out.push_str("## AI spend\n\n");
424    out.push_str(&format!(
425        "- **One-shot**: ${:.6}\n- **Agent loop**: ${:.6}\n- **Total**: ${:.6}\n\n",
426        usd_from_micros(card.spend.one_shot_usd_micros),
427        usd_from_micros(card.spend.agent_loop_usd_micros),
428        usd_from_micros(card.spend.total_usd_micros()),
429    ));
430    for split in &card.spend.by_task_kind {
431        out.push_str(&format!(
432            "- {}: ${:.6}\n",
433            markdown_code(&split.key),
434            usd_from_micros(split.count)
435        ));
436    }
437    out.push('\n');
438
439    out.push_str("## Phase wall clock\n\n");
440    for phase in &card.phase_durations {
441        out.push_str(&format!(
442            "- {}: {} ms ({} call{})\n",
443            markdown_code(&phase.phase),
444            phase.wall_clock_ms,
445            phase.call_count,
446            if phase.call_count == 1 { "" } else { "s" },
447        ));
448    }
449    out
450}
451
452fn push_markdown_split(out: &mut String, title: &str, splits: &[BySplit]) {
453    out.push_str(&format!("## {title}\n\n"));
454    if splits.is_empty() {
455        out.push_str("_no rows_\n\n");
456        return;
457    }
458    for s in splits {
459        out.push_str(&format!("- {}: {}\n", markdown_code(&s.key), s.count));
460    }
461    out.push('\n');
462}
463
464/// Wrap `s` in a CommonMark code span using a backtick fence one longer
465/// than the longest run of backticks in the input, padding with a
466/// space when the content begins or ends with a backtick. Renders the
467/// content as inline code so a downstream renderer with raw HTML
468/// enabled treats `<img onerror=...>` as text rather than markup.
469fn markdown_code(s: &str) -> String {
470    let mut longest_run = 0usize;
471    let mut current_run = 0usize;
472    for ch in s.chars() {
473        if ch == '`' {
474            current_run += 1;
475            longest_run = longest_run.max(current_run);
476        } else {
477            current_run = 0;
478        }
479    }
480    let fence_len = longest_run + 1;
481    let fence: String = "`".repeat(fence_len);
482    let needs_pad = s.starts_with('`') || s.ends_with('`');
483    if needs_pad {
484        format!("{fence} {s} {fence}")
485    } else {
486        format!("{fence}{s}{fence}")
487    }
488}
489
490fn usd_from_micros(micros: i64) -> f64 {
491    micros as f64 / 1_000_000.0
492}
493
494#[derive(sqlx::FromRow)]
495struct RunRow {
496    id: String,
497    started_at: i64,
498    finished_at: Option<i64>,
499    status: String,
500    triggered_by: String,
501    wall_clock_ms: Option<i64>,
502}
503
504#[derive(sqlx::FromRow)]
505struct FindingRow {
506    id: String,
507    repo: String,
508    path: String,
509    cap: String,
510    status: String,
511    finding_origin: String,
512}
513
514#[derive(sqlx::FromRow)]
515struct PayloadRow {
516    finding_id: String,
517    lang: String,
518}
519
520#[derive(sqlx::FromRow)]
521struct TraceRow {
522    task_kind: String,
523    cost_usd_micros: i64,
524    started_at: i64,
525    finished_at: Option<i64>,
526}
527
528#[derive(sqlx::FromRow)]
529struct CandidateRow {
530    status: String,
531    count: i64,
532}
533
534#[derive(sqlx::FromRow)]
535struct BusinessLogicSummaryRow {
536    templates_considered: i64,
537    candidates_generated: i64,
538    templates_skipped: i64,
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::store::testutil::{
545        fresh_store, sample_candidate, sample_finding, sample_payload, sample_repo, sample_run,
546    };
547    use crate::store::AgentTraceRecord;
548
549    async fn seed_two_repo_run(s: &crate::store::Store) -> String {
550        s.repos().upsert(&sample_repo("alpha")).await.expect("alpha");
551        s.repos().upsert(&sample_repo("beta")).await.expect("beta");
552        let mut run = sample_run("run-card-1");
553        run.status = "Succeeded".to_string();
554        run.finished_at = Some(12_000);
555        run.wall_clock_ms = Some(10_000);
556        s.runs().insert(&run).await.expect("run");
557        // 3 findings: 2 sqli (one each repo), 1 cmdi.
558        let f1 = sample_finding("run-card-1", "alpha", "src/a.py", "rule-a");
559        let f2 = sample_finding("run-card-1", "beta", "src/b.py", "rule-b");
560        let mut f3 = sample_finding("run-card-1", "alpha", "src/c.rs", "rule-c");
561        f3.cap = "cmdi".to_string();
562        f3.finding_origin = "AI".to_string();
563        f3.status = "Verified".to_string();
564        s.findings().upsert(&f1).await.expect("f1");
565        s.findings().upsert(&f2).await.expect("f2");
566        s.findings().upsert(&f3).await.expect("f3");
567        // Payload with explicit lang for f1 (overrides path-based guess).
568        let mut p1 = sample_payload("p-1", &f1.id);
569        p1.lang = "python".to_string();
570        s.payloads().insert(&p1).await.expect("payload");
571        // PayloadSynthesis trace: one_shot, $0.123456.
572        s.agent_traces()
573            .insert(&AgentTraceRecord {
574                id: "trace-1".to_string(),
575                finding_id: Some(f1.id.clone()),
576                task_kind: TaskKind::PayloadSynthesis.as_str().to_string(),
577                runtime_name: "anthropic".to_string(),
578                model: "claude-opus-4-7".to_string(),
579                prompt_version: Some("v1".to_string()),
580                conversation_jsonl_path: None,
581                tokens_in: 1_000,
582                tokens_out: 200,
583                cost_usd_micros: 123_456,
584                cache_hits: 0,
585                cache_misses: 1,
586                duration_ms: Some(2_000),
587                started_at: 4_000,
588                finished_at: Some(6_000),
589                verifier_blob: None,
590            })
591            .await
592            .expect("trace-1");
593        // Exploration trace: agent_loop, $0.500000.
594        s.agent_traces()
595            .insert(&AgentTraceRecord {
596                id: "trace-2".to_string(),
597                finding_id: Some(f3.id.clone()),
598                task_kind: TaskKind::Exploration.as_str().to_string(),
599                runtime_name: "claude-code".to_string(),
600                model: "sonnet".to_string(),
601                prompt_version: Some("v1".to_string()),
602                conversation_jsonl_path: None,
603                tokens_in: 5_000,
604                tokens_out: 800,
605                cost_usd_micros: 500_000,
606                cache_hits: 0,
607                cache_misses: 1,
608                duration_ms: Some(7_000),
609                started_at: 5_000,
610                finished_at: Some(12_000),
611                verifier_blob: None,
612            })
613            .await
614            .expect("trace-2");
615        // Two AI-proposed candidates for this run: one still Pending,
616        // one Promoted (an end-to-end NovelFindingDiscovery + verifier
617        // confirmation). Used by the candidate-aggregate assertion.
618        s.candidate_findings()
619            .insert(&sample_candidate("cand-pending", "run-card-1", "alpha"))
620            .await
621            .expect("cand-pending");
622        let mut promoted = sample_candidate("cand-promoted", "run-card-1", "beta");
623        promoted.status = "Promoted".to_string();
624        s.candidate_findings().insert(&promoted).await.expect("cand-promoted");
625        "run-card-1".to_string()
626    }
627
628    #[tokio::test]
629    async fn build_run_card_aggregates_counts_and_spend() {
630        let (_tmp, s) = fresh_store().await;
631        let run_id = seed_two_repo_run(&s).await;
632        let card = build_run_card(s.pool(), &run_id).await.expect("card");
633        assert_eq!(card.run_id, run_id);
634        assert_eq!(card.status, "Succeeded");
635        assert_eq!(card.total_findings, 3);
636
637        let by_cap: Vec<_> = card.by_cap.iter().map(|s| (s.key.as_str(), s.count)).collect();
638        assert!(by_cap.contains(&("sqli", 2)));
639        assert!(by_cap.contains(&("cmdi", 1)));
640
641        let by_origin: Vec<_> = card.by_origin.iter().map(|s| (s.key.as_str(), s.count)).collect();
642        assert!(by_origin.contains(&("Static", 2)));
643        assert!(by_origin.contains(&("AI", 1)));
644
645        let by_lang: Vec<_> = card.by_lang.iter().map(|s| (s.key.as_str(), s.count)).collect();
646        assert!(by_lang.contains(&("python", 2)), "expected python: {by_lang:?}");
647        assert!(by_lang.contains(&("rust", 1)));
648
649        let by_repo: Vec<_> = card.by_repo.iter().map(|s| (s.key.as_str(), s.count)).collect();
650        assert!(by_repo.contains(&("alpha", 2)));
651        assert!(by_repo.contains(&("beta", 1)));
652
653        assert_eq!(card.spend.one_shot_usd_micros, 123_456);
654        assert_eq!(card.spend.agent_loop_usd_micros, 500_000);
655        assert_eq!(card.spend.total_usd_micros(), 623_456);
656
657        let static_phase =
658            card.phase_durations.iter().find(|p| p.phase == "static").expect("static phase");
659        assert_eq!(static_phase.wall_clock_ms, 10_000);
660
661        let exploration = card
662            .phase_durations
663            .iter()
664            .find(|p| p.phase == TaskKind::Exploration.as_str())
665            .expect("exploration phase");
666        assert_eq!(exploration.wall_clock_ms, 7_000);
667        assert_eq!(exploration.call_count, 1);
668
669        // Candidate-findings aggregate: two rows, one Pending + one
670        // Promoted. The per-status split sorts by `count DESC` then key
671        // ASC, so both keys end up present without relying on order.
672        assert_eq!(card.candidate_findings_total, 2);
673        let by_cand: Vec<_> =
674            card.by_candidate_status.iter().map(|s| (s.key.as_str(), s.count)).collect();
675        assert!(
676            by_cand.contains(&("Pending", 1)),
677            "by_candidate_status missing Pending: {by_cand:?}"
678        );
679        assert!(
680            by_cand.contains(&("Promoted", 1)),
681            "by_candidate_status missing Promoted: {by_cand:?}"
682        );
683    }
684
685    #[tokio::test]
686    async fn build_run_card_with_no_candidates_reports_zero_total() {
687        let (_tmp, s) = fresh_store().await;
688        s.repos().upsert(&sample_repo("alpha")).await.expect("alpha");
689        let mut run = sample_run("run-card-empty");
690        run.status = "Succeeded".to_string();
691        run.finished_at = Some(1);
692        run.wall_clock_ms = Some(1);
693        s.runs().insert(&run).await.expect("run");
694        let card = build_run_card(s.pool(), "run-card-empty").await.expect("card");
695        assert_eq!(card.candidate_findings_total, 0);
696        assert!(
697            card.by_candidate_status.is_empty(),
698            "no candidate rows should yield no per-status split",
699        );
700    }
701
702    #[tokio::test]
703    async fn build_run_card_missing_run_returns_not_found() {
704        let (_tmp, s) = fresh_store().await;
705        let err = build_run_card(s.pool(), "nope").await.expect_err("not found");
706        assert!(matches!(err, RunCardError::NotFound(_)));
707    }
708
709    #[tokio::test]
710    async fn render_markdown_round_trips_card() {
711        let (_tmp, s) = fresh_store().await;
712        let run_id = seed_two_repo_run(&s).await;
713        let card = build_run_card(s.pool(), &run_id).await.expect("card");
714        let md = render_markdown(&card);
715        assert!(md.contains("Run `run-card-1`"));
716        assert!(md.contains("Total findings"));
717        assert!(md.contains("One-shot"));
718        assert!(md.contains("Agent loop"));
719        assert!(md.contains("Phase wall clock"));
720    }
721
722    #[test]
723    fn markdown_code_wraps_simple_input() {
724        assert_eq!(markdown_code("sqli"), "`sqli`");
725    }
726
727    #[test]
728    fn markdown_code_lengthens_fence_to_dodge_inner_backticks() {
729        // `<-- one backtick inside; wrapper uses two.
730        assert_eq!(markdown_code("a`b"), "``a`b``");
731    }
732
733    #[test]
734    fn markdown_code_pads_when_content_borders_with_backtick() {
735        assert_eq!(markdown_code("`foo"), "`` `foo ``");
736        assert_eq!(markdown_code("foo`"), "`` foo` ``");
737    }
738
739    #[test]
740    fn render_markdown_neutralises_injected_html_in_by_split_keys() {
741        // BySplit key the AI controls (e.g. `payloads.lang` or
742        // `findings.cap`) cannot break out of the code span.
743        let card = RunCard {
744            run_id: "run-1".to_string(),
745            started_at: 0,
746            finished_at: Some(1),
747            status: "Succeeded".to_string(),
748            triggered_by: "UI".to_string(),
749            wall_clock_ms: Some(1),
750            total_findings: 1,
751            by_status: vec![BySplit { key: "Open".to_string(), count: 1 }],
752            by_cap: vec![BySplit { key: "<img src=x onerror=alert(1)>".to_string(), count: 1 }],
753            by_origin: Vec::new(),
754            by_lang: Vec::new(),
755            by_repo: Vec::new(),
756            spend: SpendSplit::default(),
757            phase_durations: Vec::new(),
758            candidate_findings_total: 0,
759            by_candidate_status: Vec::new(),
760            business_logic_templates_considered: 0,
761            business_logic_candidates_generated: 0,
762            business_logic_templates_skipped: 0,
763        };
764        let md = render_markdown(&card);
765        // The dangerous chars are inside a code span; the literal `<`
766        // is not interpreted as a tag opener by any conforming
767        // CommonMark renderer.
768        assert!(md.contains("`<img src=x onerror=alert(1)>`"));
769        // And nowhere does the bare tag appear outside backticks.
770        assert!(!md.split('`').enumerate().any(|(i, seg)| i % 2 == 0 && seg.contains("<img")));
771    }
772
773    #[tokio::test]
774    async fn render_html_round_trips_card() {
775        let (_tmp, s) = fresh_store().await;
776        let run_id = seed_two_repo_run(&s).await;
777        let card = build_run_card(s.pool(), &run_id).await.expect("card");
778        let html = render_html(&card);
779        assert!(html.contains("<h2>Run run-card-1</h2>"));
780        assert!(html.contains("<h3>Capability</h3>"));
781        // Escape verified: a finding cap containing `<` would land in
782        // the output verbatim if escape_html broke.
783        assert!(!html.contains("<script>"));
784    }
785
786    #[test]
787    fn lang_from_path_handles_common_extensions() {
788        assert_eq!(lang_from_path("src/foo.rs"), "rust");
789        assert_eq!(lang_from_path("src/foo.PY"), "python");
790        assert_eq!(lang_from_path("src/foo.tsx"), "typescript");
791        assert_eq!(lang_from_path("Dockerfile"), "unknown");
792    }
793}