Skip to main content

a3s_effect/
actor.rs

1//! An actor is a fold over an immutable log.
2//!
3//! Each component is a Moore machine: `step` consumes one fact, `output`
4//! returns the view and the transitions that state enables. The runtime is
5//! the edge that runs those transitions. A transition that has already left
6//! a `cause` in the log is not selected again, so resuming a thread continues
7//! unfinished work and does not repeat finished work.
8
9use std::sync::Arc;
10
11use tokio_util::sync::CancellationToken;
12
13use crate::effect::{Effect, TraceEvent};
14use crate::error::ActorError;
15use crate::exit::Exit;
16use crate::fact::{cut_log, Fact, LogCut, NewFact};
17use crate::log::LogStore;
18
19pub struct Transition<S> {
20    pub key: String,
21    pub run: TransitionProgram<S>,
22}
23
24/// A preparation value and the still-unexecuted continuation it authorizes.
25/// Only the actor appends these facts; preparing never acquires tool effects.
26pub struct PreparedTransition<S> {
27    pub facts: Vec<NewFact>,
28    pub run: Effect<Vec<NewFact>, ActorError, S>,
29}
30
31pub enum TransitionProgram<S> {
32    Immediate(Effect<Vec<NewFact>, ActorError, S>),
33    Prepared(Effect<PreparedTransition<S>, ActorError, S>),
34}
35
36impl<S> From<Effect<Vec<NewFact>, ActorError, S>> for TransitionProgram<S> {
37    fn from(run: Effect<Vec<NewFact>, ActorError, S>) -> Self {
38        Self::Immediate(run)
39    }
40}
41
42impl<S: Send + Sync + 'static> TransitionProgram<S> {
43    /// Project the same environment through preparation and its continuation.
44    /// This preserves the system binding installed by composed harnesses.
45    pub fn map_services(self, project: impl Fn(Arc<S>) -> Arc<S> + Send + Sync + 'static) -> Self {
46        let project = Arc::new(project);
47        match self {
48            Self::Immediate(run) => {
49                Self::Immediate(run.map_services(move |services| project(services)))
50            }
51            Self::Prepared(prepare) => {
52                let continuation_project = Arc::clone(&project);
53                Self::Prepared(
54                    prepare
55                        .map(move |prepared| {
56                            let project = Arc::clone(&continuation_project);
57                            PreparedTransition {
58                                facts: prepared.facts,
59                                run: prepared.run.map_services(move |services| project(services)),
60                            }
61                        })
62                        .map_services(move |services| project(services)),
63                )
64            }
65        }
66    }
67}
68
69type Projection<S, V> =
70    dyn Fn(&[Fact]) -> Result<(V, Vec<Transition<S>>), ActorError> + Send + Sync;
71
72pub struct ErasedComponent<S, V> {
73    pub(crate) project: Arc<Projection<S, V>>,
74}
75
76pub fn component<S, V, St, I, Step, Out>(
77    initial: I,
78    step: Step,
79    output: Out,
80) -> ErasedComponent<S, V>
81where
82    S: Send + Sync + 'static,
83    V: 'static,
84    St: Send + 'static,
85    I: Fn() -> St + Send + Sync + 'static,
86    Step: Fn(St, &Fact) -> St + Send + Sync + 'static,
87    Out: Fn(&St) -> (V, Vec<Transition<S>>) + Send + Sync + 'static,
88{
89    ErasedComponent {
90        project: Arc::new(move |facts| {
91            let mut state = initial();
92            for fact in facts {
93                state = step(state, fact);
94            }
95            Ok(output(&state))
96        }),
97    }
98}
99
100pub struct Actor<S, V> {
101    pub name: &'static str,
102    components: Vec<ErasedComponent<S, V>>,
103    merge: Arc<dyn Fn(Vec<V>) -> V + Send + Sync>,
104}
105
106impl<S, V> Actor<S, V>
107where
108    S: Send + Sync + 'static,
109    V: 'static,
110{
111    pub fn new(
112        name: &'static str,
113        components: Vec<ErasedComponent<S, V>>,
114        merge: impl Fn(Vec<V>) -> V + Send + Sync + 'static,
115    ) -> Self {
116        Self {
117            name,
118            components,
119            merge: Arc::new(merge),
120        }
121    }
122
123    fn project(&self, facts: &[Fact]) -> Result<(V, Vec<Transition<S>>), ActorError> {
124        let mut views = Vec::with_capacity(self.components.len());
125        let mut transitions = Vec::new();
126        for component in &self.components {
127            let (view, enabled) = (component.project)(facts)?;
128            views.push(view);
129            transitions.extend(enabled);
130        }
131        let mut seen = std::collections::BTreeSet::new();
132        for transition in &transitions {
133            if !seen.insert(transition.key.clone()) {
134                return Err(ActorError::DuplicateTransition {
135                    key: transition.key.clone(),
136                });
137            }
138        }
139        let causes: std::collections::BTreeSet<&str> = facts
140            .iter()
141            .filter_map(|fact| fact.cause.as_deref())
142            .collect();
143        transitions.retain(|transition| !causes.contains(transition.key.as_str()));
144        transitions.sort_by(|left, right| left.key.cmp(&right.key));
145        Ok(((self.merge)(views), transitions))
146    }
147}
148
149pub struct Settlement<V> {
150    pub view: V,
151    pub log: Vec<Fact>,
152    pub steps: u32,
153    pub cut: LogCut,
154    pub trace: Vec<TraceEvent>,
155}
156
157pub async fn resume<S, V>(
158    actor: &Actor<S, V>,
159    log: &dyn LogStore,
160    services: Arc<S>,
161    thread_id: &str,
162    limit: u32,
163) -> Result<Settlement<V>, Exit<ActorError>>
164where
165    S: Send + Sync + 'static,
166    V: 'static,
167{
168    resume_with_cancel(
169        actor,
170        log,
171        services,
172        thread_id,
173        limit,
174        &CancellationToken::new(),
175    )
176    .await
177}
178
179pub async fn resume_with_cancel<S, V>(
180    actor: &Actor<S, V>,
181    log: &dyn LogStore,
182    services: Arc<S>,
183    thread_id: &str,
184    limit: u32,
185    cancel: &CancellationToken,
186) -> Result<Settlement<V>, Exit<ActorError>>
187where
188    S: Send + Sync + 'static,
189    V: 'static,
190{
191    let mut steps = 0;
192    let mut trace_events = Vec::new();
193    loop {
194        let facts = match log.read(thread_id) {
195            Ok(facts) => facts,
196            Err(error) => return Err(Exit::Fail(error)),
197        };
198        let (view, transitions) = match actor.project(&facts) {
199            Ok(projected) => projected,
200            Err(error) => return Err(Exit::Fail(error)),
201        };
202        let Some(next) = transitions.into_iter().next() else {
203            return Ok(Settlement {
204                cut: cut_log(&facts),
205                view,
206                log: facts,
207                steps,
208                trace: trace_events,
209            });
210        };
211        if steps >= limit {
212            return Err(Exit::Fail(ActorError::StepLimit { limit }));
213        }
214        // The transition records its own cancel facts. Do not select around
215        // this await: dropping it first would skip those facts, and a later
216        // resume would run the same tool again.
217        let run = match next.run {
218            TransitionProgram::Immediate(run) => run,
219            TransitionProgram::Prepared(prepare) => {
220                let (prepared, span) = prepare
221                    .with_span(format!("preparation:{}", next.key))
222                    .run_with_cancel(Arc::clone(&services), cancel.clone())
223                    .await;
224                trace_events.extend(span);
225                let prepared = prepared?;
226                // Preparation deliberately has no execution cause. On recovery
227                // the component sees the obligation and chooses reconciliation,
228                // while only this selected continuation may execute the tool.
229                log.append(thread_id, &prepared.facts, None)
230                    .map_err(Exit::Fail)?;
231                prepared.run
232            }
233        };
234        let (result, span) = run
235            .with_span(format!("transition:{}", next.key))
236            .run_with_cancel(Arc::clone(&services), cancel.clone())
237            .await;
238        trace_events.extend(span);
239        let produced = match result {
240            Ok(produced) => produced,
241            Err(error) => return Err(error),
242        };
243        if let Err(error) = log.append(thread_id, &produced, Some(next.key.as_str())) {
244            return Err(Exit::Fail(error));
245        }
246        steps += 1;
247        // A transition that completed and appended is durable work: settle
248        // with it, never discard it. Cancellation is carried by the
249        // transitions themselves — observed effects record their own
250        // interrupt facts and quiesce the actor, and `Effect::from_async`
251        // refuses to start once cancelled, which still leaves the loop with
252        // [`Exit::Interrupt`].
253    }
254}
255
256pub async fn ingest<S, V>(
257    actor: &Actor<S, V>,
258    log: &dyn LogStore,
259    services: Arc<S>,
260    thread_id: &str,
261    fact: NewFact,
262    limit: u32,
263) -> Result<Settlement<V>, Exit<ActorError>>
264where
265    S: Send + Sync + 'static,
266    V: 'static,
267{
268    ingest_with_cancel(
269        actor,
270        log,
271        services,
272        thread_id,
273        fact,
274        limit,
275        &CancellationToken::new(),
276    )
277    .await
278}
279
280/// Append one host input to the existing actor log without resuming it.
281///
282/// The caller retains the original log and thread binding. This entry point
283/// does not project components, select transitions, or acquire any services;
284/// preparation and terminal facts still come from the actor's transition path.
285pub fn append_input(log: &dyn LogStore, thread_id: &str, fact: NewFact) -> Result<(), ActorError> {
286    log.append(thread_id, &[fact], None).map(|_| ())
287}
288
289pub async fn ingest_with_cancel<S, V>(
290    actor: &Actor<S, V>,
291    log: &dyn LogStore,
292    services: Arc<S>,
293    thread_id: &str,
294    fact: NewFact,
295    limit: u32,
296    cancel: &CancellationToken,
297) -> Result<Settlement<V>, Exit<ActorError>>
298where
299    S: Send + Sync + 'static,
300    V: 'static,
301{
302    append_input(log, thread_id, fact).map_err(Exit::Fail)?;
303    resume_with_cancel(actor, log, services, thread_id, limit, cancel).await
304}
305
306#[cfg(test)]
307#[path = "actor/tests.rs"]
308mod tests;