Skip to main content

heddle_core/
actor.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Actor presence reports and completion planning.
3//!
4//! Owns:
5//! - listing registry entries into a typed report with stable JSON field names
6//! - pure active-only (and related) filters over [`ActorPresence`] slices
7//! - assembling a single actor entry plus ancestry chain for presence JSON
8//! - pure [`complete_actor_entry`] / [`plan_actor_done`] and thin
9//!   [`mark_actor_done`] for `presence complete`
10//!
11//! Human/JSON rendering and implicit session resolution (current-lane / path /
12//! any-active fallbacks) stay CLI-owned because they couple to recovery advice
13//! and checkout state. Thread-ref creation on mint also stays CLI-owned.
14
15use anyhow::Result;
16use chrono::{DateTime, Utc};
17use objects::store::{
18    ActorChainNode, ActorPresence, ActorPresenceStatus, ActorPresenceStore, AgentUsageSummary,
19};
20use repo::Repository;
21use serde::Serialize;
22
23/// Machine JSON for `heddle presence list` domain fields (stable field names).
24///
25/// CLI may wrap this with a `verification` envelope; domain fields here match
26/// the public `actor_list` contract (`output_kind`, `actors`, `active_only`).
27#[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/// Machine JSON for a single actor-presence payload.
35///
36/// Domain portion only — CLI supplies `output_kind` and `verification` when
37/// rendering machine output.
38#[derive(Debug, Clone, Serialize)]
39pub struct ActorShowReport {
40    pub actor: ActorEntryReport,
41}
42
43/// One actor registry entry as surfaced by list/show machine JSON.
44///
45/// Field names match the existing CLI contract (`session_id`, `thread`,
46/// `status`, `actor_chain`, …).
47#[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/// One hop in an actor ancestry chain for machine JSON.
94#[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    /// Attach an ancestry chain (root → leaf) to this entry report.
160    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
166/// Pure filter: when `active_only`, retain only [`ActorPresenceStatus::Active`] entries.
167pub 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
177/// Pure filter over a borrowed slice (for callers that already hold entries).
178pub 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
188/// List actors from the repository agent registry into a typed report.
189///
190/// Applies the active-only filter when requested. Does not attach verification
191/// or render human text — CLI owns the envelope and presentation.
192pub fn list_actors(repo: &Repository, active_only: bool) -> Result<ActorListReport> {
193    let registry = ActorPresenceStore::new(repo.heddle_dir());
194    list_actors_from_registry(&registry, active_only)
195}
196
197/// List actors from an already-opened [`ActorPresenceStore`].
198pub 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
211/// Assemble a single actor entry plus ancestry chain (pure relative to I/O).
212///
213/// Used by `presence show` machine JSON after the caller has resolved
214/// which registry entry to surface.
215pub 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
223/// Load and assemble one actor by explicit session id.
224///
225/// Returns `Ok(None)` when the session is not in the registry. Does **not**
226/// perform implicit current-lane / path / any-active resolution — that stays
227/// CLI-owned (recovery advice + checkout predicates).
228pub 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(&registry, &entry)?,
238    }))
239}
240
241/// Assemble show payload from a resolved entry (after CLI implicit resolve).
242pub 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// ---------------------------------------------------------------------------
252// Done planning
253// ---------------------------------------------------------------------------
254
255/// Caller inputs for `actor done` planning (identity already resolved).
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct ActorDoneOptions {
258    pub session_id: String,
259}
260
261/// Pure plan describing the done transition for machine/human output.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct ActorDonePlan {
264    pub session_id: String,
265    pub thread: String,
266    /// Always [`ActorPresenceStatus::Complete`] for a successful done plan.
267    pub status: ActorPresenceStatus,
268}
269
270/// Build a done plan from a resolved registry entry (no I/O).
271pub 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
279/// Pure status transition for `actor done`: mark complete with a timestamp.
280pub 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
289/// Mark an actor complete in the registry (thin store mutation).
290pub 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(&registry, 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(&registry, false).unwrap();
440        assert_eq!(all.actors.len(), 2);
441
442        let active_only = list_actors_from_registry(&registry, 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(&registry, "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}