use super::{now_ms, read_spool, spool_dir, Note};
use crate::cli::{DogfoodCategory, DogfoodSeverity, TriageCommand, TriageFilter};
use crate::output::{self, OutputFormat};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::PathBuf;
pub(super) fn handle(command: TriageCommand, quiet: bool) -> anyhow::Result<()> {
match command {
TriageCommand::Handle { id, reason } => set_state(&id, true, reason, quiet),
TriageCommand::Reopen { id } => set_state(&id, false, None, quiet),
TriageCommand::List { status, format } => list(status, format),
}
}
#[derive(Serialize, Deserialize)]
struct StateRecord {
ts_ms: i64,
note_id: String,
handled: bool,
reason: Option<String>,
}
#[derive(Serialize)]
struct TriageView {
id: String,
status: &'static str,
reason: Option<String>,
ts_ms: i64,
session_id: Option<String>,
surface: String,
category: DogfoodCategory,
severity: DogfoodSeverity,
summary: String,
}
fn set_state(
input: &str,
handled: bool,
reason: Option<String>,
quiet: bool,
) -> anyhow::Result<()> {
let notes = read_spool()?;
let note_id = resolve_note_id(input, ¬es)?;
append_state(&StateRecord {
ts_ms: now_ms(),
note_id: note_id.clone(),
handled,
reason,
})?;
if !quiet {
let status = if handled { "handled" } else { "unhandled" };
println!("marked {note_id} {status}");
}
Ok(())
}
fn list(filter: TriageFilter, format: OutputFormat) -> anyhow::Result<()> {
let notes = read_spool()?;
let state = load_state()?;
let mut views: Vec<TriageView> = notes.iter().map(|note| view(note, &state)).collect();
views.sort_by(|a, b| a.id.cmp(&b.id));
views.dedup_by(|a, b| a.id == b.id);
let shown: Vec<TriageView> = views
.into_iter()
.filter(|entry| matches_filter(entry, filter))
.collect();
output::print(format, &shown)?;
Ok(())
}
fn note_id(note: &Note) -> String {
let canonical = serde_json::to_vec(note).expect("canonical JSON of a note");
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in &canonical {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{hash:016x}")
}
fn resolve_note_id(input: &str, notes: &[Note]) -> anyhow::Result<String> {
let mut ids: Vec<String> = notes.iter().map(note_id).collect();
ids.sort();
ids.dedup();
if ids.iter().any(|id| id == input) {
return Ok(input.to_string());
}
let matches: Vec<String> = ids
.iter()
.filter(|id| id.starts_with(input))
.cloned()
.collect();
match matches.as_slice() {
[] if ids.is_empty() => {
anyhow::bail!("no dogfood notes captured; nothing to resolve `{input}` against")
}
[] => anyhow::bail!(
"unknown dogfood note id `{input}`; run `provenance dogfood triage list` for ids"
),
[only] => Ok((*only).clone()),
many => anyhow::bail!(
"ambiguous dogfood note id prefix `{input}` matches {} notes: {}",
many.len(),
many.join(", ")
),
}
}
fn view(note: &Note, state: &BTreeMap<String, StateRecord>) -> TriageView {
let id = note_id(note);
let (status, reason) = match state.get(&id) {
Some(record) if record.handled => ("handled", record.reason.clone()),
_ => ("unhandled", None),
};
TriageView {
id,
status,
reason,
ts_ms: note.ts_ms,
session_id: note.session_id.clone(),
surface: note.surface.clone(),
category: note.category,
severity: note.severity,
summary: note.summary.clone(),
}
}
fn matches_filter(entry: &TriageView, filter: TriageFilter) -> bool {
match filter {
TriageFilter::All => true,
TriageFilter::Handled => entry.status == "handled",
TriageFilter::Unhandled => entry.status == "unhandled",
}
}
fn triage_path() -> PathBuf {
spool_dir().join("triage.jsonl")
}
fn load_state() -> anyhow::Result<BTreeMap<String, StateRecord>> {
let path = triage_path();
if !path.exists() {
return Ok(BTreeMap::new());
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading dogfood triage state {}", path.display()))?;
let mut state = BTreeMap::new();
for (index, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<StateRecord>(line) {
Ok(record) => {
state.insert(record.note_id.clone(), record);
}
Err(err) => eprintln!(
"skipping malformed dogfood triage record at {}:{}: {err}",
path.display(),
index + 1
),
}
}
Ok(state)
}
fn append_state(record: &StateRecord) -> anyhow::Result<()> {
let dir = spool_dir();
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating dogfood spool dir {}", dir.display()))?;
let path = triage_path();
let mut line = serde_json::to_string(record)?;
line.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening dogfood triage state {}", path.display()))?;
file.write_all(line.as_bytes())
.with_context(|| format!("appending dogfood triage record to {}", path.display()))
}