Skip to main content

majordomus_cli/execution/
engine.rs

1//! The engine: what turns a capability call into an execution anybody can watch.
2//!
3//! It owns no behaviour of its own. Everything it runs is a capability of the registry,
4//! reached through [`crate::capability::Context::execute`] — the same executor MCP, the
5//! HTTP routes, the command line and the benchmarks call — so a browser, an agent and a
6//! terminal reach one implementation and there is no second copy of anything to drift.
7//!
8//! ```text
9//!   submit ── validate ── create ── queue ──► worker thread
10//!                                               │
11//!                          Context.progress ────┤ ctx.execute(id, input)
12//!                                               │        │
13//!                                               │        └─► the capability's own handler
14//!                                               ▼
15//!                                       ExecutionStore ──► subscribers ──► WebSocket
16//! ```
17//!
18//! It is deliberately small: an in-memory store, a bounded number of worker threads, and
19//! a cooperative cancellation flag. There is no durable queue, no retry and no scheduler,
20//! because nothing here is a background job system — it is a way to watch a call that
21//! takes longer than a request should be held open for.
22
23use std::collections::{BTreeMap, VecDeque};
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::{Arc, Mutex};
26
27use serde_json::Value;
28
29use crate::capability::{Capability, CapabilityError, CapabilityKind, Concurrency, Context};
30
31use super::event::EventPayload;
32use super::model::{Actor, Execution, ExecutionError, ExecutionId, RepositoryRef};
33use super::redact::redact;
34use super::store::{ExecutionStore, Limits};
35
36/// Why an execution was not accepted. Refused before anything is created, so a client that
37/// is told no knows nothing started.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum SubmitError {
40    /// No capability of that id.
41    #[error("unknown capability: {0}")]
42    UnknownCapability(String),
43    /// A resource is read, not executed; a planned or unsupported capability is listed and
44    /// never run.
45    #[error("not executable: {0}")]
46    NotExecutable(String),
47    /// The input does not satisfy the capability's input schema.
48    #[error("invalid input: {0}")]
49    InvalidInput(String),
50    /// The engine is stopping and accepts nothing further.
51    #[error("unavailable: {0}")]
52    Unavailable(String),
53}
54
55impl SubmitError {
56    /// The stable code a transport maps to its own vocabulary.
57    pub fn code(&self) -> &'static str {
58        match self {
59            SubmitError::UnknownCapability(_) => "unknown_capability",
60            SubmitError::NotExecutable(_) => "action_unavailable",
61            SubmitError::InvalidInput(_) => "validation_error",
62            SubmitError::Unavailable(_) => "unavailable",
63        }
64    }
65}
66
67struct Pending {
68    id: ExecutionId,
69    capability: String,
70    input: Value,
71    ctx: Context,
72}
73
74struct Inner {
75    queue: VecDeque<Pending>,
76    /// How many workers are inside a handler right now.
77    running: usize,
78    /// Which capabilities have an execution in flight, for the serial policy.
79    busy: BTreeMap<String, usize>,
80    workers: Vec<std::thread::JoinHandle<()>>,
81}
82
83/// The execution engine of one process.
84///
85/// One per process, held by [`crate::capability::Context`], so that the command line, an
86/// MCP session and every HTTP worker see the same executions.
87pub struct ExecutionEngine {
88    store: Arc<ExecutionStore>,
89    inner: Mutex<Inner>,
90    stopping: AtomicBool,
91    validators: Mutex<BTreeMap<String, Arc<jsonschema::Validator>>>,
92}
93
94impl std::fmt::Debug for ExecutionEngine {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "ExecutionEngine({} active)", self.store.active())
97    }
98}
99
100impl Default for ExecutionEngine {
101    fn default() -> Self {
102        Self::new(Limits::default())
103    }
104}
105
106impl ExecutionEngine {
107    /// An engine with these limits.
108    pub fn new(limits: Limits) -> Self {
109        ExecutionEngine {
110            store: Arc::new(ExecutionStore::new(limits)),
111            inner: Mutex::new(Inner {
112                queue: VecDeque::new(),
113                running: 0,
114                busy: BTreeMap::new(),
115                workers: Vec::new(),
116            }),
117            stopping: AtomicBool::new(false),
118            validators: Mutex::new(BTreeMap::new()),
119        }
120    }
121
122    /// The store, for the capabilities that read it.
123    pub fn store(&self) -> &Arc<ExecutionStore> {
124        &self.store
125    }
126
127    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
128        self.inner.lock().unwrap_or_else(|e| e.into_inner())
129    }
130
131    /// Check an input against a capability's schema, with the compiled validator kept.
132    ///
133    /// The handler validates again when it deserialises, and that is what is authoritative;
134    /// this exists so that a client learns its input is wrong from the request that sent
135    /// it, with the constraint named, rather than from an execution that fails a moment
136    /// later.
137    fn validate(&self, c: &Capability, input: &Value) -> Result<(), SubmitError> {
138        let validator = {
139            let mut cache = self.validators.lock().unwrap_or_else(|e| e.into_inner());
140            match cache.get(c.id.as_str()) {
141                Some(v) => Arc::clone(v),
142                None => match jsonschema::validator_for(&c.input.schema) {
143                    Ok(v) => {
144                        let v = Arc::new(v);
145                        cache.insert(c.id.to_string(), Arc::clone(&v));
146                        v
147                    }
148                    // a schema this crate derived that will not compile is a defect of the
149                    // crate, not of the caller: say so and let the handler decide
150                    Err(e) => {
151                        tracing::warn!(capability_id = %c.id, "the input schema does not compile: {e}");
152                        return Ok(());
153                    }
154                },
155            }
156        };
157        let problems: Vec<String> = validator
158            .iter_errors(input)
159            .take(5)
160            .map(|e| {
161                let at = e.instance_path().to_string();
162                if at.is_empty() {
163                    e.to_string()
164                } else {
165                    format!("{at}: {e}")
166                }
167            })
168            .collect();
169        if problems.is_empty() {
170            Ok(())
171        } else {
172            Err(SubmitError::InvalidInput(problems.join("; ")))
173        }
174    }
175
176    /// Accept a capability call as an execution and answer with its first snapshot.
177    ///
178    /// Fast on purpose: the input is checked, the record is created, the work is queued and
179    /// this returns. Nothing waits for the handler.
180    ///
181    /// The answer is the execution *as accepted* — `queued`, sequence 2, the `execution.queued`
182    /// event already published — and never whatever a worker has since made of it. That is
183    /// what `docs/EXECUTIONS.md` promises a client of `executions.start`, and it is not a
184    /// courtesy: reading the record again after the work became dispatchable would answer
185    /// `queued`, `running` or even `succeeded` depending on how the machine was loaded that
186    /// millisecond, and the same window would let a worker publish `execution.started` before
187    /// `execution.queued`, whereupon the state machine refuses the queued event and the
188    /// stream loses it. Acceptance is therefore recorded and read while the queue lock is
189    /// held, before `Self::dispatch` can hand the pending to anybody.
190    pub fn submit(
191        &self,
192        ctx: &Context,
193        capability: &str,
194        input: Value,
195        actor: Actor,
196    ) -> Result<Execution, SubmitError> {
197        if self.stopping.load(Ordering::SeqCst) {
198            return Err(SubmitError::Unavailable(
199                "this server is shutting down and is accepting no further executions".into(),
200            ));
201        }
202        let c = ctx
203            .registry
204            .get(capability)
205            .ok_or_else(|| SubmitError::UnknownCapability(capability.to_string()))?;
206        if !c.kind.is_executable() {
207            return Err(SubmitError::NotExecutable(format!(
208                "'{}' is a resource: it is read, never executed. Read it with objects.get",
209                c.id
210            )));
211        }
212        if !c.stability.executable() {
213            return Err(SubmitError::NotExecutable(format!(
214                "'{}' is {:?} and no projection executes it",
215                c.id, c.stability
216            )));
217        }
218        let input = if input.is_null() {
219            Value::Object(serde_json::Map::new())
220        } else {
221            input
222        };
223        self.validate(c, &input)?;
224
225        let id = ExecutionId::fresh();
226        let stored = redact(&c.input.schema, &input);
227        self.store.create(
228            id.clone(),
229            c.id.as_str(),
230            &c.title,
231            stored,
232            c.execution.cancellable,
233            actor,
234            repository_of(ctx),
235        );
236        let pending = Pending {
237            id: id.clone(),
238            capability: c.id.to_string(),
239            input,
240            ctx: ctx.clone(),
241        };
242        // The queue lock is held across the queued event and the snapshot: a worker
243        // finishing another execution calls `dispatch` too, and it needs this lock to take
244        // the pending, so nothing can start what has not yet been announced as accepted.
245        // The nesting is one way — this is the only place that takes the store's lock while
246        // holding the engine's, the store knows nothing of the engine, and the store's
247        // fan-out never blocks — so it cannot deadlock.
248        let accepted = {
249            let mut inner = self.lock();
250            inner.queue.push_back(pending);
251            let ahead = inner.queue.len() - 1;
252            self.store.publish(&id, EventPayload::Queued { ahead });
253            self.store.get(&id)
254        };
255        self.dispatch();
256        accepted.ok_or_else(|| {
257            SubmitError::Unavailable("the execution was forgotten as it was created".into())
258        })
259    }
260
261    /// Start whatever may start now: the bound on concurrent handlers, and the serial
262    /// policy of a capability that must not overlap with itself.
263    fn dispatch(&self) {
264        loop {
265            let next = {
266                let mut inner = self.lock();
267                if inner.running >= self.store.limits().max_running {
268                    return;
269                }
270                let position = inner.queue.iter().position(|p| {
271                    let serial = self
272                        .concurrency_of(&p.ctx, &p.capability)
273                        .is_some_and(|c| c == Concurrency::Serial);
274                    !serial || !inner.busy.contains_key(&p.capability)
275                });
276                let Some(position) = position else { return };
277                let Some(pending) = inner.queue.remove(position) else {
278                    return;
279                };
280                inner.running += 1;
281                *inner.busy.entry(pending.capability.clone()).or_insert(0) += 1;
282                inner.workers.retain(|w| !w.is_finished());
283                pending
284            };
285            let store = Arc::clone(&self.store);
286            let id = next.id.clone();
287            let capability = next.capability.clone();
288            let done_with = capability.clone();
289            let engine = Arc::clone(&next.ctx.executions);
290            let worker = std::thread::Builder::new()
291                .name(format!(
292                    "execution-{}",
293                    &id.as_str()[..12.min(id.as_str().len())]
294                ))
295                .spawn(move || {
296                    run(&store, next);
297                    let mut inner = engine.lock();
298                    inner.running = inner.running.saturating_sub(1);
299                    if let Some(count) = inner.busy.get_mut(&done_with) {
300                        *count -= 1;
301                        if *count == 0 {
302                            inner.busy.remove(&done_with);
303                        }
304                    }
305                    drop(inner);
306                    engine.dispatch();
307                });
308            match worker {
309                Ok(handle) => self.lock().workers.push(handle),
310                Err(e) => {
311                    // the thread could not be spawned: the execution fails now rather than
312                    // sitting queued for a worker that will never come
313                    let mut inner = self.lock();
314                    inner.running = inner.running.saturating_sub(1);
315                    inner.busy.remove(&capability);
316                    drop(inner);
317                    self.store.publish(
318                        &id,
319                        EventPayload::Failed {
320                            error: ExecutionError {
321                                code: "internal".into(),
322                                message: format!("no worker could be started: {e}"),
323                                suggestion: None,
324                                correlation_id: id.to_string(),
325                            },
326                        },
327                    );
328                }
329            }
330        }
331    }
332
333    fn concurrency_of(&self, ctx: &Context, capability: &str) -> Option<Concurrency> {
334        ctx.registry
335            .get(capability)
336            .map(|c| c.execution.concurrency)
337    }
338
339    /// Ask an execution to stop.
340    pub fn cancel(&self, id: &ExecutionId, by: &str) -> super::store::CancelOutcome {
341        self.store.request_cancel(id, by)
342    }
343
344    /// Stop accepting executions, ask every one that is running to stop, and wait for the
345    /// workers, up to `grace`.
346    ///
347    /// A handler that does not look at its cancellation flag is not killed: this process
348    /// is about to end, and interrupting a read half way leaves nothing behind that
349    /// matters. What the wait buys is that a client watching gets the final event.
350    pub fn shutdown(&self, grace: std::time::Duration) {
351        self.stopping.store(true, Ordering::SeqCst);
352        for execution in self.store.list(None, None, usize::MAX) {
353            if execution.state.is_active() {
354                self.store.request_cancel(&execution.id, "server");
355            }
356        }
357        let deadline = std::time::Instant::now() + grace;
358        loop {
359            let done = {
360                let mut inner = self.lock();
361                inner.workers.retain(|w| !w.is_finished());
362                inner.workers.is_empty() && inner.queue.is_empty()
363            };
364            if done || std::time::Instant::now() >= deadline {
365                break;
366            }
367            std::thread::sleep(std::time::Duration::from_millis(10));
368        }
369        let stranded: Vec<ExecutionId> = self
370            .store
371            .list(None, None, usize::MAX)
372            .into_iter()
373            .filter(|e| e.state.is_active())
374            .map(|e| e.id)
375            .collect();
376        for id in stranded {
377            self.store.publish(&id, EventPayload::Cancelled);
378        }
379    }
380
381    /// Is this engine still accepting executions?
382    pub fn accepting(&self) -> bool {
383        !self.stopping.load(Ordering::SeqCst)
384    }
385
386    /// How many executions are waiting for a worker.
387    pub fn queued(&self) -> usize {
388        self.lock().queue.len()
389    }
390}
391
392/// Run one execution to its end, whatever happens inside the handler.
393fn run(store: &Arc<ExecutionStore>, pending: Pending) {
394    let Pending {
395        id,
396        capability,
397        input,
398        ctx,
399    } = pending;
400    let Some(cancel) = store.token(&id) else {
401        return;
402    };
403    // it may have been cancelled while it waited, up to this very instant: the start is
404    // refused from a final state under the lock a cancel takes, and a refused start runs
405    // nothing, where a look at the state first would leave that instant open
406    if store.publish(&id, EventPayload::Started).is_none() {
407        return;
408    }
409    let progress =
410        super::sink::Progress::reporting(Arc::clone(store), id.clone(), Arc::clone(&cancel));
411    let ctx = ctx.reporting(progress);
412    let span = tracing::info_span!("execution", execution_id = %id, capability_id = %capability);
413    let entered = span.enter();
414    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
415        ctx.execute_observed(&capability, input)
416    }));
417    drop(entered);
418    let cancelled = cancel.load(Ordering::SeqCst);
419    let payload = match outcome {
420        Ok(Ok(output)) => EventPayload::Completed { output },
421        Ok(Err(error)) if cancelled => {
422            tracing::debug!(execution_id = %id, "the handler stopped after cancellation: {error}");
423            EventPayload::Cancelled
424        }
425        Ok(Err(error)) => EventPayload::Failed {
426            error: failure(&id, &error),
427        },
428        Err(panic) => {
429            let what = panic
430                .downcast_ref::<&str>()
431                .map(|s| (*s).to_string())
432                .or_else(|| panic.downcast_ref::<String>().cloned())
433                .unwrap_or_else(|| "a handler panicked".to_string());
434            // the message goes to this process's log, where an operator can read it; the
435            // client is told which execution it was and nothing about this crate's insides
436            tracing::error!(execution_id = %id, capability_id = %capability, "a handler panicked: {what}");
437            EventPayload::Failed {
438                error: ExecutionError {
439                    code: "internal".into(),
440                    message: "the handler failed unexpectedly; the server's log carries the detail"
441                        .into(),
442                    suggestion: Some(format!("search the server's output for execution_id={id}")),
443                    correlation_id: id.to_string(),
444                },
445            }
446        }
447    };
448    store.publish(&id, payload);
449}
450
451/// A capability error as an execution reports it.
452fn failure(id: &ExecutionId, error: &CapabilityError) -> ExecutionError {
453    let (code, suggestion) = match error {
454        CapabilityError::InvalidInput(_) => (
455            "validation_error",
456            Some("check the input against the capability's input schema".to_string()),
457        ),
458        CapabilityError::NotFound(_) => ("not_found", None),
459        CapabilityError::Refused(_) => ("refused", None),
460        CapabilityError::Forbidden(_) => ("forbidden", None),
461        CapabilityError::Internal(_) => (
462            "internal",
463            Some(format!("search the server's output for execution_id={id}")),
464        ),
465    };
466    ExecutionError {
467        code: code.into(),
468        message: match error {
469            CapabilityError::InvalidInput(m)
470            | CapabilityError::NotFound(m)
471            | CapabilityError::Refused(m)
472            | CapabilityError::Internal(m)
473            | CapabilityError::Forbidden(m) => m.clone(),
474        },
475        suggestion,
476        correlation_id: id.to_string(),
477    }
478}
479
480/// Which repository this process runs executions against. Never the request's idea of it:
481/// a process serves the one repository it read at start-up.
482pub(crate) fn repository_of(ctx: &Context) -> RepositoryRef {
483    let root = std::path::Path::new(&ctx.index.repository.root);
484    RepositoryRef {
485        name: root
486            .file_name()
487            .map(|n| n.to_string_lossy().into_owned())
488            .unwrap_or_else(|| "repository".into()),
489        id: crate::repository::identity(root),
490        branch: match &ctx.index.repository.git {
491            crate::git::GitState::Available(info) => info.branch.clone(),
492            crate::git::GitState::Unavailable { .. } => None,
493        },
494    }
495}
496
497/// Is this capability one an execution may run at all? The same question `submit` asks,
498/// answered without submitting, for a projection that lists what a client may start.
499///
500/// ```
501/// use majordomus_cli::capability::{CapabilityKind, Stability};
502/// use majordomus_cli::execution::engine::runnable;
503/// assert!(runnable(CapabilityKind::Query, Stability::Implemented).is_none());
504/// assert!(runnable(CapabilityKind::Resource, Stability::Implemented).is_some());
505/// assert!(runnable(CapabilityKind::Query, Stability::Planned).is_some());
506/// ```
507pub fn runnable(kind: CapabilityKind, stability: crate::capability::Stability) -> Option<String> {
508    if !kind.is_executable() {
509        return Some("it is a resource: read it rather than running it".into());
510    }
511    if !stability.executable() {
512        return Some(format!(
513            "it is {}, and no projection executes it",
514            format!("{stability:?}").to_lowercase()
515        ));
516    }
517    None
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn a_forbidden_capability_fails_its_execution_with_the_refusal_it_gave() {
526        let id = ExecutionId::fresh();
527        let e = failure(&id, &CapabilityError::remote("plan.transition"));
528        assert_eq!(e.code, "forbidden");
529        assert!(e.message.contains("plan.transition"), "{}", e.message);
530        assert_eq!(e.suggestion, None);
531        assert_eq!(e.correlation_id, id.to_string());
532        for (error, code) in [
533            (
534                CapabilityError::InvalidInput("m".into()),
535                "validation_error",
536            ),
537            (CapabilityError::NotFound("m".into()), "not_found"),
538            (CapabilityError::Refused("m".into()), "refused"),
539            (CapabilityError::Internal("m".into()), "internal"),
540        ] {
541            let e = failure(&id, &error);
542            assert_eq!((e.code.as_str(), e.message.as_str()), (code, "m"));
543        }
544    }
545}