use std::path::Path;
use serde_json::Value;
use crate::budgets::{BudgetSpend, spend_from_bytes};
use crate::git_tree::{AgentState, Framing, framing, segment_count};
mod render;
mod wound;
pub use render::{StepTab, render};
pub use wound::{NO_RESPONSE, latest_step_no_response};
const STEPS_DIR: &str = "steps";
const META_FILE: &str = "meta.json";
const REQUEST_FILE: &str = "request.json";
const RESPONSE_FILE: &str = "response.json";
const STAGING_FILE: &str = "staging.json";
const TOOLS_SUBDIR: &str = "tools";
const INPUT_FILE: &str = "input.json";
const OUTPUT_FILE: &str = "output.json";
const STEP_SEQ_WIDTH: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepSummary {
pub seq: String,
pub framing: Framing,
pub attempts: usize,
pub tokens: BudgetSpend,
pub commit: Option<String>,
pub started_at: Option<String>,
pub ended_at: Option<String>,
pub auth_failed: bool,
pub no_response: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StepsView {
pub steps: Vec<StepSummary>,
}
pub fn build(workspace: &Path, agent_id: &str, state: AgentState) -> StepsView {
let mut steps: Vec<StepSummary> = step_seqs(workspace, agent_id)
.into_iter()
.map(|seq| summarize(workspace, agent_id, &seq))
.collect();
if wound::driven(state)
&& let Some(newest) = steps.last_mut()
{
newest.no_response = false;
}
StepsView { steps }
}
fn step_seqs(workspace: &Path, agent_id: &str) -> Vec<String> {
let dir = workspace.join(STEPS_DIR).join(agent_id);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut seqs: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_str()?.to_string();
let is_seq = name.len() == STEP_SEQ_WIDTH
&& name.bytes().all(|b| b.is_ascii_digit())
&& entry.path().is_dir();
is_seq.then_some(name)
})
.collect();
seqs.sort();
seqs
}
fn summarize(workspace: &Path, agent_id: &str, seq: &str) -> StepSummary {
let step = workspace.join(STEPS_DIR).join(agent_id).join(seq);
let response = std::fs::read(step.join(RESPONSE_FILE)).unwrap_or_default();
let meta_bytes = std::fs::read(step.join(META_FILE)).ok();
let meta = meta_bytes
.as_ref()
.and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok());
StepSummary {
seq: seq.to_string(),
framing: framing(&response),
attempts: segment_count(&response),
tokens: spend_from_bytes(&response),
commit: meta_field(meta.as_ref(), "commit"),
started_at: meta_field(meta.as_ref(), "started_at"),
ended_at: meta_field(meta.as_ref(), "ended_at"),
auth_failed: crate::login::auth::is_auth_failure(&response),
no_response: wound::unanswered(&response, meta_bytes.is_some()),
}
}
fn meta_field(meta: Option<&Value>, key: &str) -> Option<String> {
meta?.get(key)?.as_str().map(str::to_string)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Doc {
Json(Value),
Raw(Vec<u8>),
}
impl Doc {
fn of_bytes(bytes: Vec<u8>) -> Doc {
match serde_json::from_slice(&bytes) {
Ok(value) => Doc::Json(value),
Err(_) => Doc::Raw(bytes),
}
}
fn of_file(path: &Path) -> Doc {
Doc::of_bytes(std::fs::read(path).unwrap_or_default())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolIo {
pub tool_id: String,
pub input: Doc,
pub output: Doc,
pub is_error: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepDetail {
pub seq: String,
pub meta: Doc,
pub request: Doc,
pub staging: Doc,
pub response: Vec<Doc>,
pub tools: Vec<ToolIo>,
}
pub fn detail(workspace: &Path, agent_id: &str, seq: &str) -> StepDetail {
let step = workspace.join(STEPS_DIR).join(agent_id).join(seq);
StepDetail {
seq: seq.to_string(),
meta: Doc::of_file(&step.join(META_FILE)),
request: Doc::of_file(&step.join(REQUEST_FILE)),
staging: Doc::of_file(&step.join(STAGING_FILE)),
response: response_events(&step.join(RESPONSE_FILE)),
tools: tool_ios(&step.join(TOOLS_SUBDIR)),
}
}
fn response_events(path: &Path) -> Vec<Doc> {
let Ok(bytes) = std::fs::read(path) else {
return Vec::new();
};
bytes
.split(|&b| b == b'\n')
.filter(|line| !line.is_empty())
.map(|line| Doc::of_bytes(line.to_vec()))
.collect()
}
fn tool_ios(tools_dir: &Path) -> Vec<ToolIo> {
let Ok(entries) = std::fs::read_dir(tools_dir) else {
return Vec::new();
};
let mut tools: Vec<ToolIo> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if !path.is_dir() {
return None;
}
let tool_id = entry.file_name().to_str()?.to_string();
let output = Doc::of_file(&path.join(OUTPUT_FILE));
let is_error = output_is_error(&output);
Some(ToolIo {
tool_id,
input: Doc::of_file(&path.join(INPUT_FILE)),
output,
is_error,
})
})
.collect();
tools.sort_by(|a, b| a.tool_id.cmp(&b.tool_id));
tools
}
fn output_is_error(output: &Doc) -> bool {
match output {
Doc::Json(value) => value
.get("exit_code")
.and_then(Value::as_i64)
.is_some_and(|code| code != 0),
Doc::Raw(_) => false,
}
}
#[cfg(test)]
mod tests;