1use objects::store::{ActorPresence, WriterLease, WriterLeaseStatus};
15use serde::Serialize;
16
17#[derive(Debug, Clone, PartialEq)]
23pub struct AgentCaptureOptions {
24 pub lease: String,
25 pub message: Option<String>,
26 pub confidence: Option<f32>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct AgentCapturePlan {
32 pub lease: String,
33 pub message: Option<String>,
34 pub confidence: Option<f32>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum AgentCapturePlanError {
40 EmptyLease,
41 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
61pub 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#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum AgentCaptureThreadCheck {
77 Ok,
79 Mismatch {
81 reserved_thread: String,
82 current_thread: String,
83 },
84}
85
86pub 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#[derive(Debug, Clone, PartialEq)]
109pub struct AgentReadyOptions {
110 pub lease: String,
111 pub message: Option<String>,
112 pub confidence: Option<f32>,
113}
114
115#[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#[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
148pub 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#[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
215pub fn assemble_agent_reservation(lease: &WriterLease) -> AgentReservationReport {
217 AgentReservationReport::from(lease)
218}
219
220#[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
228pub 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
241pub 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
254pub 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#[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
302pub fn default_attach_reason_message() -> &'static str {
304 "no persisted attach reason is available for this agent"
305}
306
307pub 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
331fn 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}