use anyhow::Result;
use chrono::{DateTime, Utc};
use objects::store::{
ActorChainNode, ActorPresence, ActorPresenceStatus, ActorPresenceStore, AgentUsageSummary,
};
use repo::Repository;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct ActorListReport {
pub output_kind: &'static str,
pub actors: Vec<ActorEntryReport>,
pub active_only: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActorShowReport {
pub actor: ActorEntryReport,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActorEntryReport {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_instance_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_actor_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_parent_actor_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_instance_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heddle_session_id: Option<String>,
pub thread: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
pub base_state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking_level: Option<String>,
pub usage_summary: AgentUsageSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_progress_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub report_flush_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attach_reason: Option<String>,
pub attach_precedence: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub winning_attach_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub probe_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub probe_confidence: Option<f32>,
pub status: String,
pub started_at: String,
pub actor_chain: Vec<ActorChainEntry>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ActorChainEntry {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_actor_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_parent_actor_key: Option<String>,
pub thread: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
}
impl From<ActorChainNode> for ActorChainEntry {
fn from(node: ActorChainNode) -> Self {
Self {
session_id: node.session_id,
native_actor_key: node.native_actor_key,
native_parent_actor_key: node.native_parent_actor_key,
thread: node.thread,
status: node.status.to_string(),
provider: node.provider,
model: node.model,
harness: node.harness,
}
}
}
impl From<&ActorPresence> for ActorEntryReport {
fn from(entry: &ActorPresence) -> Self {
Self {
session_id: entry.session_id.clone(),
client_instance_id: entry.client_instance_id.clone(),
native_actor_key: entry.native_actor_key.clone(),
native_parent_actor_key: entry.native_parent_actor_key.clone(),
native_instance_key: entry.native_instance_key.clone(),
heddle_session_id: entry.heddle_session_id.clone(),
thread: entry.thread.clone(),
thread_id: entry.thread_id.clone(),
base_state: entry.base_state.clone(),
path: entry.path.as_ref().map(|path| path.display().to_string()),
provider: entry.provider.clone(),
model: entry.model.clone(),
harness: entry.harness.clone(),
thinking_level: entry.thinking_level.clone(),
usage_summary: entry.usage_summary.clone(),
last_progress_at: entry.last_progress_at.map(|ts| ts.to_rfc3339()),
report_flush_state: entry.report_flush_state.clone(),
attach_reason: entry.attach_reason.clone(),
attach_precedence: entry.attach_precedence.clone(),
winning_attach_rule: entry.winning_attach_rule.clone(),
probe_source: entry.probe_source.clone(),
probe_confidence: entry.probe_confidence,
status: entry.status.to_string(),
started_at: entry.started_at.to_rfc3339(),
actor_chain: vec![],
}
}
}
impl ActorEntryReport {
pub fn with_chain(mut self, chain: Vec<ActorChainNode>) -> Self {
self.actor_chain = chain.into_iter().map(ActorChainEntry::from).collect();
self
}
}
pub fn filter_actors(entries: Vec<ActorPresence>, active_only: bool) -> Vec<ActorPresence> {
if !active_only {
return entries;
}
entries
.into_iter()
.filter(|entry| entry.status == ActorPresenceStatus::Active)
.collect()
}
pub fn filter_actors_ref<'a>(
entries: impl IntoIterator<Item = &'a ActorPresence>,
active_only: bool,
) -> Vec<&'a ActorPresence> {
entries
.into_iter()
.filter(|entry| !active_only || entry.status == ActorPresenceStatus::Active)
.collect()
}
pub fn list_actors(repo: &Repository, active_only: bool) -> Result<ActorListReport> {
let registry = ActorPresenceStore::new(repo.heddle_dir());
list_actors_from_registry(®istry, active_only)
}
pub fn list_actors_from_registry(
registry: &ActorPresenceStore,
active_only: bool,
) -> Result<ActorListReport> {
let entries = registry.current_entries()?;
let entries = filter_actors(entries, active_only);
Ok(ActorListReport {
output_kind: "actor_list",
actors: entries.iter().map(ActorEntryReport::from).collect(),
active_only,
})
}
pub fn assemble_actor_entry(
registry: &ActorPresenceStore,
entry: &ActorPresence,
) -> Result<ActorEntryReport> {
let chain = registry.actor_chain_for_session(&entry.session_id)?;
Ok(ActorEntryReport::from(entry).with_chain(chain))
}
pub fn show_actor_by_session(
repo: &Repository,
session_id: &str,
) -> Result<Option<ActorShowReport>> {
let registry = ActorPresenceStore::new(repo.heddle_dir());
let Some(entry) = registry.load(session_id)? else {
return Ok(None);
};
Ok(Some(ActorShowReport {
actor: assemble_actor_entry(®istry, &entry)?,
}))
}
pub fn show_actor_from_entry(
registry: &ActorPresenceStore,
entry: &ActorPresence,
) -> Result<ActorShowReport> {
Ok(ActorShowReport {
actor: assemble_actor_entry(registry, entry)?,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActorDoneOptions {
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActorDonePlan {
pub session_id: String,
pub thread: String,
pub status: ActorPresenceStatus,
}
pub fn plan_actor_done(entry: &ActorPresence) -> ActorDonePlan {
ActorDonePlan {
session_id: entry.session_id.clone(),
thread: entry.thread.clone(),
status: ActorPresenceStatus::Complete,
}
}
pub fn complete_actor_entry(
mut entry: ActorPresence,
completed_at: DateTime<Utc>,
) -> ActorPresence {
entry.status = ActorPresenceStatus::Complete;
entry.completed_at = Some(completed_at);
entry
}
pub fn mark_actor_done(registry: &ActorPresenceStore, session_id: &str) -> Result<()> {
registry.update_status(session_id, ActorPresenceStatus::Complete)?;
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use objects::store::{ActorPresence, ActorPresenceStatus, AgentUsageSummary};
use tempfile::TempDir;
use super::*;
fn sample_entry(session_id: &str, status: ActorPresenceStatus, thread: &str) -> ActorPresence {
ActorPresence {
session_id: session_id.to_string(),
client_instance_id: None,
native_actor_key: None,
native_parent_actor_key: None,
native_instance_key: None,
heddle_session_id: None,
thread_id: None,
thread: thread.to_string(),
anchor_state: None,
anchor_root: None,
path: None,
base_state: "abc123".to_string(),
started_at: Utc::now(),
provider: Some("openai".to_string()),
model: Some("gpt-5".to_string()),
harness: Some("codex".to_string()),
thinking_level: None,
usage_summary: AgentUsageSummary::default(),
last_progress_at: None,
report_flush_state: None,
attach_reason: Some("test".to_string()),
task_assignment_id: None,
attach_precedence: vec!["explicit-actor-spawn".to_string()],
winning_attach_rule: Some("explicit-actor-spawn".to_string()),
probe_source: None,
probe_confidence: None,
status,
completed_at: None,
context_queries: vec![],
}
}
#[test]
fn filter_actors_active_only_keeps_active() {
let entries = vec![
sample_entry("a1", ActorPresenceStatus::Active, "t1"),
sample_entry("a2", ActorPresenceStatus::Complete, "t2"),
sample_entry("a3", ActorPresenceStatus::Active, "t3"),
sample_entry("a4", ActorPresenceStatus::Merged, "t4"),
];
let filtered = filter_actors(entries, true);
assert_eq!(filtered.len(), 2);
assert!(
filtered
.iter()
.all(|e| e.status == ActorPresenceStatus::Active)
);
}
#[test]
fn filter_actors_all_when_not_active_only() {
let entries = vec![
sample_entry("a1", ActorPresenceStatus::Active, "t1"),
sample_entry("a2", ActorPresenceStatus::Complete, "t2"),
];
let filtered = filter_actors(entries, false);
assert_eq!(filtered.len(), 2);
}
#[test]
fn entry_report_stable_json_field_names() {
let entry = sample_entry(
"agent-test",
ActorPresenceStatus::Active,
"actor/agent-test",
);
let report = ActorEntryReport::from(&entry);
let value = serde_json::to_value(&report).unwrap();
assert_eq!(value["session_id"], "agent-test");
assert_eq!(value["thread"], "actor/agent-test");
assert_eq!(value["status"], "active");
assert_eq!(value["base_state"], "abc123");
assert_eq!(value["provider"], "openai");
assert_eq!(value["model"], "gpt-5");
assert_eq!(value["harness"], "codex");
assert!(value["started_at"].is_string());
assert!(value["usage_summary"].is_object());
assert!(value["attach_precedence"].is_array());
assert!(value["actor_chain"].is_array());
assert_eq!(value["actor_chain"].as_array().unwrap().len(), 0);
}
#[test]
fn list_report_stable_json_field_names() {
let report = ActorListReport {
output_kind: "actor_list",
actors: vec![ActorEntryReport::from(&sample_entry(
"agent-test",
ActorPresenceStatus::Active,
"main",
))],
active_only: true,
};
let value = serde_json::to_value(&report).unwrap();
assert_eq!(value["output_kind"], "actor_list");
assert_eq!(value["active_only"], true);
assert!(value["actors"].is_array());
assert_eq!(value["actors"][0]["session_id"], "agent-test");
}
#[test]
fn list_actors_from_empty_registry() {
let temp = TempDir::new().unwrap();
let heddle_dir = temp.path().join(".heddle");
std::fs::create_dir_all(&heddle_dir).unwrap();
let registry = ActorPresenceStore::new(&heddle_dir);
let report = list_actors_from_registry(®istry, false).unwrap();
assert_eq!(report.output_kind, "actor_list");
assert!(report.actors.is_empty());
assert!(!report.active_only);
}
#[test]
fn list_actors_active_only_filters_registry() {
let temp = TempDir::new().unwrap();
let heddle_dir = temp.path().join(".heddle");
std::fs::create_dir_all(&heddle_dir).unwrap();
let registry = ActorPresenceStore::new(&heddle_dir);
registry
.save(&sample_entry(
"agent-active",
ActorPresenceStatus::Active,
"t-active",
))
.unwrap();
registry
.save(&sample_entry(
"agent-complete",
ActorPresenceStatus::Complete,
"t-complete",
))
.unwrap();
let all = list_actors_from_registry(®istry, false).unwrap();
assert_eq!(all.actors.len(), 2);
let active_only = list_actors_from_registry(®istry, true).unwrap();
assert_eq!(active_only.actors.len(), 1);
assert_eq!(active_only.actors[0].session_id, "agent-active");
assert_eq!(active_only.actors[0].status, "active");
assert!(active_only.active_only);
}
#[test]
fn with_chain_maps_nodes() {
let entry = sample_entry("leaf", ActorPresenceStatus::Active, "t-leaf");
let chain = vec![
ActorChainNode {
session_id: "root".to_string(),
native_actor_key: Some("root-key".to_string()),
native_parent_actor_key: None,
thread: "t-root".to_string(),
status: ActorPresenceStatus::Complete,
provider: None,
model: None,
harness: None,
},
ActorChainNode {
session_id: "leaf".to_string(),
native_actor_key: Some("leaf-key".to_string()),
native_parent_actor_key: Some("root-key".to_string()),
thread: "t-leaf".to_string(),
status: ActorPresenceStatus::Active,
provider: Some("openai".to_string()),
model: Some("gpt-5".to_string()),
harness: Some("codex".to_string()),
},
];
let report = ActorEntryReport::from(&entry).with_chain(chain);
assert_eq!(report.actor_chain.len(), 2);
assert_eq!(report.actor_chain[0].session_id, "root");
assert_eq!(report.actor_chain[0].status, "complete");
assert_eq!(
report.actor_chain[1].native_parent_actor_key.as_deref(),
Some("root-key")
);
}
#[test]
fn complete_actor_entry_sets_status_and_timestamp() {
let entry = sample_entry("agent-1", ActorPresenceStatus::Active, "t1");
let done_at = Utc::now();
let completed = complete_actor_entry(entry, done_at);
assert_eq!(completed.status, ActorPresenceStatus::Complete);
assert_eq!(completed.completed_at, Some(done_at));
}
#[test]
fn plan_actor_done_captures_session_and_thread() {
let entry = sample_entry("agent-1", ActorPresenceStatus::Active, "feature/x");
let plan = plan_actor_done(&entry);
assert_eq!(plan.session_id, "agent-1");
assert_eq!(plan.thread, "feature/x");
assert_eq!(plan.status, ActorPresenceStatus::Complete);
}
#[test]
fn mark_actor_done_updates_registry() {
let temp = TempDir::new().unwrap();
let heddle_dir = temp.path().join(".heddle");
std::fs::create_dir_all(&heddle_dir).unwrap();
let registry = ActorPresenceStore::new(&heddle_dir);
registry
.save(&sample_entry(
"agent-active",
ActorPresenceStatus::Active,
"t-active",
))
.unwrap();
mark_actor_done(®istry, "agent-active").unwrap();
let loaded = registry.load("agent-active").unwrap().unwrap();
assert_eq!(loaded.status, ActorPresenceStatus::Complete);
assert!(loaded.completed_at.is_some());
}
}