Skip to main content

heddle_core/
agent_ops.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure agent reservation API helpers (non-fanout verbs).
3//!
4//! Owns:
5//! - `agent capture` option/plan validation and thread-ownership checks
6//! - `agent ready` plan assembly from a resolved active reservation
7//! - `agent list` filter + writer lease report assembly
8//! - attach/explain field assembly from registry facts
9//! - pure heartbeat / release status transitions
10//!
11//! Registry I/O, recovery advice, harness probing, and human/JSON render stay
12//! CLI-owned.
13
14use objects::store::{ActorPresence, WriterLease, WriterLeaseStatus};
15use serde::Serialize;
16
17// ---------------------------------------------------------------------------
18// Capture plan / thread check
19// ---------------------------------------------------------------------------
20
21/// Caller-supplied `agent capture` options (CLI surface, no I/O).
22#[derive(Debug, Clone, PartialEq)]
23pub struct AgentCaptureOptions {
24    pub lease: String,
25    pub message: Option<String>,
26    pub confidence: Option<f32>,
27}
28
29/// Validated capture plan after pure option preflight.
30#[derive(Debug, Clone, PartialEq)]
31pub struct AgentCapturePlan {
32    pub lease: String,
33    pub message: Option<String>,
34    pub confidence: Option<f32>,
35}
36
37/// Failures from pure agent capture option validation.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum AgentCapturePlanError {
40    EmptyLease,
41    /// Confidence was not a finite value in `0.0..=1.0`.
42    InvalidConfidence {
43        value: String,
44    },
45}
46
47impl std::fmt::Display for AgentCapturePlanError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::EmptyLease => write!(f, "agent capture requires a non-empty --lease"),
51            Self::InvalidConfidence { value } => write!(
52                f,
53                "confidence must be a finite number from 0.0 to 1.0, got `{value}`"
54            ),
55        }
56    }
57}
58
59impl std::error::Error for AgentCapturePlanError {}
60
61/// Pure preflight for `heddle agent capture` options (no registry I/O).
62pub fn plan_agent_capture(
63    options: &AgentCaptureOptions,
64) -> Result<AgentCapturePlan, AgentCapturePlanError> {
65    let lease = require_nonempty_lease(&options.lease)?;
66    let confidence = normalize_confidence(options.confidence)?;
67    Ok(AgentCapturePlan {
68        lease,
69        message: nonempty_optional_string(options.message.clone()),
70        confidence,
71    })
72}
73
74/// Outcome of comparing the reservation's thread to the current checkout lane.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum AgentCaptureThreadCheck {
77    /// No current lane, or current lane matches the reserved thread.
78    Ok,
79    /// Checkout is attached to a different thread than the reservation owns.
80    Mismatch {
81        reserved_thread: String,
82        current_thread: String,
83    },
84}
85
86/// Pure thread-ownership check for session-guarded capture.
87///
88/// When `current_lane` is `None` (detached), capture is allowed — matching the
89/// historical CLI: only an attached mismatched lane fails closed.
90pub fn check_agent_capture_thread(
91    reserved_thread: &str,
92    current_lane: Option<&str>,
93) -> AgentCaptureThreadCheck {
94    match current_lane.map(str::trim).filter(|s| !s.is_empty()) {
95        Some(current) if current != reserved_thread => AgentCaptureThreadCheck::Mismatch {
96            reserved_thread: reserved_thread.to_string(),
97            current_thread: current.to_string(),
98        },
99        _ => AgentCaptureThreadCheck::Ok,
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Ready plan
105// ---------------------------------------------------------------------------
106
107/// Caller-supplied `agent ready` options (CLI surface, no I/O).
108#[derive(Debug, Clone, PartialEq)]
109pub struct AgentReadyOptions {
110    pub lease: String,
111    pub message: Option<String>,
112    pub confidence: Option<f32>,
113}
114
115/// Ready plan after pure option preflight + reservation facts.
116///
117/// The CLI still enforces active-session I/O; this only assembles the
118/// session-scoped ready payload (thread comes from the reservation entry).
119#[derive(Debug, Clone, PartialEq)]
120pub struct AgentReadyPlan {
121    pub lease: String,
122    pub thread: String,
123    pub message: Option<String>,
124    pub confidence: Option<f32>,
125}
126
127/// Failures from pure agent ready option validation.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum AgentReadyPlanError {
130    EmptyLease,
131    InvalidConfidence { value: String },
132}
133
134impl std::fmt::Display for AgentReadyPlanError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::EmptyLease => write!(f, "agent ready requires a non-empty --lease"),
138            Self::InvalidConfidence { value } => write!(
139                f,
140                "confidence must be a finite number from 0.0 to 1.0, got `{value}`"
141            ),
142        }
143    }
144}
145
146impl std::error::Error for AgentReadyPlanError {}
147
148/// Pure preflight for `heddle agent ready` from options + resolved entry facts.
149///
150pub fn plan_agent_ready(
151    lease: &WriterLease,
152    options: &AgentReadyOptions,
153) -> Result<AgentReadyPlan, AgentReadyPlanError> {
154    let lease_id = require_nonempty_lease(&options.lease).map_err(|err| match err {
155        AgentCapturePlanError::EmptyLease => AgentReadyPlanError::EmptyLease,
156        AgentCapturePlanError::InvalidConfidence { value } => {
157            AgentReadyPlanError::InvalidConfidence { value }
158        }
159    })?;
160    let confidence = normalize_confidence(options.confidence).map_err(|err| match err {
161        AgentCapturePlanError::EmptyLease => AgentReadyPlanError::EmptyLease,
162        AgentCapturePlanError::InvalidConfidence { value } => {
163            AgentReadyPlanError::InvalidConfidence { value }
164        }
165    })?;
166    Ok(AgentReadyPlan {
167        lease: lease_id,
168        thread: lease.thread.clone(),
169        message: nonempty_optional_string(options.message.clone()),
170        confidence,
171    })
172}
173
174// ---------------------------------------------------------------------------
175// List filter + reservation report assembly
176// ---------------------------------------------------------------------------
177
178/// Machine JSON domain fields for one reservation (stable field names).
179///
180/// Mirrors the public `agent list` / reservation envelope body without the
181/// verification wrapper. `task` is the registry `attach_reason` (CLI contract).
182#[derive(Debug, Clone, Serialize, PartialEq)]
183pub struct AgentReservationReport {
184    pub lease_id: String,
185    pub actor_session_id: Option<String>,
186    pub thread: String,
187    pub anchor_state: Option<String>,
188    pub anchor_root: Option<String>,
189    pub task_assignment_id: Option<String>,
190    pub status: String,
191    pub path: Option<String>,
192    pub heartbeat_at: String,
193    pub lease_expires_at: String,
194    pub liveness: String,
195}
196
197impl From<&WriterLease> for AgentReservationReport {
198    fn from(lease: &WriterLease) -> Self {
199        Self {
200            lease_id: lease.lease_id.clone(),
201            actor_session_id: lease.actor_session_id.clone(),
202            thread: lease.thread.clone(),
203            anchor_state: lease.anchor_state.clone(),
204            anchor_root: lease.anchor_root.clone(),
205            task_assignment_id: lease.task_assignment_id.clone(),
206            status: lease.status.to_string(),
207            path: lease.path.as_ref().map(|path| path.display().to_string()),
208            heartbeat_at: lease.heartbeat_at.to_rfc3339(),
209            lease_expires_at: lease.lease_expires_at().to_rfc3339(),
210            liveness: lease.liveness_at(chrono::Utc::now()).to_string(),
211        }
212    }
213}
214
215/// Assemble one reservation report from registry facts (pure).
216pub fn assemble_agent_reservation(lease: &WriterLease) -> AgentReservationReport {
217    AgentReservationReport::from(lease)
218}
219
220/// Domain list payload for `agent list` (no verification envelope).
221#[derive(Debug, Clone, Serialize, PartialEq)]
222pub struct AgentReservationListReport {
223    pub reservations: Vec<AgentReservationReport>,
224    pub alive_only: bool,
225    pub thread: Option<String>,
226}
227
228/// Pure filter for `agent list`: optional thread name + alive-only (Active).
229pub fn filter_agent_reservations(
230    entries: impl IntoIterator<Item = WriterLease>,
231    thread: Option<&str>,
232    alive_only: bool,
233) -> Vec<WriterLease> {
234    entries
235        .into_iter()
236        .filter(|lease| thread.is_none_or(|thread| lease.thread == thread))
237        .filter(|lease| !alive_only || lease.status == WriterLeaseStatus::Active)
238        .collect()
239}
240
241/// Pure filter over a borrowed slice.
242pub fn filter_agent_reservations_ref<'a>(
243    entries: impl IntoIterator<Item = &'a WriterLease>,
244    thread: Option<&str>,
245    alive_only: bool,
246) -> Vec<&'a WriterLease> {
247    entries
248        .into_iter()
249        .filter(|lease| thread.is_none_or(|thread| lease.thread == thread))
250        .filter(|lease| !alive_only || lease.status == WriterLeaseStatus::Active)
251        .collect()
252}
253
254/// Filter entries and assemble the list report domain fields.
255pub fn assemble_agent_reservation_list(
256    entries: impl IntoIterator<Item = WriterLease>,
257    thread: Option<String>,
258    alive_only: bool,
259) -> AgentReservationListReport {
260    let filtered = filter_agent_reservations(entries, thread.as_deref(), alive_only);
261    AgentReservationListReport {
262        reservations: filtered.iter().map(assemble_agent_reservation).collect(),
263        alive_only,
264        thread,
265    }
266}
267
268// ---------------------------------------------------------------------------
269// Explain assembly from ActorPresence facts
270// ---------------------------------------------------------------------------
271
272/// Pure attach/explain report built from registry facts.
273///
274/// Field names align with actor-explain style presentation (`attach_reason`,
275/// `winning_rule`, probe identity) without harness detection or verification.
276#[derive(Debug, Clone, Serialize, PartialEq)]
277pub struct AgentExplainReport {
278    pub session_id: String,
279    pub thread: String,
280    pub status: String,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub heddle_session_id: Option<String>,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub client_instance_id: Option<String>,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub native_actor_key: Option<String>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub native_parent_actor_key: Option<String>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub native_instance_key: Option<String>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub probe_source: Option<String>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub probe_confidence: Option<f32>,
295    pub attach_reason: String,
296    #[serde(skip_serializing_if = "Vec::is_empty")]
297    pub attach_precedence: Vec<String>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub winning_rule: Option<String>,
300}
301
302/// Default attach-reason when the registry entry has none persisted.
303pub fn default_attach_reason_message() -> &'static str {
304    "no persisted attach reason is available for this agent"
305}
306
307/// Assemble explain fields from an [`ActorPresence`] (pure, no I/O).
308pub fn assemble_agent_explain(entry: &ActorPresence) -> AgentExplainReport {
309    let attach_reason = entry
310        .attach_reason
311        .clone()
312        .filter(|s| !s.trim().is_empty())
313        .unwrap_or_else(|| default_attach_reason_message().to_string());
314    AgentExplainReport {
315        session_id: entry.session_id.clone(),
316        thread: entry.thread.clone(),
317        status: entry.status.to_string(),
318        heddle_session_id: entry.heddle_session_id.clone(),
319        client_instance_id: entry.client_instance_id.clone(),
320        native_actor_key: entry.native_actor_key.clone(),
321        native_parent_actor_key: entry.native_parent_actor_key.clone(),
322        native_instance_key: entry.native_instance_key.clone(),
323        probe_source: entry.probe_source.clone(),
324        probe_confidence: entry.probe_confidence,
325        attach_reason,
326        attach_precedence: entry.attach_precedence.clone(),
327        winning_rule: entry.winning_attach_rule.clone(),
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Shared option helpers
333// ---------------------------------------------------------------------------
334
335fn require_nonempty_lease(lease: &str) -> Result<String, AgentCapturePlanError> {
336    let trimmed = lease.trim();
337    if trimmed.is_empty() {
338        Err(AgentCapturePlanError::EmptyLease)
339    } else {
340        Ok(trimmed.to_string())
341    }
342}
343
344fn normalize_confidence(confidence: Option<f32>) -> Result<Option<f32>, AgentCapturePlanError> {
345    match confidence {
346        None => Ok(None),
347        Some(value) if value.is_finite() && (0.0..=1.0).contains(&value) => Ok(Some(value)),
348        Some(value) => Err(AgentCapturePlanError::InvalidConfidence {
349            value: value.to_string(),
350        }),
351    }
352}
353
354fn nonempty_optional_string(value: Option<String>) -> Option<String> {
355    value.and_then(|v| {
356        let trimmed = v.trim();
357        if trimmed.is_empty() {
358            None
359        } else {
360            Some(trimmed.to_string())
361        }
362    })
363}
364
365#[cfg(test)]
366mod tests {
367    use chrono::Utc;
368    use objects::store::WriterLeaseStatus;
369
370    use super::*;
371
372    fn lease() -> WriterLease {
373        let now = Utc::now();
374        WriterLease {
375            lease_id: "lease-one".to_string(),
376            thread: "feature/a".to_string(),
377            actor_session_id: Some("agent-one".to_string()),
378            task_assignment_id: None,
379            anchor_state: Some("hd-state".to_string()),
380            anchor_root: Some("root".to_string()),
381            path: None,
382            token_hash: "hash".to_string(),
383            pid: None,
384            boot_id: None,
385            heartbeat_at: now,
386            started_at: now,
387            status: WriterLeaseStatus::Active,
388            completed_at: None,
389        }
390    }
391
392    #[test]
393    fn capture_plan_requires_a_lease_id() {
394        let error = plan_agent_capture(&AgentCaptureOptions {
395            lease: " ".to_string(),
396            message: None,
397            confidence: None,
398        })
399        .unwrap_err();
400        assert_eq!(error, AgentCapturePlanError::EmptyLease);
401    }
402
403    #[test]
404    fn ready_plan_uses_the_leased_thread() {
405        let plan = plan_agent_ready(
406            &lease(),
407            &AgentReadyOptions {
408                lease: "lease-one".to_string(),
409                message: Some("ready".to_string()),
410                confidence: Some(0.9),
411            },
412        )
413        .unwrap();
414        assert_eq!(plan.thread, "feature/a");
415        assert_eq!(plan.lease, "lease-one");
416    }
417
418    #[test]
419    fn reservation_report_never_contains_token_material() {
420        let value = serde_json::to_value(assemble_agent_reservation(&lease())).unwrap();
421        assert!(value.get("token").is_none());
422        assert!(value.get("token_hash").is_none());
423        assert_eq!(value["lease_id"], "lease-one");
424    }
425}