1use serde::{Deserialize, Serialize};
2use time::{Duration, OffsetDateTime};
3
4use super::{AgentExecutionStatus, AgentLiveness, AgentStatusSource, AgentUsageKind};
5use crate::error::DomainError;
6use crate::value_objects::{
7 CeremonyAgentExecutionId, CeremonyId, ExecutionOperationId, HostAgentIncarnation, LeaseOwnerId,
8 LogicalWorkerId, RoleId, StepClaimFence, StepId,
9};
10
11const MAX_STATUS_TEXT: usize = 512;
12const MAX_EVIDENCE_REFERENCES: usize = 20;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct CeremonyAgentStatus {
17 ceremony_id: CeremonyId,
18 agent_execution_id: CeremonyAgentExecutionId,
19 operation_id: ExecutionOperationId,
20 claim_owner_id: LeaseOwnerId,
21 logical_worker_id: LogicalWorkerId,
22 host_agent_id: LeaseOwnerId,
23 host_agent_incarnation: HostAgentIncarnation,
24 previous_host_agent_id: Option<LeaseOwnerId>,
25 previous_host_agent_incarnation: Option<HostAgentIncarnation>,
26 role_id: RoleId,
27 step_id: StepId,
28 attempt: u32,
29 execution_status: AgentExecutionStatus,
30 liveness: AgentLiveness,
31 source: AgentStatusSource,
32 requested_model: Option<String>,
33 requested_reasoning_effort: Option<String>,
34 actual_model: Option<String>,
35 actual_reasoning_effort: Option<String>,
36 activity: String,
37 blocker: Option<String>,
38 dependency: Option<String>,
39 task_summary: String,
40 evidence_references: Vec<String>,
41 usage_kind: Option<AgentUsageKind>,
42 usage_value: Option<u64>,
43 observed_at: OffsetDateTime,
44 report_sequence: u64,
45 idempotency_key: String,
46 claim_fence: StepClaimFence,
47}
48
49#[allow(clippy::too_many_arguments)]
50impl CeremonyAgentStatus {
51 pub fn new(
52 ceremony_id: CeremonyId,
53 agent_execution_id: CeremonyAgentExecutionId,
54 operation_id: ExecutionOperationId,
55 claim_owner_id: LeaseOwnerId,
56 logical_worker_id: LogicalWorkerId,
57 host_agent_id: LeaseOwnerId,
58 host_agent_incarnation: HostAgentIncarnation,
59 previous_host_agent_id: Option<LeaseOwnerId>,
60 previous_host_agent_incarnation: Option<HostAgentIncarnation>,
61 role_id: RoleId,
62 step_id: StepId,
63 attempt: u32,
64 execution_status: AgentExecutionStatus,
65 liveness: AgentLiveness,
66 source: AgentStatusSource,
67 requested_model: Option<String>,
68 requested_reasoning_effort: Option<String>,
69 actual_model: Option<String>,
70 actual_reasoning_effort: Option<String>,
71 activity: impl Into<String>,
72 blocker: Option<String>,
73 dependency: Option<String>,
74 task_summary: impl Into<String>,
75 evidence_references: Vec<String>,
76 usage_kind: Option<AgentUsageKind>,
77 usage_value: Option<u64>,
78 observed_at: OffsetDateTime,
79 report_sequence: u64,
80 idempotency_key: impl Into<String>,
81 claim_fence: StepClaimFence,
82 ) -> Result<Self, DomainError> {
83 if report_sequence == 0 {
84 return Err(DomainError::MustBeNonZero {
85 field: "report_sequence",
86 });
87 }
88 let status = Self {
89 ceremony_id,
90 agent_execution_id,
91 operation_id,
92 claim_owner_id,
93 logical_worker_id,
94 host_agent_id,
95 host_agent_incarnation,
96 previous_host_agent_id,
97 previous_host_agent_incarnation,
98 role_id,
99 step_id,
100 attempt,
101 execution_status,
102 liveness,
103 source,
104 requested_model: optional_text(requested_model, "requested_model")?,
105 requested_reasoning_effort: optional_text(
106 requested_reasoning_effort,
107 "requested_reasoning_effort",
108 )?,
109 actual_model: optional_text(actual_model, "actual_model")?,
110 actual_reasoning_effort: optional_text(
111 actual_reasoning_effort,
112 "actual_reasoning_effort",
113 )?,
114 activity: text(activity.into(), "activity")?,
115 blocker: optional_text(blocker, "blocker")?,
116 dependency: optional_text(dependency, "dependency")?,
117 task_summary: text(task_summary.into(), "task_summary")?,
118 evidence_references: evidence_references
119 .into_iter()
120 .map(|value| text(value, "evidence_references"))
121 .collect::<Result<_, _>>()?,
122 usage_kind,
123 usage_value,
124 observed_at,
125 report_sequence,
126 idempotency_key: text(idempotency_key.into(), "idempotency_key")?,
127 claim_fence,
128 };
129 if status.previous_host_agent_id.is_some()
130 != status.previous_host_agent_incarnation.is_some()
131 {
132 return Err(DomainError::InvariantViolated {
133 reason: "handoff provenance must include both previous host identities",
134 });
135 }
136 if status.evidence_references.len() > MAX_EVIDENCE_REFERENCES {
137 return Err(DomainError::OutOfRange {
138 field: "evidence_references",
139 value: status.evidence_references.len() as f64,
140 min: 0.0,
141 max: MAX_EVIDENCE_REFERENCES as f64,
142 });
143 }
144 match (status.usage_kind, status.usage_value) {
145 (None | Some(AgentUsageKind::Unavailable), None)
146 | (Some(AgentUsageKind::Measured | AgentUsageKind::Estimated), Some(_)) => {}
147 _ => {
148 return Err(DomainError::InvariantViolated {
149 reason: "agent usage value must match measured, estimated, or unavailable provenance",
150 });
151 }
152 }
153 if matches!(status.source, AgentStatusSource::DerivedLease)
156 && matches!(
157 status.liveness,
158 AgentLiveness::Fresh | AgentLiveness::Unreachable
159 )
160 {
161 return Err(DomainError::InvariantViolated {
162 reason: "a derived lease cannot claim host liveness",
163 });
164 }
165 if matches!(status.source, AgentStatusSource::HostDiscoveryUnsupported)
166 && !matches!(status.liveness, AgentLiveness::Unknown)
167 {
168 return Err(DomainError::InvariantViolated {
169 reason: "an unsupported host can only yield an unknown observation",
170 });
171 }
172 Ok(status)
173 }
174
175 #[must_use]
176 pub fn ceremony_id(&self) -> &CeremonyId {
177 &self.ceremony_id
178 }
179 #[must_use]
180 pub fn agent_execution_id(&self) -> &CeremonyAgentExecutionId {
181 &self.agent_execution_id
182 }
183 #[must_use]
184 pub const fn operation_id(&self) -> &ExecutionOperationId {
185 &self.operation_id
186 }
187 #[must_use]
188 pub const fn claim_owner_id(&self) -> &LeaseOwnerId {
189 &self.claim_owner_id
190 }
191 #[must_use]
192 pub fn logical_worker_id(&self) -> &LogicalWorkerId {
193 &self.logical_worker_id
194 }
195 #[must_use]
196 pub fn host_agent_id(&self) -> &LeaseOwnerId {
197 &self.host_agent_id
198 }
199 #[must_use]
200 pub fn host_agent_incarnation(&self) -> &HostAgentIncarnation {
201 &self.host_agent_incarnation
202 }
203 #[must_use]
204 pub fn previous_host_agent_id(&self) -> Option<&LeaseOwnerId> {
205 self.previous_host_agent_id.as_ref()
206 }
207 #[must_use]
208 pub fn previous_host_agent_incarnation(&self) -> Option<&HostAgentIncarnation> {
209 self.previous_host_agent_incarnation.as_ref()
210 }
211 #[must_use]
212 pub fn role_id(&self) -> &RoleId {
213 &self.role_id
214 }
215 #[must_use]
216 pub fn step_id(&self) -> &StepId {
217 &self.step_id
218 }
219 #[must_use]
220 pub fn attempt(&self) -> u32 {
221 self.attempt
222 }
223 #[must_use]
224 pub fn execution_status(&self) -> AgentExecutionStatus {
225 self.execution_status
226 }
227 #[must_use]
228 pub fn liveness(&self) -> AgentLiveness {
229 self.liveness
230 }
231 #[must_use]
232 pub fn source(&self) -> AgentStatusSource {
233 self.source
234 }
235 #[must_use]
236 pub fn requested_model(&self) -> Option<&str> {
237 self.requested_model.as_deref()
238 }
239 #[must_use]
240 pub fn requested_reasoning_effort(&self) -> Option<&str> {
241 self.requested_reasoning_effort.as_deref()
242 }
243 #[must_use]
244 pub fn actual_model(&self) -> Option<&str> {
245 self.actual_model.as_deref()
246 }
247 #[must_use]
248 pub fn actual_reasoning_effort(&self) -> Option<&str> {
249 self.actual_reasoning_effort.as_deref()
250 }
251 #[must_use]
252 pub fn activity(&self) -> &str {
253 &self.activity
254 }
255 #[must_use]
256 pub fn blocker(&self) -> Option<&str> {
257 self.blocker.as_deref()
258 }
259 #[must_use]
260 pub fn dependency(&self) -> Option<&str> {
261 self.dependency.as_deref()
262 }
263 #[must_use]
264 pub fn task_summary(&self) -> &str {
265 &self.task_summary
266 }
267 #[must_use]
268 pub fn evidence_references(&self) -> &[String] {
269 &self.evidence_references
270 }
271 #[must_use]
272 pub const fn usage_kind(&self) -> Option<AgentUsageKind> {
273 self.usage_kind
274 }
275 #[must_use]
276 pub fn usage_value(&self) -> Option<u64> {
277 self.usage_value
278 }
279 #[must_use]
280 pub fn observed_at(&self) -> OffsetDateTime {
281 self.observed_at
282 }
283 #[must_use]
284 pub fn report_sequence(&self) -> u64 {
285 self.report_sequence
286 }
287 #[must_use]
288 pub fn idempotency_key(&self) -> &str {
289 &self.idempotency_key
290 }
291 #[must_use]
292 pub const fn claim_fence(&self) -> &StepClaimFence {
293 &self.claim_fence
294 }
295
296 #[must_use]
299 pub fn projected_at(&self, now: OffsetDateTime, stale_after: Duration) -> Self {
300 let mut projected = self.clone();
301 if self.source == AgentStatusSource::HostReport
302 && self.liveness == AgentLiveness::Fresh
303 && now - self.observed_at > stale_after
304 {
305 projected.liveness = AgentLiveness::Stale;
306 }
307 projected
308 }
309
310 #[must_use]
313 pub fn has_same_logical_execution_as(&self, other: &Self) -> bool {
314 self.ceremony_id == other.ceremony_id
315 && self.agent_execution_id == other.agent_execution_id
316 && self.logical_worker_id == other.logical_worker_id
317 && self.role_id == other.role_id
318 && self.step_id == other.step_id
319 && self.attempt == other.attempt
320 }
321}
322
323#[allow(clippy::needless_pass_by_value)] fn text(value: String, field: &'static str) -> Result<String, DomainError> {
325 let value = value.trim().to_owned();
326 if value.is_empty() {
327 return Err(DomainError::EmptyField { field });
328 }
329 if value.len() > MAX_STATUS_TEXT {
330 return Err(DomainError::FieldTooLong {
331 field,
332 actual: value.len(),
333 max: MAX_STATUS_TEXT,
334 });
335 }
336 if value.chars().any(char::is_control) {
337 return Err(DomainError::InvalidCharacters { field });
338 }
339 Ok(value)
340}
341
342fn optional_text(
343 value: Option<String>,
344 field: &'static str,
345) -> Result<Option<String>, DomainError> {
346 value.map(|value| text(value, field)).transpose()
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 fn operation_id() -> ExecutionOperationId {
354 ExecutionOperationId::new("1".repeat(64)).unwrap()
355 }
356
357 fn claim_owner_id() -> LeaseOwnerId {
358 LeaseOwnerId::new("host").unwrap()
359 }
360
361 fn claim_fence() -> StepClaimFence {
362 StepClaimFence::new("2".repeat(64)).unwrap()
363 }
364
365 fn ceremony(value: &str) -> CeremonyId {
366 CeremonyId::new(value).unwrap()
367 }
368 fn execution(value: &str) -> CeremonyAgentExecutionId {
369 CeremonyAgentExecutionId::new(value).unwrap()
370 }
371 fn worker(value: &str) -> LogicalWorkerId {
372 LogicalWorkerId::new(value).unwrap()
373 }
374 fn owner(value: &str) -> LeaseOwnerId {
375 LeaseOwnerId::new(value).unwrap()
376 }
377 fn incarnation(value: &str) -> HostAgentIncarnation {
378 HostAgentIncarnation::new(value).unwrap()
379 }
380 fn role(value: &str) -> RoleId {
381 RoleId::new(value).unwrap()
382 }
383 fn step(value: &str) -> StepId {
384 StepId::new(value).unwrap()
385 }
386
387 fn status_with_usage(
388 usage_kind: Option<AgentUsageKind>,
389 usage_value: Option<u64>,
390 ) -> Result<CeremonyAgentStatus, DomainError> {
391 CeremonyAgentStatus::new(
392 ceremony("ceremony"),
393 execution("execution"),
394 operation_id(),
395 claim_owner_id(),
396 worker("worker"),
397 owner("host"),
398 incarnation("inc"),
399 None,
400 None,
401 role("reviewer"),
402 step("step"),
403 1,
404 AgentExecutionStatus::Blocked,
405 AgentLiveness::Fresh,
406 AgentStatusSource::HostReport,
407 None,
408 None,
409 None,
410 None,
411 "waiting for build lock",
412 Some("build lock".into()),
413 None,
414 "bounded summary",
415 vec![],
416 usage_kind,
417 usage_value,
418 OffsetDateTime::UNIX_EPOCH,
419 1,
420 "report-1",
421 claim_fence(),
422 )
423 }
424
425 #[test]
426 fn execution_and_liveness_are_independent() {
427 let status = status_with_usage(Some(AgentUsageKind::Unavailable), None).unwrap();
428 assert_eq!(status.execution_status(), AgentExecutionStatus::Blocked);
429 assert_eq!(status.liveness(), AgentLiveness::Fresh);
430 let aged = status.projected_at(
431 OffsetDateTime::UNIX_EPOCH + Duration::seconds(61),
432 Duration::seconds(60),
433 );
434 assert_eq!(aged.execution_status(), AgentExecutionStatus::Blocked);
435 assert_eq!(aged.liveness(), AgentLiveness::Stale);
436 assert_eq!(aged.source(), AgentStatusSource::HostReport);
437 }
438
439 #[test]
440 fn usage_value_matches_its_provenance() {
441 assert!(status_with_usage(Some(AgentUsageKind::Measured), Some(7)).is_ok());
442 assert!(status_with_usage(Some(AgentUsageKind::Estimated), Some(7)).is_ok());
443 assert!(status_with_usage(Some(AgentUsageKind::Unavailable), None).is_ok());
444 assert!(status_with_usage(Some(AgentUsageKind::Measured), None).is_err());
445 assert!(status_with_usage(Some(AgentUsageKind::Unavailable), Some(7)).is_err());
446 assert!(status_with_usage(None, Some(7)).is_err());
447 }
448
449 #[test]
450 fn handoff_requires_both_previous_identities() {
451 assert!(CeremonyAgentStatus::new(
452 ceremony("c"),
453 execution("e"),
454 operation_id(),
455 claim_owner_id(),
456 worker("w"),
457 owner("h"),
458 incarnation("i"),
459 Some(owner("old")),
460 None,
461 role("r"),
462 step("s"),
463 1,
464 AgentExecutionStatus::Running,
465 AgentLiveness::Fresh,
466 AgentStatusSource::HostReport,
467 None,
468 None,
469 None,
470 None,
471 "work",
472 None,
473 None,
474 "summary",
475 vec![],
476 None,
477 None,
478 OffsetDateTime::UNIX_EPOCH,
479 1,
480 "k",
481 claim_fence(),
482 )
483 .is_err());
484 }
485
486 #[test]
487 fn a_lease_or_unsupported_host_never_fabricates_a_fresh_observation() {
488 for source in [
489 AgentStatusSource::DerivedLease,
490 AgentStatusSource::HostDiscoveryUnsupported,
491 ] {
492 assert!(CeremonyAgentStatus::new(
493 ceremony("c"),
494 execution("e"),
495 operation_id(),
496 claim_owner_id(),
497 worker("participant"),
498 owner("host"),
499 incarnation("inc"),
500 None,
501 None,
502 role("role"),
503 step("step"),
504 1,
505 AgentExecutionStatus::Running,
506 AgentLiveness::Fresh,
507 source,
508 None,
509 None,
510 None,
511 None,
512 "working",
513 None,
514 None,
515 "summary",
516 vec![],
517 None,
518 None,
519 OffsetDateTime::UNIX_EPOCH,
520 1,
521 "key",
522 claim_fence(),
523 )
524 .is_err());
525 }
526 assert!(CeremonyAgentStatus::new(
527 ceremony("c"),
528 execution("e"),
529 operation_id(),
530 claim_owner_id(),
531 worker("participant"),
532 owner("host"),
533 incarnation("inc"),
534 None,
535 None,
536 role("role"),
537 step("step"),
538 1,
539 AgentExecutionStatus::Blocked,
540 AgentLiveness::Stale,
541 AgentStatusSource::DerivedLease,
542 None,
543 None,
544 None,
545 None,
546 "waiting",
547 Some("host report missing".into()),
548 None,
549 "summary",
550 vec![],
551 None,
552 None,
553 OffsetDateTime::UNIX_EPOCH,
554 1,
555 "key",
556 claim_fence(),
557 )
558 .is_ok());
559 }
560}