1use anyhow::Result;
16use chrono::{DateTime, Utc};
17use objects::store::{
18 ActorChainNode, ActorPresence, ActorPresenceStatus, ActorPresenceStore, AgentUsageSummary,
19};
20use repo::Repository;
21use serde::Serialize;
22
23#[derive(Debug, Clone, Serialize)]
28pub struct ActorListReport {
29 pub output_kind: &'static str,
30 pub actors: Vec<ActorEntryReport>,
31 pub active_only: bool,
32}
33
34#[derive(Debug, Clone, Serialize)]
39pub struct ActorShowReport {
40 pub actor: ActorEntryReport,
41}
42
43#[derive(Debug, Clone, Serialize)]
48pub struct ActorEntryReport {
49 pub session_id: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub client_instance_id: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub native_actor_key: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub native_parent_actor_key: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub native_instance_key: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub heddle_session_id: Option<String>,
60 pub thread: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub thread_id: Option<String>,
63 pub base_state: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub path: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub provider: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub model: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub harness: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub thinking_level: Option<String>,
74 pub usage_summary: AgentUsageSummary,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub last_progress_at: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub report_flush_state: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub attach_reason: Option<String>,
81 pub attach_precedence: Vec<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub winning_attach_rule: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub probe_source: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub probe_confidence: Option<f32>,
88 pub status: String,
89 pub started_at: String,
90 pub actor_chain: Vec<ActorChainEntry>,
91}
92
93#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
95pub struct ActorChainEntry {
96 pub session_id: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub native_actor_key: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub native_parent_actor_key: Option<String>,
101 pub thread: String,
102 pub status: String,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub provider: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub model: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub harness: Option<String>,
109}
110
111impl From<ActorChainNode> for ActorChainEntry {
112 fn from(node: ActorChainNode) -> Self {
113 Self {
114 session_id: node.session_id,
115 native_actor_key: node.native_actor_key,
116 native_parent_actor_key: node.native_parent_actor_key,
117 thread: node.thread,
118 status: node.status.to_string(),
119 provider: node.provider,
120 model: node.model,
121 harness: node.harness,
122 }
123 }
124}
125
126impl From<&ActorPresence> for ActorEntryReport {
127 fn from(entry: &ActorPresence) -> Self {
128 Self {
129 session_id: entry.session_id.clone(),
130 client_instance_id: entry.client_instance_id.clone(),
131 native_actor_key: entry.native_actor_key.clone(),
132 native_parent_actor_key: entry.native_parent_actor_key.clone(),
133 native_instance_key: entry.native_instance_key.clone(),
134 heddle_session_id: entry.heddle_session_id.clone(),
135 thread: entry.thread.clone(),
136 thread_id: entry.thread_id.clone(),
137 base_state: entry.base_state.clone(),
138 path: entry.path.as_ref().map(|path| path.display().to_string()),
139 provider: entry.provider.clone(),
140 model: entry.model.clone(),
141 harness: entry.harness.clone(),
142 thinking_level: entry.thinking_level.clone(),
143 usage_summary: entry.usage_summary.clone(),
144 last_progress_at: entry.last_progress_at.map(|ts| ts.to_rfc3339()),
145 report_flush_state: entry.report_flush_state.clone(),
146 attach_reason: entry.attach_reason.clone(),
147 attach_precedence: entry.attach_precedence.clone(),
148 winning_attach_rule: entry.winning_attach_rule.clone(),
149 probe_source: entry.probe_source.clone(),
150 probe_confidence: entry.probe_confidence,
151 status: entry.status.to_string(),
152 started_at: entry.started_at.to_rfc3339(),
153 actor_chain: vec![],
154 }
155 }
156}
157
158impl ActorEntryReport {
159 pub fn with_chain(mut self, chain: Vec<ActorChainNode>) -> Self {
161 self.actor_chain = chain.into_iter().map(ActorChainEntry::from).collect();
162 self
163 }
164}
165
166pub fn filter_actors(entries: Vec<ActorPresence>, active_only: bool) -> Vec<ActorPresence> {
168 if !active_only {
169 return entries;
170 }
171 entries
172 .into_iter()
173 .filter(|entry| entry.status == ActorPresenceStatus::Active)
174 .collect()
175}
176
177pub fn filter_actors_ref<'a>(
179 entries: impl IntoIterator<Item = &'a ActorPresence>,
180 active_only: bool,
181) -> Vec<&'a ActorPresence> {
182 entries
183 .into_iter()
184 .filter(|entry| !active_only || entry.status == ActorPresenceStatus::Active)
185 .collect()
186}
187
188pub fn list_actors(repo: &Repository, active_only: bool) -> Result<ActorListReport> {
193 let registry = ActorPresenceStore::new(repo.heddle_dir());
194 list_actors_from_registry(®istry, active_only)
195}
196
197pub fn list_actors_from_registry(
199 registry: &ActorPresenceStore,
200 active_only: bool,
201) -> Result<ActorListReport> {
202 let entries = registry.current_entries()?;
203 let entries = filter_actors(entries, active_only);
204 Ok(ActorListReport {
205 output_kind: "actor_list",
206 actors: entries.iter().map(ActorEntryReport::from).collect(),
207 active_only,
208 })
209}
210
211pub fn assemble_actor_entry(
216 registry: &ActorPresenceStore,
217 entry: &ActorPresence,
218) -> Result<ActorEntryReport> {
219 let chain = registry.actor_chain_for_session(&entry.session_id)?;
220 Ok(ActorEntryReport::from(entry).with_chain(chain))
221}
222
223pub fn show_actor_by_session(
229 repo: &Repository,
230 session_id: &str,
231) -> Result<Option<ActorShowReport>> {
232 let registry = ActorPresenceStore::new(repo.heddle_dir());
233 let Some(entry) = registry.load(session_id)? else {
234 return Ok(None);
235 };
236 Ok(Some(ActorShowReport {
237 actor: assemble_actor_entry(®istry, &entry)?,
238 }))
239}
240
241pub fn show_actor_from_entry(
243 registry: &ActorPresenceStore,
244 entry: &ActorPresence,
245) -> Result<ActorShowReport> {
246 Ok(ActorShowReport {
247 actor: assemble_actor_entry(registry, entry)?,
248 })
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct ActorDoneOptions {
258 pub session_id: String,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct ActorDonePlan {
264 pub session_id: String,
265 pub thread: String,
266 pub status: ActorPresenceStatus,
268}
269
270pub fn plan_actor_done(entry: &ActorPresence) -> ActorDonePlan {
272 ActorDonePlan {
273 session_id: entry.session_id.clone(),
274 thread: entry.thread.clone(),
275 status: ActorPresenceStatus::Complete,
276 }
277}
278
279pub fn complete_actor_entry(
281 mut entry: ActorPresence,
282 completed_at: DateTime<Utc>,
283) -> ActorPresence {
284 entry.status = ActorPresenceStatus::Complete;
285 entry.completed_at = Some(completed_at);
286 entry
287}
288
289pub fn mark_actor_done(registry: &ActorPresenceStore, session_id: &str) -> Result<()> {
291 registry.update_status(session_id, ActorPresenceStatus::Complete)?;
292 Ok(())
293}
294
295#[cfg(test)]
296mod tests {
297 use chrono::Utc;
298 use objects::store::{ActorPresence, ActorPresenceStatus, AgentUsageSummary};
299 use tempfile::TempDir;
300
301 use super::*;
302
303 fn sample_entry(session_id: &str, status: ActorPresenceStatus, thread: &str) -> ActorPresence {
304 ActorPresence {
305 session_id: session_id.to_string(),
306 client_instance_id: None,
307 native_actor_key: None,
308 native_parent_actor_key: None,
309 native_instance_key: None,
310 heddle_session_id: None,
311 thread_id: None,
312 thread: thread.to_string(),
313 anchor_state: None,
314 anchor_root: None,
315 path: None,
316 base_state: "abc123".to_string(),
317 started_at: Utc::now(),
318 provider: Some("openai".to_string()),
319 model: Some("gpt-5".to_string()),
320 harness: Some("codex".to_string()),
321 thinking_level: None,
322 usage_summary: AgentUsageSummary::default(),
323 last_progress_at: None,
324 report_flush_state: None,
325 attach_reason: Some("test".to_string()),
326 task_assignment_id: None,
327 attach_precedence: vec!["explicit-actor-spawn".to_string()],
328 winning_attach_rule: Some("explicit-actor-spawn".to_string()),
329 probe_source: None,
330 probe_confidence: None,
331 status,
332 completed_at: None,
333 context_queries: vec![],
334 }
335 }
336
337 #[test]
338 fn filter_actors_active_only_keeps_active() {
339 let entries = vec![
340 sample_entry("a1", ActorPresenceStatus::Active, "t1"),
341 sample_entry("a2", ActorPresenceStatus::Complete, "t2"),
342 sample_entry("a3", ActorPresenceStatus::Active, "t3"),
343 sample_entry("a4", ActorPresenceStatus::Merged, "t4"),
344 ];
345 let filtered = filter_actors(entries, true);
346 assert_eq!(filtered.len(), 2);
347 assert!(
348 filtered
349 .iter()
350 .all(|e| e.status == ActorPresenceStatus::Active)
351 );
352 }
353
354 #[test]
355 fn filter_actors_all_when_not_active_only() {
356 let entries = vec![
357 sample_entry("a1", ActorPresenceStatus::Active, "t1"),
358 sample_entry("a2", ActorPresenceStatus::Complete, "t2"),
359 ];
360 let filtered = filter_actors(entries, false);
361 assert_eq!(filtered.len(), 2);
362 }
363
364 #[test]
365 fn entry_report_stable_json_field_names() {
366 let entry = sample_entry(
367 "agent-test",
368 ActorPresenceStatus::Active,
369 "actor/agent-test",
370 );
371 let report = ActorEntryReport::from(&entry);
372 let value = serde_json::to_value(&report).unwrap();
373 assert_eq!(value["session_id"], "agent-test");
374 assert_eq!(value["thread"], "actor/agent-test");
375 assert_eq!(value["status"], "active");
376 assert_eq!(value["base_state"], "abc123");
377 assert_eq!(value["provider"], "openai");
378 assert_eq!(value["model"], "gpt-5");
379 assert_eq!(value["harness"], "codex");
380 assert!(value["started_at"].is_string());
381 assert!(value["usage_summary"].is_object());
382 assert!(value["attach_precedence"].is_array());
383 assert!(value["actor_chain"].is_array());
384 assert_eq!(value["actor_chain"].as_array().unwrap().len(), 0);
385 }
386
387 #[test]
388 fn list_report_stable_json_field_names() {
389 let report = ActorListReport {
390 output_kind: "actor_list",
391 actors: vec![ActorEntryReport::from(&sample_entry(
392 "agent-test",
393 ActorPresenceStatus::Active,
394 "main",
395 ))],
396 active_only: true,
397 };
398 let value = serde_json::to_value(&report).unwrap();
399 assert_eq!(value["output_kind"], "actor_list");
400 assert_eq!(value["active_only"], true);
401 assert!(value["actors"].is_array());
402 assert_eq!(value["actors"][0]["session_id"], "agent-test");
403 }
404
405 #[test]
406 fn list_actors_from_empty_registry() {
407 let temp = TempDir::new().unwrap();
408 let heddle_dir = temp.path().join(".heddle");
409 std::fs::create_dir_all(&heddle_dir).unwrap();
410 let registry = ActorPresenceStore::new(&heddle_dir);
411 let report = list_actors_from_registry(®istry, false).unwrap();
412 assert_eq!(report.output_kind, "actor_list");
413 assert!(report.actors.is_empty());
414 assert!(!report.active_only);
415 }
416
417 #[test]
418 fn list_actors_active_only_filters_registry() {
419 let temp = TempDir::new().unwrap();
420 let heddle_dir = temp.path().join(".heddle");
421 std::fs::create_dir_all(&heddle_dir).unwrap();
422 let registry = ActorPresenceStore::new(&heddle_dir);
423
424 registry
425 .save(&sample_entry(
426 "agent-active",
427 ActorPresenceStatus::Active,
428 "t-active",
429 ))
430 .unwrap();
431 registry
432 .save(&sample_entry(
433 "agent-complete",
434 ActorPresenceStatus::Complete,
435 "t-complete",
436 ))
437 .unwrap();
438
439 let all = list_actors_from_registry(®istry, false).unwrap();
440 assert_eq!(all.actors.len(), 2);
441
442 let active_only = list_actors_from_registry(®istry, true).unwrap();
443 assert_eq!(active_only.actors.len(), 1);
444 assert_eq!(active_only.actors[0].session_id, "agent-active");
445 assert_eq!(active_only.actors[0].status, "active");
446 assert!(active_only.active_only);
447 }
448
449 #[test]
450 fn with_chain_maps_nodes() {
451 let entry = sample_entry("leaf", ActorPresenceStatus::Active, "t-leaf");
452 let chain = vec![
453 ActorChainNode {
454 session_id: "root".to_string(),
455 native_actor_key: Some("root-key".to_string()),
456 native_parent_actor_key: None,
457 thread: "t-root".to_string(),
458 status: ActorPresenceStatus::Complete,
459 provider: None,
460 model: None,
461 harness: None,
462 },
463 ActorChainNode {
464 session_id: "leaf".to_string(),
465 native_actor_key: Some("leaf-key".to_string()),
466 native_parent_actor_key: Some("root-key".to_string()),
467 thread: "t-leaf".to_string(),
468 status: ActorPresenceStatus::Active,
469 provider: Some("openai".to_string()),
470 model: Some("gpt-5".to_string()),
471 harness: Some("codex".to_string()),
472 },
473 ];
474 let report = ActorEntryReport::from(&entry).with_chain(chain);
475 assert_eq!(report.actor_chain.len(), 2);
476 assert_eq!(report.actor_chain[0].session_id, "root");
477 assert_eq!(report.actor_chain[0].status, "complete");
478 assert_eq!(
479 report.actor_chain[1].native_parent_actor_key.as_deref(),
480 Some("root-key")
481 );
482 }
483
484 #[test]
485 fn complete_actor_entry_sets_status_and_timestamp() {
486 let entry = sample_entry("agent-1", ActorPresenceStatus::Active, "t1");
487 let done_at = Utc::now();
488 let completed = complete_actor_entry(entry, done_at);
489 assert_eq!(completed.status, ActorPresenceStatus::Complete);
490 assert_eq!(completed.completed_at, Some(done_at));
491 }
492
493 #[test]
494 fn plan_actor_done_captures_session_and_thread() {
495 let entry = sample_entry("agent-1", ActorPresenceStatus::Active, "feature/x");
496 let plan = plan_actor_done(&entry);
497 assert_eq!(plan.session_id, "agent-1");
498 assert_eq!(plan.thread, "feature/x");
499 assert_eq!(plan.status, ActorPresenceStatus::Complete);
500 }
501
502 #[test]
503 fn mark_actor_done_updates_registry() {
504 let temp = TempDir::new().unwrap();
505 let heddle_dir = temp.path().join(".heddle");
506 std::fs::create_dir_all(&heddle_dir).unwrap();
507 let registry = ActorPresenceStore::new(&heddle_dir);
508 registry
509 .save(&sample_entry(
510 "agent-active",
511 ActorPresenceStatus::Active,
512 "t-active",
513 ))
514 .unwrap();
515 mark_actor_done(®istry, "agent-active").unwrap();
516 let loaded = registry.load("agent-active").unwrap().unwrap();
517 assert_eq!(loaded.status, ActorPresenceStatus::Complete);
518 assert!(loaded.completed_at.is_some());
519 }
520}