1use 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum SubmitError {
40 #[error("unknown capability: {0}")]
42 UnknownCapability(String),
43 #[error("not executable: {0}")]
46 NotExecutable(String),
47 #[error("invalid input: {0}")]
49 InvalidInput(String),
50 #[error("unavailable: {0}")]
52 Unavailable(String),
53}
54
55impl SubmitError {
56 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 running: usize,
78 busy: BTreeMap<String, usize>,
80 workers: Vec<std::thread::JoinHandle<()>>,
81}
82
83pub 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 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 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 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 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 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 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 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 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 pub fn cancel(&self, id: &ExecutionId, by: &str) -> super::store::CancelOutcome {
341 self.store.request_cancel(id, by)
342 }
343
344 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 pub fn accepting(&self) -> bool {
383 !self.stopping.load(Ordering::SeqCst)
384 }
385
386 pub fn queued(&self) -> usize {
388 self.lock().queue.len()
389 }
390}
391
392fn 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 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 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
451fn 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
480pub(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
497pub 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}