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