Skip to main content

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