Skip to main content

made_core/entities/
agentic_system_execution.rs

1//! [`AgenticSystemExecution`] — one run of one sealed design.
2//!
3//! A separate entity with a separate store, on purpose (ADR-021). Held
4//! on the design aggregate, every run would rewrite the document and
5//! fight the compare-and-swap that protects an author's edits; and a
6//! design whose bytes changed whenever somebody ran it could not be
7//! pinned by the run at all.
8//!
9//! What it holds is the join between intent and reality: which design
10//! it is running, which real instance each composition became, which
11//! profiles were requested for each role, and how each logical
12//! participant was materialized — including the ones that were not.
13
14use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17use time::OffsetDateTime;
18
19use crate::error::DomainError;
20use crate::value_objects::{
21    AgenticSystemExecutionId, CeremonyExecutionLink, ExecutionState, IntegratorBindingId,
22    LinkStatus, ParticipantId, ParticipantMaterialization, RequestedExecutionProfile,
23    SystemCeremonyId, SystemPin, SystemRoleId,
24};
25
26/// One run of an agentic system.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct AgenticSystemExecution {
29    id: AgenticSystemExecutionId,
30    system: SystemPin,
31    ceremonies: BTreeMap<SystemCeremonyId, CeremonyExecutionLink>,
32    resolved_profiles: BTreeMap<SystemRoleId, RequestedExecutionProfile>,
33    participants: BTreeMap<ParticipantId, ParticipantMaterialization>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    integrator_binding: Option<IntegratorBindingId>,
36    state: ExecutionState,
37    #[serde(with = "time::serde::rfc3339")]
38    created_at: OffsetDateTime,
39    #[serde(with = "time::serde::rfc3339")]
40    updated_at: OffsetDateTime,
41}
42
43impl AgenticSystemExecution {
44    /// Open a run with nothing started yet.
45    pub fn plan(
46        id: AgenticSystemExecutionId,
47        system: SystemPin,
48        ceremonies: impl IntoIterator<Item = (SystemCeremonyId, CeremonyExecutionLink)>,
49        resolved_profiles: impl IntoIterator<Item = (SystemRoleId, RequestedExecutionProfile)>,
50        participants: impl IntoIterator<Item = (ParticipantId, ParticipantMaterialization)>,
51        now: OffsetDateTime,
52    ) -> Result<Self, DomainError> {
53        let ceremonies: BTreeMap<_, _> = ceremonies.into_iter().collect();
54        if ceremonies.is_empty() {
55            return Err(DomainError::EmptyCollection {
56                field: "agentic_system_execution.ceremonies",
57            });
58        }
59        let mut execution = Self {
60            id,
61            system,
62            ceremonies,
63            resolved_profiles: resolved_profiles.into_iter().collect(),
64            participants: participants.into_iter().collect(),
65            integrator_binding: None,
66            state: ExecutionState::Planned,
67            created_at: now,
68            updated_at: now,
69        };
70        execution.state = execution.observed_state();
71        Ok(execution)
72    }
73
74    #[must_use]
75    pub const fn id(&self) -> &AgenticSystemExecutionId {
76        &self.id
77    }
78
79    /// Which design, at which revision, with which bytes.
80    #[must_use]
81    pub const fn system(&self) -> &SystemPin {
82        &self.system
83    }
84
85    #[must_use]
86    pub const fn ceremonies(&self) -> &BTreeMap<SystemCeremonyId, CeremonyExecutionLink> {
87        &self.ceremonies
88    }
89
90    /// What was asked of the host for each role, never what it did.
91    #[must_use]
92    pub const fn resolved_profiles(&self) -> &BTreeMap<SystemRoleId, RequestedExecutionProfile> {
93        &self.resolved_profiles
94    }
95
96    #[must_use]
97    pub const fn participants(&self) -> &BTreeMap<ParticipantId, ParticipantMaterialization> {
98        &self.participants
99    }
100
101    #[must_use]
102    pub const fn integrator_binding(&self) -> Option<&IntegratorBindingId> {
103        self.integrator_binding.as_ref()
104    }
105
106    #[must_use]
107    pub const fn state(&self) -> ExecutionState {
108        self.state
109    }
110
111    #[must_use]
112    pub const fn created_at(&self) -> OffsetDateTime {
113        self.created_at
114    }
115
116    #[must_use]
117    pub const fn updated_at(&self) -> OffsetDateTime {
118        self.updated_at
119    }
120
121    #[must_use]
122    pub fn link(&self, ceremony: &SystemCeremonyId) -> Option<&CeremonyExecutionLink> {
123        self.ceremonies.get(ceremony)
124    }
125
126    /// The run with one composition's link replaced.
127    ///
128    /// Replacing rather than mutating, and re-deriving the state from
129    /// the links each time, so the summary can never disagree with the
130    /// detail it summarises.
131    pub fn with_link(
132        &self,
133        ceremony: &SystemCeremonyId,
134        link: CeremonyExecutionLink,
135        now: OffsetDateTime,
136    ) -> Result<Self, DomainError> {
137        if !self.ceremonies.contains_key(ceremony) {
138            return Err(DomainError::NotFound {
139                what: "agentic_system_execution.ceremony",
140            });
141        }
142        let mut next = self.clone();
143        next.ceremonies.insert(ceremony.clone(), link);
144        next.updated_at = now;
145        next.state = next.observed_state();
146        Ok(next)
147    }
148
149    /// The run with the integrator's destination recorded.
150    #[must_use]
151    pub fn with_integrator_binding(
152        &self,
153        binding: IntegratorBindingId,
154        now: OffsetDateTime,
155    ) -> Self {
156        Self {
157            integrator_binding: Some(binding),
158            updated_at: now,
159            ..self.clone()
160        }
161    }
162
163    /// Which compositions could be started now: still pending, and
164    /// with everything they wait for completed.
165    ///
166    /// Completed, not merely settled. A skipped ceremony produced no
167    /// outputs, so anything that read them would be reading nothing,
168    /// and starting it anyway would be the one thing a skip exists to
169    /// prevent.
170    #[must_use]
171    pub fn ready(
172        &self,
173        blocking: &BTreeMap<SystemCeremonyId, Vec<SystemCeremonyId>>,
174    ) -> Vec<&SystemCeremonyId> {
175        self.ceremonies
176            .iter()
177            .filter(|(id, link)| {
178                link.status() == LinkStatus::Pending
179                    && blocking
180                        .get(*id)
181                        .is_none_or(|waiting| waiting.iter().all(|other| self.is_completed(other)))
182            })
183            .map(|(id, _)| id)
184            .collect()
185    }
186
187    fn is_completed(&self, ceremony: &SystemCeremonyId) -> bool {
188        self.ceremonies
189            .get(ceremony)
190            .is_some_and(|link| link.status().releases_dependants())
191    }
192
193    fn observed_state(&self) -> ExecutionState {
194        ExecutionState::of(self.ceremonies.values().map(CeremonyExecutionLink::status))
195    }
196}