Skip to main content

verbs/
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 repo::{
18    ActorChainNode, ActorPresence, ActorPresenceStatus, ActorPresenceStore, AgentUsageSummary,
19    Repository,
20};
21use schemars::JsonSchema;
22use serde::Serialize;
23
24/// Machine JSON for `heddle agent presence list` domain fields (stable field names).
25///
26/// CLI may wrap this with a `verification` envelope; domain fields here match
27/// the public `actor_list` contract (`output_kind`, `actors`, `active_only`).
28#[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/// Machine JSON for a single actor-presence payload.
36///
37/// Domain portion only — CLI supplies `output_kind` and `verification` when
38/// rendering machine output.
39#[derive(Debug, Clone, Serialize, JsonSchema)]
40pub struct ActorShowReport {
41    pub actor: ActorEntryReport,
42}
43
44/// One actor registry entry as surfaced by list/show machine JSON.
45///
46/// Field names match the existing CLI contract (`session_id`, `thread`,
47/// `status`, `actor_chain`, …).
48#[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/// One hop in an actor ancestry chain for machine JSON.
95#[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    /// Attach an ancestry chain (root → leaf) to this entry report.
161    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
167/// Pure filter: when `active_only`, retain only [`ActorPresenceStatus::Active`] entries.
168pub 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
178/// Pure filter over a borrowed slice (for callers that already hold entries).
179pub 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
189/// List actors from the repository agent registry into a typed report.
190///
191/// Applies the active-only filter when requested. Does not attach verification
192/// or render human text — CLI owns the envelope and presentation.
193pub fn list_actors(repo: &Repository, active_only: bool) -> Result<ActorListReport> {
194    let registry = ActorPresenceStore::new(repo.heddle_dir());
195    list_actors_from_registry(&registry, active_only)
196}
197
198/// List actors from an already-opened [`ActorPresenceStore`].
199pub 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
212/// Assemble a single actor entry plus ancestry chain (pure relative to I/O).
213///
214/// Used by `presence show` machine JSON after the caller has resolved
215/// which registry entry to surface.
216pub 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
224/// Load and assemble one actor by explicit session id.
225///
226/// Returns `Ok(None)` when the session is not in the registry. Does **not**
227/// perform implicit current-lane / path / any-active resolution — that stays
228/// CLI-owned (recovery advice + checkout predicates).
229pub 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(&registry, &entry)?,
239    }))
240}
241
242/// Assemble show payload from a resolved entry (after CLI implicit resolve).
243pub 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// ---------------------------------------------------------------------------
253// Done planning
254// ---------------------------------------------------------------------------
255
256/// Caller inputs for `actor done` planning (identity already resolved).
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct ActorDoneOptions {
259    pub session_id: String,
260}
261
262/// Pure plan describing the done transition for machine/human output.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct ActorDonePlan {
265    pub session_id: String,
266    pub thread: String,
267    /// Always [`ActorPresenceStatus::Complete`] for a successful done plan.
268    pub status: ActorPresenceStatus,
269}
270
271/// Build a done plan from a resolved registry entry (no I/O).
272pub 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
280/// Pure status transition for `actor done`: mark complete with a timestamp.
281pub 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
290/// Mark an actor complete in the registry (thin store mutation).
291pub 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(&registry, 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(&registry, false).unwrap();
441        assert_eq!(all.actors.len(), 2);
442
443        let active_only = list_actors_from_registry(&registry, 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(&registry, "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}