1use objects::store::{WriterLease, WriterLeaseStatus};
15use repo::ActorPresence;
16use serde::Serialize;
17
18#[derive(Debug, Clone, PartialEq)]
24pub struct AgentCaptureOptions {
25 pub lease: String,
26 pub message: Option<String>,
27 pub confidence: Option<f32>,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct AgentCapturePlan {
33 pub lease: String,
34 pub message: Option<String>,
35 pub confidence: Option<f32>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum AgentCapturePlanError {
41 EmptyLease,
42 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
62pub 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#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum AgentCaptureThreadCheck {
78 Ok,
80 Mismatch {
82 reserved_thread: String,
83 current_thread: String,
84 },
85}
86
87pub 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#[derive(Debug, Clone, PartialEq)]
110pub struct AgentReadyOptions {
111 pub lease: String,
112 pub message: Option<String>,
113 pub confidence: Option<f32>,
114}
115
116#[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#[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
149pub 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#[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
216pub fn assemble_agent_reservation(lease: &WriterLease) -> AgentReservationReport {
218 AgentReservationReport::from(lease)
219}
220
221#[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
229pub 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
242pub 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
255pub 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#[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
303pub fn default_attach_reason_message() -> &'static str {
305 "no persisted attach reason is available for this agent"
306}
307
308pub 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
332fn 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}