use super::cmd::{LogEntry, for_each_ref_agents, ref_name, walk_branch_steps};
use super::detect::payload_headline;
use super::marks::Marks;
use super::probe::{LockProbe, WriterProbe};
use super::state::classify;
use super::streaming::{RESPONSE_FILE, latest_step_dir, stream_from_disk};
use super::tools::tool_calls_from_disk;
use super::{AGENTS_DIR, Agent, CommitNode, GitTreeError, MESSAGES_DIR, STEPS_DIR};
use std::path::Path;
pub(super) fn build_node(entry: LogEntry) -> CommitNode {
let LogEntry {
oid,
timestamp,
subject,
} = entry;
let short_oid = oid.get(..8).unwrap_or(&oid).to_string();
CommitNode {
oid,
short_oid,
timestamp_unix: timestamp,
subject,
}
}
pub(super) fn enumerate_agents(
workspace: &Path,
git_dir: &Path,
lock: &dyn LockProbe,
writer: &dyn WriterProbe,
) -> Result<Vec<Agent>, GitTreeError> {
let out = for_each_ref_agents(git_dir)?;
let text = String::from_utf8_lossy(&out);
let marks = Marks::from_repo(git_dir)?;
let mut agents = Vec::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
let mut parts = line.splitn(3, ' ');
let branch_name = parts
.next()
.ok_or_else(|| GitTreeError::LogFormat(line.to_string()))?
.to_string();
let tip_oid = parts
.next()
.ok_or_else(|| GitTreeError::LogFormat(line.to_string()))?
.to_string();
let ts_str = parts
.next()
.ok_or_else(|| GitTreeError::LogFormat(line.to_string()))?;
let tip_ts: i64 = ts_str
.parse()
.map_err(|_| GitTreeError::LogFormat(line.to_string()))?;
let agent_id = branch_name
.strip_prefix("agents/")
.unwrap_or(&branch_name)
.to_string();
let tip_short_oid = tip_oid.get(..8).unwrap_or(&tip_oid).to_string();
let steps = walk_branch_steps(git_dir, &branch_name)?;
let liveness = classify(workspace, &agent_id, lock, writer);
let goal = goal_from_disk(workspace, &agent_id);
let messages = messages_from_disk(workspace, &agent_id);
let last_action = last_action_from_disk(workspace, &agent_id, tip_ts, messages.newest_unix);
let stream = stream_from_disk(workspace, &agent_id);
agents.push(Agent {
name: ref_name(git_dir, &branch_name)?,
preview: goal.preview,
stream,
tool_calls: tool_calls_from_disk(workspace, &agent_id),
state: liveness.state,
state_uncertain: liveness.uncertain,
truncated: liveness.truncated,
failure: liveness.failure,
pending: crate::inboxview::list_inbox(workspace, &agent_id),
conflicted_oid: marks.conflicted_oid(&agent_id),
budget_oid: marks.budget_oid(&agent_id),
abandoned_oid: marks.abandoned_oid(&agent_id),
notify_oid: marks.notify_oid(&agent_id),
held: marks.held(&agent_id),
flagged: None,
goal_ball: goal.ball,
goal_name: goal.name,
call_start_unix: call_start_from_disk(workspace, &agent_id),
branch_name,
agent_id,
tip_oid,
tip_short_oid,
tip_timestamp_unix: tip_ts,
last_action_unix: last_action,
messages: messages.count,
steps,
});
}
Ok(agents)
}
fn last_action_from_disk(workspace: &Path, agent_id: &str, tip_ts: i64, newest_msg: i64) -> i64 {
tip_ts
.max(newest_msg)
.max(live_tail_mtime(workspace, agent_id))
}
struct Messages {
count: usize,
newest_unix: i64,
}
fn call_start_from_disk(workspace: &Path, agent_id: &str) -> Option<i64> {
let steps = workspace.join(STEPS_DIR).join(agent_id);
mtime_unix(&latest_step_dir(&steps)?.join("request.json"))
}
fn messages_from_disk(workspace: &Path, agent_id: &str) -> Messages {
let dir = workspace.join(AGENTS_DIR).join(agent_id).join(MESSAGES_DIR);
let mut fold = Messages {
count: 0,
newest_unix: 0,
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return fold;
};
for entry in entries.flatten() {
fold.newest_unix = fold.newest_unix.max(mtime_unix(&entry.path()).unwrap_or(0));
if let Some(seq) = crate::transcript::seq_of(&entry.file_name().to_string_lossy()) {
fold.count = fold.count.max(seq);
}
}
fold
}
fn live_tail_mtime(workspace: &Path, agent_id: &str) -> i64 {
let steps = workspace.join(STEPS_DIR).join(agent_id);
latest_step_dir(&steps)
.and_then(|dir| mtime_unix(&dir.join(RESPONSE_FILE)))
.unwrap_or(0)
}
pub(crate) fn mtime_unix(path: &Path) -> Option<i64> {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|since| i64::try_from(since.as_secs()).ok())
}
struct Goal {
ball: Option<String>,
name: Option<String>,
preview: Option<String>,
}
fn goal_from_disk(workspace: &Path, agent_id: &str) -> Goal {
let path = workspace.join(AGENTS_DIR).join(agent_id).join("goal.md");
let text = std::fs::read_to_string(&path).ok();
Goal {
ball: text.as_deref().and_then(crate::start::parse_ball_stamp),
name: text.as_deref().and_then(crate::start::parse_identity_stamp),
preview: text.as_deref().map(payload_headline),
}
}