1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::{
5 atomic::{AtomicBool, Ordering},
6 Arc, Weak,
7};
8use std::time::Duration;
9
10use async_trait::async_trait;
11use bamboo_agent_core::tools::{
12 FunctionCall, ToolCall, ToolExecutionContext, ToolExecutionSessionFlags, ToolExecutor,
13 ToolOutcome, ToolResult,
14};
15use bamboo_domain::{
16 validate_schema, CompiledWorkflow, FailurePolicy, StartWorkflowRun, ValueRef,
17 WorkflowBudgetUsage, WorkflowBudgets, WorkflowCompileError, WorkflowDefinitionBundle,
18 WorkflowFailure, WorkflowFailureCode, WorkflowPlan, WorkflowProgress, WorkflowRunDefinition,
19 WorkflowRunEvent, WorkflowRunEventKind, WorkflowRunSnapshot, WorkflowRunStatus,
20 WorkflowStepDefinition, WorkflowStepKind, WorkflowStepSnapshot, WorkflowStepStatus,
21 WorkflowSuspensionContext,
22};
23use chrono::Utc;
24use dashmap::{mapref::entry::Entry, DashMap};
25use futures::{future::join_all, stream::FuturesUnordered, StreamExt};
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28use thiserror::Error;
29use tokio::sync::{broadcast, Mutex, Semaphore};
30use tokio_util::sync::CancellationToken;
31use uuid::Uuid;
32
33use super::repository::WorkflowRunRepository;
34
35type SecretResolutionFuture<'a> =
36 Pin<Box<dyn Future<Output = Result<(Value, Vec<String>), WorkflowFailure>> + Send + 'a>>;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct NamedAgentSpec {
40 pub name: String,
41 pub allowed_capabilities: BTreeSet<String>,
42 pub profile:
43 Option<Arc<crate::session_app::child_session::named_profile::ResolvedChildProfile>>,
44 pub cost_supported: bool,
45}
46
47#[derive(Debug, Clone)]
48pub struct AgentStepResult {
49 pub output: Value,
50 pub tokens: u64,
51 pub cost_micros: Option<u64>,
52 pub failure: Option<WorkflowFailure>,
54 pub attempt_id: Option<String>,
56}
57
58#[async_trait]
59pub trait AgentStepPort: Send + Sync {
60 async fn resolve(&self, name: &str, session_id: &str)
62 -> Result<Option<NamedAgentSpec>, String>;
63 async fn execute(
64 &self,
65 spec: &NamedAgentSpec,
66 prompt: Value,
67 model: Option<&str>,
68 effort: Option<&str>,
69 capabilities: &BTreeSet<String>,
70 session_id: &str,
71 root_run_id: &str,
72 cancellation: CancellationToken,
73 ) -> Result<AgentStepResult, String>;
74 fn acknowledge_result(&self, _attempt_id: &str) -> bool {
76 true
77 }
78 async fn drain_cancelled(
80 &self,
81 _root_run_id: &str,
82 ) -> (Vec<Result<AgentStepResult, String>>, Option<String>) {
83 (Vec::new(), None)
84 }
85}
86
87#[async_trait]
88pub trait WorkflowDefinitionPort: Send + Sync {
89 async fn pin_bundle(
93 &self,
94 root: &WorkflowRunDefinition,
95 ) -> Result<WorkflowDefinitionBundle, String>;
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum PermissionDecision {
100 Allow,
101 Deny(String),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum WorkflowPolicyTarget {
106 Tool(String),
107 Agent(String),
108 Workflow { id: String, revision: u64 },
109}
110
111#[async_trait]
112pub trait WorkflowPolicyPort: Send + Sync {
113 async fn authorize(
114 &self,
115 session_id: &str,
116 target: &WorkflowPolicyTarget,
117 requested: &BTreeSet<String>,
118 workspace_trusted: bool,
119 ) -> PermissionDecision;
120}
121
122#[async_trait]
123pub trait WorkflowSessionPermissionPort: Send + Sync {
124 async fn flags_for_session(
125 &self,
126 session_id: &str,
127 ) -> Result<ToolExecutionSessionFlags, String>;
128}
129
130pub struct WorkflowSecretMaterial(String);
134
135impl WorkflowSecretMaterial {
136 pub fn new(value: String) -> Self {
137 Self(value)
138 }
139
140 fn into_exposed(self) -> String {
141 self.0
142 }
143}
144
145#[async_trait]
146pub trait WorkflowSecretResolverPort: Send + Sync {
147 async fn resolve(
148 &self,
149 session_id: &str,
150 capability: &str,
151 ) -> Result<WorkflowSecretMaterial, String>;
152}
153
154#[derive(Debug, Error)]
155pub enum WorkflowRunError {
156 #[error(transparent)]
157 Compile(#[from] WorkflowCompileError),
158 #[error("invalid workflow input: {0}")]
159 InvalidInput(String),
160 #[error("workflow preflight failed: {0}")]
161 Preflight(String),
162 #[error("named agents without monetary measurement do not support a finite monetary budget")]
163 UnsupportedMonetaryBudget,
164 #[error("workflow storage failed: {0}")]
165 Storage(String),
166 #[error("workflow run not found")]
167 NotFound,
168 #[error("workflow run is already terminal")]
169 Terminal,
170}
171
172pub struct WorkflowRunEngine {
173 repository: Arc<dyn WorkflowRunRepository>,
174 tools: Arc<dyn ToolExecutor>,
175 agents: Arc<dyn AgentStepPort>,
176 definitions: Arc<dyn WorkflowDefinitionPort>,
177 policy: Arc<dyn WorkflowPolicyPort>,
178 secrets: Arc<dyn WorkflowSecretResolverPort>,
179 ceilings: WorkflowBudgets,
180 active: DashMap<String, Arc<ActiveRun>>,
181 events: DashMap<String, broadcast::Sender<WorkflowRunEvent>>,
182 session_permissions: std::sync::RwLock<Option<Arc<dyn WorkflowSessionPermissionPort>>>,
183}
184
185struct ActiveRun {
186 cancellation: CancellationToken,
187 snapshot: Arc<Mutex<WorkflowRunSnapshot>>,
188 root_run_id: String,
189 ledger: Arc<Mutex<WorkflowBudgetUsage>>,
190 drain_lock: Arc<Mutex<()>>,
191 cleanup_pending: AtomicBool,
192 has_agents: bool,
193}
194
195struct RuntimeRegistration {
196 engine: Weak<WorkflowRunEngine>,
197 run_id: String,
198}
199
200impl Drop for RuntimeRegistration {
201 fn drop(&mut self) {
202 if let Some(engine) = self.engine.upgrade() {
203 let pending = engine
204 .active
205 .get(&self.run_id)
206 .is_some_and(|run| run.cleanup_pending.load(Ordering::Acquire));
207 if !pending {
208 engine.active.remove(&self.run_id);
209 engine.events.remove(&self.run_id);
210 }
211 }
212 }
213}
214
215struct RunContext {
216 engine: Arc<WorkflowRunEngine>,
217 compiled: Arc<CompiledWorkflow>,
218 bundle: Arc<WorkflowDefinitionBundle>,
219 pinned_agents: Arc<HashMap<String, NamedAgentSpec>>,
220 snapshot: Arc<Mutex<WorkflowRunSnapshot>>,
221 cancellation: CancellationToken,
222 branch_cancellation: CancellationToken,
223 allowed_capabilities: BTreeSet<String>,
224 workspace_trusted: bool,
225 semaphore: Arc<Semaphore>,
226 items: HashMap<String, Value>,
227 scope: String,
228 map_index_path: Vec<usize>,
229 depth: u32,
230 ledger: Arc<Mutex<WorkflowBudgetUsage>>,
231 root_limits: WorkflowBudgets,
232 root_run_id: String,
233 drain_lock: Arc<Mutex<()>>,
234}
235
236impl Clone for RunContext {
237 fn clone(&self) -> Self {
238 Self {
239 engine: self.engine.clone(),
240 compiled: self.compiled.clone(),
241 bundle: self.bundle.clone(),
242 pinned_agents: self.pinned_agents.clone(),
243 snapshot: self.snapshot.clone(),
244 cancellation: self.cancellation.clone(),
245 branch_cancellation: self.branch_cancellation.clone(),
246 allowed_capabilities: self.allowed_capabilities.clone(),
247 workspace_trusted: self.workspace_trusted,
248 semaphore: self.semaphore.clone(),
249 items: self.items.clone(),
250 scope: self.scope.clone(),
251 map_index_path: self.map_index_path.clone(),
252 depth: self.depth,
253 ledger: self.ledger.clone(),
254 root_limits: self.root_limits.clone(),
255 root_run_id: self.root_run_id.clone(),
256 drain_lock: self.drain_lock.clone(),
257 }
258 }
259}
260
261type NodeFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, WorkflowFailure>> + Send + 'a>>;
262type StartSignal =
263 Arc<Mutex<Option<tokio::sync::oneshot::Sender<Result<WorkflowRunSnapshot, WorkflowRunError>>>>>;
264
265impl WorkflowRunEngine {
266 pub fn new(
267 repository: Arc<dyn WorkflowRunRepository>,
268 tools: Arc<dyn ToolExecutor>,
269 agents: Arc<dyn AgentStepPort>,
270 definitions: Arc<dyn WorkflowDefinitionPort>,
271 policy: Arc<dyn WorkflowPolicyPort>,
272 secrets: Arc<dyn WorkflowSecretResolverPort>,
273 ceilings: WorkflowBudgets,
274 ) -> Arc<Self> {
275 Arc::new(Self {
276 repository,
277 tools,
278 agents,
279 definitions,
280 policy,
281 secrets,
282 ceilings,
283 active: DashMap::new(),
284 events: DashMap::new(),
285 session_permissions: std::sync::RwLock::new(None),
286 })
287 }
288
289 pub fn set_session_permission_port(&self, port: Arc<dyn WorkflowSessionPermissionPort>) {
290 *self
291 .session_permissions
292 .write()
293 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(port);
294 }
295
296 pub async fn run(
297 self: &Arc<Self>,
298 request: StartWorkflowRun,
299 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
300 let bundle = self.pin_and_validate_bundle(&request.definition).await?;
301 self.run_pinned(request, bundle).await
302 }
303
304 pub async fn run_pinned(
305 self: &Arc<Self>,
306 request: StartWorkflowRun,
307 bundle: WorkflowDefinitionBundle,
308 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
309 self.validate_bundle(&request.definition, &bundle)?;
310 let cancellation = CancellationToken::new();
311 let ledger = Arc::new(Mutex::new(WorkflowBudgetUsage::default()));
312 let limits = effective_limits(&request.definition.budgets, &self.ceilings);
313 let semaphore = Arc::new(Semaphore::new(limits.max_concurrency));
314 let pinned_agents = Arc::new(
315 self.preflight_bundle(
316 &bundle,
317 &request.session_id,
318 &request.allowed_capabilities.iter().cloned().collect(),
319 request.workspace_trusted,
320 &limits,
321 )
322 .await?,
323 );
324 self.run_internal(
325 request,
326 Arc::new(bundle),
327 pinned_agents,
328 None,
329 None,
330 0,
331 cancellation,
332 ledger,
333 limits,
334 semaphore,
335 None,
336 None,
337 )
338 .await
339 }
340
341 pub async fn start(
344 self: &Arc<Self>,
345 request: StartWorkflowRun,
346 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
347 let bundle = self.pin_and_validate_bundle(&request.definition).await?;
348 self.start_pinned(request, bundle).await
349 }
350
351 pub async fn start_pinned(
352 self: &Arc<Self>,
353 request: StartWorkflowRun,
354 bundle: WorkflowDefinitionBundle,
355 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
356 self.validate_bundle(&request.definition, &bundle)?;
357 let cancellation = CancellationToken::new();
358 let ledger = Arc::new(Mutex::new(WorkflowBudgetUsage::default()));
359 let limits = effective_limits(&request.definition.budgets, &self.ceilings);
360 let semaphore = Arc::new(Semaphore::new(limits.max_concurrency));
361 let pinned_agents = Arc::new(
362 self.preflight_bundle(
363 &bundle,
364 &request.session_id,
365 &request.allowed_capabilities.iter().cloned().collect(),
366 request.workspace_trusted,
367 &limits,
368 )
369 .await?,
370 );
371 let (tx, rx) = tokio::sync::oneshot::channel();
372 let signal = Arc::new(Mutex::new(Some(tx)));
373 let engine = self.clone();
374 tokio::spawn(async move {
375 let result = engine
376 .run_internal(
377 request,
378 Arc::new(bundle),
379 pinned_agents,
380 None,
381 None,
382 0,
383 cancellation,
384 ledger,
385 limits,
386 semaphore,
387 Some(signal.clone()),
388 None,
389 )
390 .await;
391 if let Err(error) = result {
392 if let Some(sender) = signal.lock().await.take() {
393 let _ = sender.send(Err(error));
394 } else {
395 tracing::error!("background workflow run failed after start");
396 }
397 } else if signal.lock().await.is_some() {
398 tracing::error!("workflow task completed without publishing a start snapshot");
399 }
400 });
401 rx.await.map_err(|_| {
402 WorkflowRunError::Storage("workflow task exited before durable start".to_string())
403 })?
404 }
405
406 pub async fn restart(
409 self: &Arc<Self>,
410 run_id: &str,
411 workspace_trusted: bool,
412 allowed_capabilities: Vec<String>,
413 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
414 let previous = self
415 .repository
416 .load(run_id)
417 .await
418 .map_err(storage)?
419 .ok_or(WorkflowRunError::NotFound)?;
420 if previous.status != WorkflowRunStatus::Suspended {
421 return Err(if previous.status.is_terminal() {
422 WorkflowRunError::Terminal
423 } else {
424 WorkflowRunError::Preflight("only suspended workflows can restart".to_string())
425 });
426 }
427 if matches!(
428 previous.suspension,
429 Some(
430 WorkflowSuspensionContext::ToolApproval { .. }
431 | WorkflowSuspensionContext::ToolRunning { .. }
432 )
433 ) {
434 return Err(WorkflowRunError::Preflight(
435 "workflow has durable suspension context and requires explicit resume handling"
436 .to_string(),
437 ));
438 }
439 let bundle = previous.definition_bundle;
440 self.start_pinned(
441 StartWorkflowRun {
442 definition: previous.definition,
443 args: previous.validated_args,
444 session_id: previous.session_id,
445 workspace_trusted,
446 allowed_capabilities,
447 },
448 bundle,
449 )
450 .await
451 }
452
453 pub fn completed_prefix(snapshot: &WorkflowRunSnapshot) -> Result<usize, WorkflowRunError> {
456 let refuse = || {
457 WorkflowRunError::Preflight("workflow has no safe completed-prefix checkpoint".into())
458 };
459 if snapshot.status != WorkflowRunStatus::Suspended
460 || !matches!(
461 snapshot.suspension,
462 Some(WorkflowSuspensionContext::Recovery { .. })
463 )
464 || snapshot.parent_run_id.is_some()
465 || snapshot.parent_step_id.is_some()
466 || snapshot.output.is_some()
467 || snapshot.failure.is_some()
468 {
469 return Err(refuse());
470 }
471 let bundle = &snapshot.definition_bundle;
472 if bundle.definitions.len() != 1
473 || bundle.root_id != snapshot.definition.id
474 || bundle.root_revision != snapshot.definition.revision
475 || bundle.root() != Some(&snapshot.definition)
476 || definition_bundle_hash(bundle)? != snapshot.definition_bundle_hash
477 {
478 return Err(refuse());
479 }
480 let compiled = CompiledWorkflow::compile(snapshot.definition.clone())?;
481 compiled
482 .validate_input(&snapshot.validated_args)
483 .map_err(WorkflowRunError::InvalidInput)?;
484 reject_secret_material(&snapshot.validated_args).map_err(WorkflowRunError::InvalidInput)?;
485 let serialized = serde_json::to_value(bundle)
486 .map_err(|error| WorkflowRunError::Preflight(error.to_string()))?;
487 reject_secret_material_in_definition(&serialized).map_err(WorkflowRunError::Preflight)?;
488 let WorkflowPlan::Sequence { nodes } = &snapshot.definition.plan else {
489 return Err(refuse());
490 };
491 let ids = nodes
492 .iter()
493 .map(|node| match node {
494 WorkflowPlan::Step { step } => Ok(step.as_str()),
495 _ => Err(refuse()),
496 })
497 .collect::<Result<Vec<_>, _>>()?;
498 if ids.is_empty()
499 || ids.len() != compiled.steps.len()
500 || ids.iter().copied().collect::<BTreeSet<_>>().len() != ids.len()
501 {
502 return Err(refuse());
503 }
504 let mut outputs = BTreeMap::new();
505 let mut prefix = 0;
506 let mut suffix = false;
507 for id in ids.iter().copied() {
508 let step = compiled.steps.get(id).ok_or_else(refuse)?;
509 let WorkflowStepKind::Tool {
510 args, capabilities, ..
511 } = &step.kind
512 else {
513 return Err(refuse());
514 };
515 if capabilities.iter().any(|capability| capability != "read")
516 || contains_secret_handle(args)
517 {
518 return Err(refuse());
519 }
520 let Some(state) = snapshot.steps.get(id) else {
521 suffix = true;
522 continue;
523 };
524 if suffix
525 || state.id != id
526 || state.status != WorkflowStepStatus::Succeeded
527 || state.attempts != 1
528 || state.failure.is_some()
529 {
530 return Err(refuse());
531 }
532 let input = resolve_checkpoint_template(args, &snapshot.validated_args, &outputs)?;
533 let hash = hex::encode(Sha256::digest(
534 serde_json::to_vec(&input)
535 .map_err(|error| WorkflowRunError::InvalidInput(error.to_string()))?,
536 ));
537 let output = state.output.as_ref().ok_or_else(refuse)?;
538 if hash != state.input_hash {
539 return Err(refuse());
540 }
541 if let Some(schema) = &step.output_schema {
542 validate_schema(schema, output).map_err(|_| refuse())?;
543 }
544 reject_secret_material(output).map_err(|_| refuse())?;
545 outputs.insert(id.to_string(), output.clone());
546 prefix += 1;
547 }
548 let usage = &snapshot.usage;
549 let budget = &snapshot.definition.budgets;
550 if prefix == 0
551 || snapshot.steps.len() != prefix
552 || usage.steps as usize != prefix
553 || usage.agents != 0
554 || usage.retries != 0
555 || ids.len() > budget.max_steps as usize
556 || budget.max_tokens.is_some_and(|limit| usage.tokens > limit)
557 || budget
558 .max_cost_micros
559 .is_some_and(|limit| usage.cost_micros.is_none_or(|cost| cost > limit))
560 || remaining_wall_time(snapshot) == 0
561 {
562 return Err(refuse());
563 }
564 Ok(prefix)
565 }
566
567 pub async fn continue_completed_prefix(
570 self: &Arc<Self>,
571 run_id: &str,
572 session_id: &str,
573 workspace_trusted: bool,
574 allowed_capabilities: Vec<String>,
575 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
576 let previous = self
577 .repository
578 .load(run_id)
579 .await
580 .map_err(storage)?
581 .ok_or(WorkflowRunError::NotFound)?;
582 if previous.run_id != run_id || previous.session_id != session_id {
583 return Err(WorkflowRunError::NotFound);
584 }
585 Self::completed_prefix(&previous)?;
586 let snapshot = Arc::new(Mutex::new(previous.clone()));
587 let cancellation = CancellationToken::new();
588 let ledger = Arc::new(Mutex::new(previous.usage.clone()));
589 let drain_lock = Arc::new(Mutex::new(()));
590 let active = Arc::new(ActiveRun {
591 cancellation: cancellation.clone(),
592 snapshot: snapshot.clone(),
593 root_run_id: run_id.to_string(),
594 ledger: ledger.clone(),
595 drain_lock: drain_lock.clone(),
596 cleanup_pending: AtomicBool::new(false),
597 has_agents: false,
598 });
599 match self.active.entry(run_id.to_string()) {
600 Entry::Occupied(_) => {
601 return Err(WorkflowRunError::Preflight(
602 "workflow continuation is already active".into(),
603 ))
604 }
605 Entry::Vacant(entry) => {
606 entry.insert(active);
607 }
608 }
609 let registration = RuntimeRegistration {
610 engine: Arc::downgrade(self),
611 run_id: run_id.to_string(),
612 };
613 let current = self
616 .repository
617 .load(run_id)
618 .await
619 .map_err(storage)?
620 .ok_or(WorkflowRunError::NotFound)?;
621 let prefix = Self::completed_prefix(¤t)?;
622 if current != previous {
623 return Err(WorkflowRunError::Preflight(
624 "workflow checkpoint changed during continuation admission".into(),
625 ));
626 }
627 self.validate_bundle(¤t.definition, ¤t.definition_bundle)?;
628 self.enforce_ceilings(¤t.definition.budgets)?;
629 let allowed_capabilities = allowed_capabilities.into_iter().collect::<BTreeSet<_>>();
630 let root_limits = effective_limits(¤t.definition.budgets, &self.ceilings);
631 self.preflight_bundle(
632 ¤t.definition_bundle,
633 session_id,
634 &allowed_capabilities,
635 workspace_trusted,
636 &root_limits,
637 )
638 .await?;
639 let permission_port = self
640 .session_permissions
641 .read()
642 .unwrap_or_else(std::sync::PoisonError::into_inner)
643 .clone();
644 if let Some(port) = permission_port {
645 port.flags_for_session(session_id).await.map_err(|_| {
646 WorkflowRunError::Preflight(
647 "workflow session permission posture is unavailable".into(),
648 )
649 })?;
650 }
651 Self::completed_prefix(¤t)?;
652 let compiled = Arc::new(CompiledWorkflow::compile(current.definition.clone())?);
653 let context = RunContext {
654 engine: self.clone(),
655 compiled,
656 bundle: Arc::new(current.definition_bundle),
657 pinned_agents: Arc::new(HashMap::new()),
658 snapshot: snapshot.clone(),
659 cancellation: cancellation.clone(),
660 branch_cancellation: cancellation.child_token(),
661 allowed_capabilities,
662 workspace_trusted,
663 semaphore: Arc::new(Semaphore::new(root_limits.max_concurrency)),
664 items: HashMap::new(),
665 scope: "root".into(),
666 map_index_path: Vec::new(),
667 depth: 0,
668 ledger,
669 root_limits,
670 root_run_id: run_id.to_string(),
671 drain_lock,
672 };
673 let (sender, _) = broadcast::channel(256);
674 self.events.insert(run_id.to_string(), sender);
675 let (tx, rx) = tokio::sync::oneshot::channel();
676 let engine = self.clone();
677 tokio::spawn(async move {
678 let _registration = registration;
679 let start = {
680 let mut snapshot = snapshot.lock().await;
681 if cancellation.is_cancelled() {
682 Err(WorkflowRunError::Preflight(
683 "workflow continuation was cancelled during admission".into(),
684 ))
685 } else {
686 engine
687 .transition(
688 &mut snapshot,
689 None,
690 WorkflowRunEventKind::RunStarted,
691 |snapshot| {
692 snapshot.status = WorkflowRunStatus::Running;
693 snapshot.suspension = None;
694 },
695 )
696 .await
697 .map(|()| snapshot.clone())
698 }
699 };
700 match start {
701 Ok(started) => {
702 let timeout_ms = remaining_wall_time(&started);
703 let _ = tx.send(Ok(started));
704 if engine
705 .execute_context(context, timeout_ms, prefix)
706 .await
707 .is_err()
708 {
709 tracing::error!(
710 "background workflow continuation failed after durable start"
711 );
712 }
713 }
714 Err(error) => {
715 let _ = tx.send(Err(error));
716 }
717 }
718 });
719 rx.await.map_err(|_| {
720 WorkflowRunError::Storage("workflow continuation exited before durable start".into())
721 })?
722 }
723
724 #[allow(clippy::too_many_arguments)]
725 async fn run_internal(
726 self: &Arc<Self>,
727 request: StartWorkflowRun,
728 bundle: Arc<WorkflowDefinitionBundle>,
729 pinned_agents: Arc<HashMap<String, NamedAgentSpec>>,
730 parent_run_id: Option<String>,
731 parent_step_id: Option<String>,
732 depth: u32,
733 cancellation: CancellationToken,
734 ledger: Arc<Mutex<WorkflowBudgetUsage>>,
735 root_limits: WorkflowBudgets,
736 semaphore: Arc<Semaphore>,
737 started: Option<StartSignal>,
738 root_run_id: Option<String>,
739 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
740 if request.definition.steps.len() > self.ceilings.max_steps as usize {
741 return Err(WorkflowRunError::Preflight(
742 "workflow definition exceeds server step-count ceiling".to_string(),
743 ));
744 }
745 let compiled = Arc::new(CompiledWorkflow::compile(request.definition)?);
746 self.enforce_ceilings(&compiled.definition.budgets)?;
747 let definition_value = serde_json::to_value(&compiled.definition)
748 .map_err(|error| WorkflowRunError::Preflight(error.to_string()))?;
749 reject_secret_material_in_definition(&definition_value)
750 .map_err(WorkflowRunError::Preflight)?;
751 compiled
752 .validate_input(&request.args)
753 .map_err(WorkflowRunError::InvalidInput)?;
754 reject_secret_material(&request.args).map_err(WorkflowRunError::InvalidInput)?;
755 let allowed_capabilities = request
756 .allowed_capabilities
757 .into_iter()
758 .collect::<BTreeSet<_>>();
759 enforce_budget_within(&compiled.definition.budgets, &root_limits).map_err(|message| {
760 WorkflowRunError::Preflight(format!("nested workflow budget expands root: {message}"))
761 })?;
762
763 let run_id = Uuid::new_v4().to_string();
764 let root_run_id = root_run_id.unwrap_or_else(|| run_id.clone());
765 let now = Utc::now();
766 let snapshot = WorkflowRunSnapshot {
767 run_id: run_id.clone(),
768 parent_run_id,
769 parent_step_id,
770 session_id: request.session_id,
771 definition: compiled.definition.clone(),
772 definition_bundle: bundle.as_ref().clone(),
773 definition_bundle_hash: definition_bundle_hash(&bundle)?,
774 validated_args: request.args,
775 status: WorkflowRunStatus::Queued,
776 steps: BTreeMap::new(),
777 usage: WorkflowBudgetUsage::default(),
778 last_sequence: 1,
779 output: None,
780 failure: None,
781 suspension: None,
782 created_at: now,
783 updated_at: now,
784 };
785 let queued = event(&snapshot, None, WorkflowRunEventKind::RunQueued);
786 self.repository
787 .create(&snapshot, &queued)
788 .await
789 .map_err(storage)?;
790 let (sender, _) = broadcast::channel(256);
791 self.events.insert(run_id.clone(), sender);
792 self.publish(&queued);
793 let snapshot = Arc::new(Mutex::new(snapshot));
794 let drain_lock = Arc::new(Mutex::new(()));
795 self.active.insert(
796 run_id.clone(),
797 Arc::new(ActiveRun {
798 cancellation: cancellation.clone(),
799 snapshot: snapshot.clone(),
800 root_run_id: root_run_id.clone(),
801 ledger: ledger.clone(),
802 drain_lock: drain_lock.clone(),
803 cleanup_pending: AtomicBool::new(false),
804 has_agents: !pinned_agents.is_empty(),
805 }),
806 );
807 let _registration = RuntimeRegistration {
808 engine: Arc::downgrade(self),
809 run_id: run_id.clone(),
810 };
811 {
812 let mut shared = snapshot.lock().await;
813 let start_result = if cancellation.is_cancelled() {
814 self.finish_cancelled(&mut shared).await
815 } else {
816 self.transition(
817 &mut shared,
818 None,
819 WorkflowRunEventKind::RunStarted,
820 |snapshot| {
821 snapshot.status = WorkflowRunStatus::Running;
822 },
823 )
824 .await
825 };
826 start_result?;
827 if let Some(started) = started {
828 if let Some(sender) = started.lock().await.take() {
829 let _ = sender.send(Ok(shared.clone()));
830 }
831 }
832 }
833 let context = RunContext {
834 engine: self.clone(),
835 compiled: compiled.clone(),
836 bundle,
837 pinned_agents,
838 snapshot: snapshot.clone(),
839 cancellation: cancellation.clone(),
840 branch_cancellation: cancellation.child_token(),
841 allowed_capabilities,
842 workspace_trusted: request.workspace_trusted,
843 semaphore,
844 items: HashMap::new(),
845 scope: "root".to_string(),
846 map_index_path: Vec::new(),
847 depth,
848 ledger,
849 root_limits,
850 root_run_id: root_run_id.clone(),
851 drain_lock,
852 };
853 self.execute_context(context, compiled.definition.budgets.wall_time_ms, 0)
854 .await
855 }
856
857 async fn execute_context(
858 self: &Arc<Self>,
859 context: RunContext,
860 timeout_ms: u64,
861 completed_prefix: usize,
862 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
863 let compiled = &context.compiled;
864 let snapshot = &context.snapshot;
865 let cancellation = &context.cancellation;
866 let run_id = snapshot.lock().await.run_id.clone();
867 let result = tokio::time::timeout(
868 Duration::from_millis(timeout_ms),
869 context.execute_root(completed_prefix),
870 )
871 .await;
872 if result.is_err() {
873 cancellation.cancel();
874 }
875 let _drain_guard = context.drain_lock.lock().await;
876 {
877 let snapshot = snapshot.lock().await;
878 if snapshot.status.is_terminal() {
879 return Ok(snapshot.clone());
880 }
881 }
882 if result.is_err() {
883 if let Some(durable) = self.repository.load(&run_id).await.map_err(storage)? {
884 *snapshot.lock().await = durable;
885 }
886 }
887 let (usage_unavailable, drain_error) = context.drain_agents().await;
888 if context.ledger.lock().await.agents > 0 {
890 context
891 .checkpoint_agent_usage("AgentDrained")
892 .await
893 .map_err(|e| WorkflowRunError::Storage(e.message))?;
894 }
895 if drain_error.is_some() {
896 if let Some(active) = self.active.get(&run_id) {
897 active.cleanup_pending.store(true, Ordering::Release);
898 }
899 return Err(WorkflowRunError::Preflight(
900 "Workflow children have not confirmed stop; cancellation can be retried".into(),
901 ));
902 }
903 let mut final_snapshot = snapshot.lock().await;
904 if usage_unavailable && !final_snapshot.status.is_terminal() {
905 self.finish_failed(&mut final_snapshot, failure(WorkflowFailureCode::ExecutionFailed,
906 "workflow Agent stopped but cumulative token observation unavailable; usage is not measured", false)).await?;
907 return Ok(final_snapshot.clone());
908 }
909 if final_snapshot.status.is_terminal() {
910 return Ok(final_snapshot.clone());
911 }
912 match result {
913 Ok(Ok(output)) if cancellation.is_cancelled() => {
914 let _ = output;
915 self.finish_cancelled(&mut final_snapshot).await?;
916 }
917 Ok(Ok(output)) => {
918 if let Some(schema) = &compiled.definition.output_schema {
919 if let Err(message) = validate_schema(schema, &output) {
920 let failure = failure(WorkflowFailureCode::InvalidOutput, message, false);
921 self.finish_failed(&mut final_snapshot, failure).await?;
922 } else {
923 self.finish_succeeded(&mut final_snapshot, output).await?;
924 }
925 } else {
926 self.finish_succeeded(&mut final_snapshot, output).await?;
927 }
928 }
929 Ok(Err(error)) if error.code == WorkflowFailureCode::Cancelled => {
930 self.finish_cancelled(&mut final_snapshot).await?;
931 }
932 Ok(Err(error)) if error.code == WorkflowFailureCode::Suspended => {
933 self.finish_suspended(&mut final_snapshot, error.message)
934 .await?;
935 }
936 Ok(Err(error)) => self.finish_failed(&mut final_snapshot, error).await?,
937 Err(_) => {
938 cancellation.cancel();
939 if let Some(durable) = self.repository.load(&run_id).await.map_err(storage)? {
945 *final_snapshot = durable;
946 }
947 let step_failure = failure(
948 WorkflowFailureCode::BudgetExceeded,
949 "workflow wall-time budget exceeded",
950 false,
951 );
952 self.fail_timeout_frontier(
953 &mut final_snapshot,
954 &compiled.definition.plan,
955 step_failure.clone(),
956 )
957 .await?;
958 self.fail_active_steps(&mut final_snapshot, step_failure.clone())
959 .await?;
960 self.finish_failed(&mut final_snapshot, step_failure)
961 .await?;
962 }
963 }
964 Ok(final_snapshot.clone())
965 }
966
967 pub async fn progress(
968 &self,
969 run_id: &str,
970 since: u64,
971 ) -> Result<WorkflowProgress, WorkflowRunError> {
972 let snapshot = self
973 .repository
974 .load(run_id)
975 .await
976 .map_err(storage)?
977 .ok_or(WorkflowRunError::NotFound)?;
978 let events = self
979 .repository
980 .events_since(run_id, since)
981 .await
982 .map_err(storage)?;
983 Ok(WorkflowProgress { snapshot, events })
984 }
985
986 pub async fn list_run_ids(&self) -> Result<Vec<String>, WorkflowRunError> {
987 self.repository.list_run_ids().await.map_err(storage)
988 }
989
990 pub fn is_run_active(&self, run_id: &str) -> bool {
997 self.active.contains_key(run_id)
998 }
999
1000 #[cfg(test)]
1001 pub(super) fn test_active_ledger(
1002 &self,
1003 run_id: &str,
1004 ) -> (Arc<Mutex<WorkflowBudgetUsage>>, CancellationToken) {
1005 let active = self.active.get(run_id).unwrap();
1006 (active.ledger.clone(), active.cancellation.clone())
1007 }
1008
1009 pub async fn cancel(
1010 self: &Arc<Self>,
1011 run_id: &str,
1012 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
1013 let snapshot = self
1014 .repository
1015 .load(run_id)
1016 .await
1017 .map_err(storage)?
1018 .ok_or(WorkflowRunError::NotFound)?;
1019 if snapshot.status == WorkflowRunStatus::Cancelled {
1020 return Ok(snapshot);
1021 }
1022 if snapshot.status.is_terminal() {
1023 return Err(WorkflowRunError::Terminal);
1024 }
1025 let (active, _registration) = match self.active.entry(run_id.to_string()) {
1029 Entry::Occupied(entry) => (entry.get().clone(), None),
1030 Entry::Vacant(entry) => {
1031 let active = Arc::new(ActiveRun {
1032 cancellation: CancellationToken::new(),
1033 snapshot: Arc::new(Mutex::new(snapshot)),
1034 root_run_id: run_id.to_string(),
1035 ledger: Arc::new(Mutex::new(WorkflowBudgetUsage::default())),
1036 drain_lock: Arc::new(Mutex::new(())),
1037 cleanup_pending: AtomicBool::new(false),
1038 has_agents: false,
1039 });
1040 entry.insert(active.clone());
1041 (
1042 active,
1043 Some(RuntimeRegistration {
1044 engine: Arc::downgrade(self),
1045 run_id: run_id.to_string(),
1046 }),
1047 )
1048 }
1049 };
1050 {
1051 active.cancellation.cancel();
1052 if !active.has_agents {
1054 let mut shared = active.snapshot.lock().await;
1055 if !shared.status.is_terminal() {
1056 self.finish_cancelled(&mut shared).await?;
1057 }
1058 return Ok(shared.clone());
1059 }
1060 let _drain_guard = active.drain_lock.lock().await;
1061 {
1062 let snapshot = active.snapshot.lock().await;
1063 if snapshot.status.is_terminal() {
1064 return Ok(snapshot.clone());
1065 }
1066 }
1067 let mut usage_unavailable = false;
1068 let mut ledger = active.ledger.lock().await;
1069 let (drained, drain_error) = self.agents.drain_cancelled(&active.root_run_id).await;
1070 let usage = {
1071 let usage = &mut *ledger;
1072 for result in drained {
1073 match result {
1074 Ok(result) => {
1075 usage.tokens = usage.tokens.saturating_add(result.tokens);
1076 usage.cost_micros = usage
1077 .cost_micros
1078 .zip(result.cost_micros)
1079 .map(|(a, b)| a.saturating_add(b));
1080 }
1081 Err(_) => {
1082 usage_unavailable = true;
1083 usage.cost_micros = None;
1084 }
1085 }
1086 }
1087 usage.clone()
1088 };
1089 drop(ledger);
1090 {
1091 let mut shared = active.snapshot.lock().await;
1092 self.transition(
1093 &mut shared,
1094 None,
1095 WorkflowRunEventKind::Phase {
1096 name: "AgentDrained".into(),
1097 },
1098 |snapshot| {
1099 snapshot.usage.tokens = usage.tokens;
1100 snapshot.usage.cost_micros = usage.cost_micros;
1101 },
1102 )
1103 .await?;
1104 }
1105 if drain_error.is_some() {
1106 active.cleanup_pending.store(true, Ordering::Release);
1107 return Err(WorkflowRunError::Preflight(
1108 "Workflow children have not confirmed stop; cancellation can be retried".into(),
1109 ));
1110 }
1111 let mut shared = active.snapshot.lock().await;
1112 if !shared.status.is_terminal() {
1113 if usage_unavailable {
1114 self.finish_failed(&mut shared, failure(WorkflowFailureCode::ExecutionFailed,
1115 "workflow Agent stopped but cumulative token observation unavailable; usage is not measured", false)).await?;
1116 } else {
1117 self.finish_cancelled(&mut shared).await?;
1118 }
1119 }
1120 let result = shared.clone();
1121 if active.cleanup_pending.swap(false, Ordering::AcqRel) {
1122 self.active.remove(run_id);
1123 self.events.remove(run_id);
1124 }
1125 Ok(result)
1126 }
1127 }
1128
1129 pub async fn recover(&self) -> Result<Vec<WorkflowRunSnapshot>, WorkflowRunError> {
1130 let mut recovered = Vec::new();
1131 for run_id in self.repository.list_run_ids().await.map_err(storage)? {
1132 let Some(mut snapshot) = self.repository.load(&run_id).await.map_err(storage)? else {
1133 continue;
1134 };
1135 if matches!(
1136 snapshot.status,
1137 WorkflowRunStatus::Queued | WorkflowRunStatus::Running
1138 ) {
1139 let reason = "process restarted; explicit safe restart is required".to_string();
1140 let active_steps = snapshot
1141 .steps
1142 .iter()
1143 .filter(|(_, step)| {
1144 matches!(
1145 step.status,
1146 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1147 )
1148 })
1149 .map(|(id, _)| id.clone())
1150 .collect::<Vec<_>>();
1151 for step_id in active_steps {
1152 let state_id = step_id.clone();
1153 let step_reason = reason.clone();
1154 self.transition(
1155 &mut snapshot,
1156 Some(step_id),
1157 WorkflowRunEventKind::StepSuspended {
1158 reason: reason.clone(),
1159 },
1160 move |snapshot| {
1161 if let Some(step) = snapshot.steps.get_mut(&state_id) {
1162 step.status = WorkflowStepStatus::Suspended;
1163 step.failure = Some(failure(
1164 WorkflowFailureCode::RecoverySuspended,
1165 step_reason,
1166 true,
1167 ));
1168 }
1169 },
1170 )
1171 .await?;
1172 }
1173 self.transition(
1174 &mut snapshot,
1175 None,
1176 WorkflowRunEventKind::RunSuspended {
1177 reason: reason.clone(),
1178 },
1179 move |snapshot| {
1180 snapshot.status = WorkflowRunStatus::Suspended;
1181 snapshot.suspension = Some(WorkflowSuspensionContext::Recovery {
1182 reason: reason.clone(),
1183 });
1184 },
1185 )
1186 .await?;
1187 recovered.push(snapshot);
1188 }
1189 }
1190 Ok(recovered)
1191 }
1192
1193 pub fn subscribe(&self, run_id: &str) -> Option<broadcast::Receiver<WorkflowRunEvent>> {
1194 self.events.get(run_id).map(|sender| sender.subscribe())
1195 }
1196
1197 #[cfg(test)]
1198 pub(crate) fn runtime_resource_counts(&self) -> (usize, usize) {
1199 (self.active.len(), self.events.len())
1200 }
1201
1202 async fn pin_and_validate_bundle(
1203 &self,
1204 root: &WorkflowRunDefinition,
1205 ) -> Result<WorkflowDefinitionBundle, WorkflowRunError> {
1206 let bundle =
1207 self.definitions.pin_bundle(root).await.map_err(|_| {
1208 WorkflowRunError::Preflight("workflow bundle pin failed".to_string())
1209 })?;
1210 self.validate_bundle(root, &bundle)?;
1211 Ok(bundle)
1212 }
1213
1214 fn validate_bundle(
1215 &self,
1216 root: &WorkflowRunDefinition,
1217 bundle: &WorkflowDefinitionBundle,
1218 ) -> Result<(), WorkflowRunError> {
1219 if bundle.root_id != root.id
1220 || bundle.root_revision != root.revision
1221 || bundle.root() != Some(root)
1222 {
1223 return Err(WorkflowRunError::Preflight(
1224 "pinned bundle root identity/content mismatch".to_string(),
1225 ));
1226 }
1227 let serialized = serde_json::to_value(bundle).map_err(|_| {
1228 WorkflowRunError::Preflight("workflow bundle is not serializable".into())
1229 })?;
1230 reject_secret_material_in_definition(&serialized).map_err(WorkflowRunError::Preflight)?;
1231 let mut stack = vec![(root.id.clone(), root.revision, Vec::<String>::new())];
1232 let mut visited = BTreeSet::new();
1233 while let Some((id, revision, path)) = stack.pop() {
1234 let key = WorkflowDefinitionBundle::key(&id, revision);
1235 if path.contains(&key) {
1236 return Err(WorkflowRunError::Preflight(format!(
1237 "nested workflow cycle includes {key}"
1238 )));
1239 }
1240 if !visited.insert(key.clone()) {
1241 continue;
1242 }
1243 let definition = bundle.get(&id, revision).ok_or_else(|| {
1244 WorkflowRunError::Preflight(format!("pinned bundle is missing {key}"))
1245 })?;
1246 if definition.id != id || definition.revision != revision {
1247 return Err(WorkflowRunError::Preflight(
1248 "pinned bundle definition identity mismatch".to_string(),
1249 ));
1250 }
1251 let compiled = CompiledWorkflow::compile(definition.clone())?;
1252 let mut nested_path = path;
1253 nested_path.push(key);
1254 for step in compiled.steps.values() {
1255 if let WorkflowStepKind::Workflow {
1256 workflow_id,
1257 revision,
1258 args,
1259 } = &step.kind
1260 {
1261 let nested = bundle.get(workflow_id, *revision).ok_or_else(|| {
1262 WorkflowRunError::Preflight(format!(
1263 "pinned bundle is missing {workflow_id}@{revision}"
1264 ))
1265 })?;
1266 validate_nested_input_contract(args, &nested.input_schema, &compiled)
1267 .map_err(WorkflowRunError::Preflight)?;
1268 stack.push((workflow_id.clone(), *revision, nested_path.clone()));
1269 }
1270 }
1271 }
1272 Ok(())
1273 }
1274
1275 async fn preflight_bundle(
1276 &self,
1277 bundle: &WorkflowDefinitionBundle,
1278 session_id: &str,
1279 allowed: &BTreeSet<String>,
1280 trusted: bool,
1281 root_limits: &WorkflowBudgets,
1282 ) -> Result<HashMap<String, NamedAgentSpec>, WorkflowRunError> {
1283 let mut pinned_agents = HashMap::<String, NamedAgentSpec>::new();
1284 let mut stack = vec![(bundle.root_id.clone(), bundle.root_revision, 0_u32)];
1285 let mut visited = BTreeSet::new();
1286 while let Some((id, revision, depth)) = stack.pop() {
1287 if depth >= root_limits.max_nesting_depth {
1288 return Err(WorkflowRunError::Preflight(
1289 "nested workflow depth exceeded shared root limit".to_string(),
1290 ));
1291 }
1292 if !visited.insert(WorkflowDefinitionBundle::key(&id, revision)) {
1293 continue;
1294 }
1295 let definition = bundle.get(&id, revision).ok_or_else(|| {
1296 WorkflowRunError::Preflight("pinned workflow definition missing".to_string())
1297 })?;
1298 enforce_budget_within(&definition.budgets, root_limits).map_err(|message| {
1299 WorkflowRunError::Preflight(format!(
1300 "nested workflow budget expands root: {message}"
1301 ))
1302 })?;
1303 let compiled = CompiledWorkflow::compile(definition.clone())?;
1304 for step in compiled.steps.values() {
1305 let (target, capabilities) = match &step.kind {
1306 WorkflowStepKind::Tool {
1307 tool, capabilities, ..
1308 } => (WorkflowPolicyTarget::Tool(tool.clone()), capabilities),
1309 WorkflowStepKind::Agent {
1310 agent,
1311 capabilities,
1312 ..
1313 } => {
1314 let spec = if let Some(spec) = pinned_agents.get(agent) {
1315 spec.clone()
1316 } else {
1317 let spec = self
1318 .agents
1319 .resolve(agent, session_id)
1320 .await
1321 .map_err(|_| {
1322 WorkflowRunError::Preflight(
1323 "named agent resolution failed".to_string(),
1324 )
1325 })?
1326 .ok_or_else(|| {
1327 WorkflowRunError::Preflight(format!(
1328 "unknown named agent '{agent}'"
1329 ))
1330 })?;
1331 if spec.name != *agent {
1332 return Err(WorkflowRunError::Preflight(
1333 "named agent resolver returned mismatched identity".to_string(),
1334 ));
1335 }
1336 pinned_agents.insert(agent.clone(), spec.clone());
1337 spec
1338 };
1339 if (root_limits.max_cost_micros.is_some()
1340 || compiled.definition.budgets.max_cost_micros.is_some())
1341 && !spec.cost_supported
1342 {
1343 return Err(WorkflowRunError::UnsupportedMonetaryBudget);
1344 }
1345 if !capabilities
1346 .iter()
1347 .all(|capability| spec.allowed_capabilities.contains(capability))
1348 {
1349 return Err(WorkflowRunError::Preflight(format!(
1350 "agent '{agent}' capability expansion denied"
1351 )));
1352 }
1353 (WorkflowPolicyTarget::Agent(agent.clone()), capabilities)
1354 }
1355 WorkflowStepKind::Workflow {
1356 workflow_id,
1357 revision,
1358 args,
1359 } => {
1360 let nested = bundle.get(workflow_id, *revision).ok_or_else(|| {
1361 WorkflowRunError::Preflight(format!(
1362 "missing pinned workflow {workflow_id}@{revision}"
1363 ))
1364 })?;
1365 validate_nested_input_contract(args, &nested.input_schema, &compiled)
1366 .map_err(WorkflowRunError::Preflight)?;
1367 stack.push((workflow_id.clone(), *revision, depth + 1));
1368 (
1369 WorkflowPolicyTarget::Workflow {
1370 id: workflow_id.clone(),
1371 revision: *revision,
1372 },
1373 &Vec::new(),
1374 )
1375 }
1376 };
1377 let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
1378 if !requested.is_subset(allowed) {
1379 return Err(WorkflowRunError::Preflight(format!(
1380 "step '{}' exceeds root capabilities",
1381 step.id
1382 )));
1383 }
1384 if let PermissionDecision::Deny(_reason) = self
1385 .policy
1386 .authorize(session_id, &target, &requested, trusted)
1387 .await
1388 {
1389 return Err(WorkflowRunError::Preflight(
1390 "workflow policy denied this step".to_string(),
1391 ));
1392 }
1393 }
1394 }
1395 Ok(pinned_agents)
1396 }
1397
1398 fn enforce_ceilings(&self, budget: &WorkflowBudgets) -> Result<(), WorkflowRunError> {
1399 if budget.max_concurrency > self.ceilings.max_concurrency
1400 || budget.max_agents > self.ceilings.max_agents
1401 || budget.max_steps > self.ceilings.max_steps
1402 || budget.max_retries > self.ceilings.max_retries
1403 || budget.max_nesting_depth > self.ceilings.max_nesting_depth
1404 || budget.wall_time_ms > self.ceilings.wall_time_ms
1405 || exceeds_optional(budget.max_tokens, self.ceilings.max_tokens)
1406 || exceeds_optional(budget.max_cost_micros, self.ceilings.max_cost_micros)
1407 {
1408 return Err(WorkflowRunError::Preflight(
1409 "definition exceeds server workflow budget ceilings".to_string(),
1410 ));
1411 }
1412 Ok(())
1413 }
1414
1415 async fn transition(
1416 &self,
1417 snapshot: &mut WorkflowRunSnapshot,
1418 step_id: Option<String>,
1419 kind: WorkflowRunEventKind,
1420 mutate: impl FnOnce(&mut WorkflowRunSnapshot),
1421 ) -> Result<(), WorkflowRunError> {
1422 let mut candidate = self
1427 .repository
1428 .load(&snapshot.run_id)
1429 .await
1430 .map_err(storage)?
1431 .ok_or_else(|| WorkflowRunError::Storage("workflow snapshot missing".to_string()))?;
1432 mutate(&mut candidate);
1433 candidate.last_sequence += 1;
1434 candidate.updated_at = Utc::now();
1435 let event = event(&candidate, step_id, kind);
1436 let repository = self.repository.clone();
1437 let durable_candidate = candidate.clone();
1438 let durable_event = event.clone();
1439 let commit =
1440 tokio::spawn(
1441 async move { repository.commit(&durable_candidate, &durable_event).await },
1442 );
1443 commit
1444 .await
1445 .map_err(|error| {
1446 WorkflowRunError::Storage(format!("workflow commit task failed: {error}"))
1447 })?
1448 .map_err(storage)?;
1449 *snapshot = candidate;
1450 self.publish(&event);
1451 Ok(())
1452 }
1453
1454 fn publish(&self, event: &WorkflowRunEvent) {
1455 if let Some(sender) = self.events.get(&event.run_id) {
1456 let _ = sender.send(event.clone());
1457 }
1458 }
1459
1460 async fn finish_succeeded(
1461 &self,
1462 snapshot: &mut WorkflowRunSnapshot,
1463 output: Value,
1464 ) -> Result<(), WorkflowRunError> {
1465 if snapshot.status.is_terminal() {
1466 return Ok(());
1467 }
1468 let copy = output.clone();
1469 self.transition(
1470 snapshot,
1471 None,
1472 WorkflowRunEventKind::RunSucceeded { output },
1473 move |snapshot| {
1474 snapshot.status = WorkflowRunStatus::Succeeded;
1475 snapshot.output = Some(copy);
1476 },
1477 )
1478 .await
1479 }
1480 async fn finish_failed(
1481 &self,
1482 snapshot: &mut WorkflowRunSnapshot,
1483 error: WorkflowFailure,
1484 ) -> Result<(), WorkflowRunError> {
1485 if snapshot.status.is_terminal() {
1486 return Ok(());
1487 }
1488 let copy = error.clone();
1489 self.transition(
1490 snapshot,
1491 None,
1492 WorkflowRunEventKind::RunFailed { failure: error },
1493 move |snapshot| {
1494 snapshot.status = WorkflowRunStatus::Failed;
1495 snapshot.failure = Some(copy);
1496 },
1497 )
1498 .await
1499 }
1500 async fn finish_cancelled(
1501 &self,
1502 snapshot: &mut WorkflowRunSnapshot,
1503 ) -> Result<(), WorkflowRunError> {
1504 if snapshot.status == WorkflowRunStatus::Cancelled {
1505 return Ok(());
1506 }
1507 let active_steps = snapshot
1508 .steps
1509 .iter()
1510 .filter(|(_, step)| {
1511 matches!(
1512 step.status,
1513 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1514 )
1515 })
1516 .map(|(id, _)| id.clone())
1517 .collect::<Vec<_>>();
1518 for step_id in active_steps {
1519 let state_id = step_id.clone();
1520 self.transition(
1521 snapshot,
1522 Some(step_id),
1523 WorkflowRunEventKind::StepCancelled,
1524 move |snapshot| {
1525 if let Some(step) = snapshot.steps.get_mut(&state_id) {
1526 step.status = WorkflowStepStatus::Cancelled;
1527 step.failure = Some(failure(
1528 WorkflowFailureCode::Cancelled,
1529 "workflow cancelled",
1530 false,
1531 ));
1532 }
1533 },
1534 )
1535 .await?;
1536 }
1537 self.transition(
1538 snapshot,
1539 None,
1540 WorkflowRunEventKind::RunCancelled,
1541 |snapshot| {
1542 snapshot.status = WorkflowRunStatus::Cancelled;
1543 snapshot.failure = Some(failure(
1544 WorkflowFailureCode::Cancelled,
1545 "workflow cancelled",
1546 false,
1547 ));
1548 },
1549 )
1550 .await
1551 }
1552
1553 async fn finish_suspended(
1554 &self,
1555 snapshot: &mut WorkflowRunSnapshot,
1556 reason: String,
1557 ) -> Result<(), WorkflowRunError> {
1558 if snapshot.status.is_terminal() {
1559 return Ok(());
1560 }
1561 self.transition(
1562 snapshot,
1563 None,
1564 WorkflowRunEventKind::RunSuspended { reason },
1565 |snapshot| {
1566 snapshot.status = WorkflowRunStatus::Suspended;
1567 },
1568 )
1569 .await
1570 }
1571
1572 async fn fail_active_steps(
1573 &self,
1574 snapshot: &mut WorkflowRunSnapshot,
1575 error: WorkflowFailure,
1576 ) -> Result<(), WorkflowRunError> {
1577 let active_steps = snapshot
1578 .steps
1579 .iter()
1580 .filter(|(_, step)| {
1581 matches!(
1582 step.status,
1583 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1584 )
1585 })
1586 .map(|(id, _)| id.clone())
1587 .collect::<Vec<_>>();
1588 for step_id in active_steps {
1589 let state_id = step_id.clone();
1590 let copy = error.clone();
1591 self.transition(
1592 snapshot,
1593 Some(step_id),
1594 WorkflowRunEventKind::StepFailed {
1595 failure: error.clone(),
1596 },
1597 move |snapshot| {
1598 if let Some(step) = snapshot.steps.get_mut(&state_id) {
1599 step.status = WorkflowStepStatus::Failed;
1600 step.failure = Some(copy);
1601 }
1602 },
1603 )
1604 .await?;
1605 }
1606 Ok(())
1607 }
1608
1609 async fn fail_timeout_frontier(
1610 &self,
1611 snapshot: &mut WorkflowRunSnapshot,
1612 plan: &WorkflowPlan,
1613 error: WorkflowFailure,
1614 ) -> Result<(), WorkflowRunError> {
1615 if snapshot.steps.values().any(|step| {
1616 matches!(
1617 step.status,
1618 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1619 )
1620 }) {
1621 return Ok(());
1622 }
1623 for step_id in plan_frontier(plan) {
1624 if snapshot.steps.contains_key(&step_id) {
1625 continue;
1626 }
1627 let state_id = step_id.clone();
1628 let state_error = error.clone();
1629 let topology = plan.step_topology(&step_id, Vec::new());
1630 self.transition(
1631 snapshot,
1632 Some(step_id),
1633 WorkflowRunEventKind::StepFailed {
1634 failure: error.clone(),
1635 },
1636 move |snapshot| {
1637 snapshot.steps.insert(
1638 state_id.clone(),
1639 WorkflowStepSnapshot {
1640 id: state_id,
1641 topology,
1642 status: WorkflowStepStatus::Failed,
1643 input_hash: String::new(),
1644 output: None,
1645 failure: Some(state_error),
1646 attempts: 0,
1647 },
1648 );
1649 },
1650 )
1651 .await?;
1652 }
1653 Ok(())
1654 }
1655}
1656
1657impl RunContext {
1658 async fn execute_root(&self, completed_prefix: usize) -> Result<Value, WorkflowFailure> {
1659 if completed_prefix == 0 {
1660 return self
1661 .execute_node(&self.compiled.definition.plan, "root")
1662 .await;
1663 }
1664 let WorkflowPlan::Sequence { nodes } = &self.compiled.definition.plan else {
1665 unreachable!("validated flat sequence");
1666 };
1667 let WorkflowPlan::Step { step } = &nodes[completed_prefix - 1] else {
1668 unreachable!("validated tool leaf");
1669 };
1670 let mut result = self.snapshot.lock().await.steps[step]
1671 .output
1672 .clone()
1673 .expect("validated completed output");
1674 for (index, node) in nodes.iter().enumerate().skip(completed_prefix) {
1675 result = self.execute_node(node, &format!("root.{index}")).await?;
1676 }
1677 Ok(result)
1678 }
1679
1680 fn execute_node<'a>(&'a self, plan: &'a WorkflowPlan, path: &'a str) -> NodeFuture<'a> {
1681 Box::pin(async move {
1682 self.check_cancelled()?;
1683 match plan {
1684 WorkflowPlan::Step { step } => self.execute_step(step, path).await,
1685 WorkflowPlan::Sequence { nodes } => {
1686 let mut result = Value::Null;
1687 for (index, node) in nodes.iter().enumerate() {
1688 match self.execute_node(node, &format!("{path}.{index}")).await {
1689 Ok(value) => result = value,
1690 Err(error) => {
1691 if error.code == WorkflowFailureCode::DependencySkipped {
1692 for remaining in &nodes[index + 1..] {
1693 self.skip_plan(
1694 remaining,
1695 "dependency requested skip_dependents",
1696 )
1697 .await?;
1698 }
1699 }
1700 return Err(error);
1701 }
1702 }
1703 }
1704 Ok(result)
1705 }
1706 WorkflowPlan::Parallel { nodes } => {
1707 let parallel_cancellation = self.branch_cancellation.child_token();
1708 let mut futures = FuturesUnordered::new();
1709 for (index, node) in nodes.iter().enumerate() {
1710 let mut child = self.clone();
1711 child.branch_cancellation = parallel_cancellation.clone();
1712 futures.push(async move {
1713 (
1714 index,
1715 child.execute_node(node, &format!("{path}.{index}")).await,
1716 )
1717 });
1718 }
1719 let mut output = vec![Value::Null; nodes.len()];
1720 while let Some((index, result)) = futures.next().await {
1721 match result {
1722 Ok(value) => output[index] = value,
1723 Err(mut error) => {
1724 parallel_cancellation.cancel();
1725 drop(futures);
1731 if !self.pinned_agents.is_empty() {
1732 let _drain_guard = self.drain_lock.lock().await;
1733 let (usage_unavailable, drain_error) =
1734 self.drain_agents().await;
1735 self.checkpoint_agent_usage("AgentDrained").await?;
1736 if usage_unavailable {
1737 return Err(failure(WorkflowFailureCode::ExecutionFailed, "workflow Agent cumulative token observation unavailable; usage is not measured", false));
1738 }
1739 if drain_error.is_some() {
1740 return Err(failure(
1741 WorkflowFailureCode::ExecutionFailed,
1742 "Workflow children have not confirmed stop",
1743 false,
1744 ));
1745 }
1746 }
1747 self.cancel_active_parallel_steps(nodes).await?;
1748 error.message =
1749 format!("parallel branch[{index}] failed: {}", error.message);
1750 return Err(error);
1751 }
1752 }
1753 }
1754 Ok(Value::Array(output))
1755 }
1756 WorkflowPlan::Choice {
1757 condition,
1758 then_branch,
1759 else_branch,
1760 } => {
1761 let condition = self.resolve_ref(condition).await?;
1762 let selected = condition.as_bool().ok_or_else(|| {
1763 failure(
1764 WorkflowFailureCode::InvalidInput,
1765 "choice condition must be a boolean",
1766 false,
1767 )
1768 })?;
1769 let (chosen, unchosen, branch) = if selected {
1770 (then_branch, else_branch, "then")
1771 } else {
1772 (else_branch, then_branch, "else")
1773 };
1774 self.skip_plan(unchosen, "conditional branch not selected")
1775 .await?;
1776 self.execute_node(chosen, &format!("{path}.{branch}")).await
1777 }
1778 WorkflowPlan::Map { source, item, body } => {
1779 let source = self.resolve_ref(source).await?;
1780 let values = source.as_array().ok_or_else(|| {
1781 failure(
1782 WorkflowFailureCode::InvalidInput,
1783 "map source must be an array",
1784 false,
1785 )
1786 })?;
1787 let used = self.ledger.lock().await.steps as usize;
1788 let remaining = (self.root_limits.max_steps as usize).saturating_sub(used);
1789 let per_item = plan_leaf_count(body).max(1);
1790 if values
1791 .len()
1792 .checked_mul(per_item)
1793 .is_none_or(|required| required > remaining)
1794 {
1795 return Err(failure(
1796 WorkflowFailureCode::BudgetExceeded,
1797 "map cardinality exceeds remaining workflow step budget",
1798 false,
1799 ));
1800 }
1801 let futures = values.iter().cloned().enumerate().map(|(index, value)| {
1802 let mut child = self.clone();
1803 child.items.insert(item.clone(), value);
1804 child.scope = format!("{}[{index}]", self.scope);
1809 child.map_index_path.push(index);
1810 async move { child.execute_node(body, &format!("{path}[{index}]")).await }
1811 });
1812 let results = join_all(futures).await;
1813 let mut values = Vec::with_capacity(results.len());
1814 let mut failures = Vec::new();
1815 for (index, result) in results.into_iter().enumerate() {
1816 match result {
1817 Ok(value) => values.push(value),
1818 Err(error) => failures.push((index, error)),
1819 }
1820 }
1821 if failures.is_empty() {
1822 Ok(Value::Array(values))
1823 } else {
1824 let retryable = failures.iter().any(|(_, error)| error.retryable);
1825 let first_code = failures[0].1.code;
1826 let code = if failures
1827 .iter()
1828 .any(|(_, error)| error.code == WorkflowFailureCode::DependencySkipped)
1829 {
1830 WorkflowFailureCode::DependencySkipped
1831 } else if failures.iter().all(|(_, error)| error.code == first_code) {
1832 first_code
1833 } else {
1834 WorkflowFailureCode::ExecutionFailed
1835 };
1836 let diagnostics = failures
1837 .into_iter()
1838 .map(|(index, error)| format!("item[{index}]: {}", error.message))
1839 .collect::<Vec<_>>()
1840 .join("; ");
1841 Err(failure(
1842 code,
1843 format!("map items failed: {diagnostics}"),
1844 retryable,
1845 ))
1846 }
1847 }
1848 WorkflowPlan::Retry {
1849 node,
1850 max_attempts,
1851 delay_ms,
1852 } => {
1853 let limit =
1854 (*max_attempts).min(self.compiled.definition.budgets.max_retries + 1);
1855 let mut last = None;
1856 for attempt in 0..limit {
1857 match self
1858 .execute_node(node, &format!("{path}.retry{attempt}"))
1859 .await
1860 {
1861 Ok(value) => return Ok(value),
1862 Err(error) if error.retryable => {
1863 last = Some(error);
1864 if attempt + 1 < limit {
1865 self.reserve_retry().await?;
1866 self.checkpoint_usage("retry_reserved").await?;
1867 tokio::select! {
1868 _ = self.cancellation.cancelled() => return Err(failure(WorkflowFailureCode::Cancelled, "workflow cancelled", false)),
1869 _ = self.branch_cancellation.cancelled() => return Err(failure(WorkflowFailureCode::Cancelled, "workflow branch cancelled", false)),
1870 _ = tokio::time::sleep(Duration::from_millis(*delay_ms)) => {}
1871 }
1872 } else {
1873 break;
1874 }
1875 }
1876 Err(error) => return Err(error),
1877 }
1878 }
1879 Err(failure(
1880 WorkflowFailureCode::RetryExhausted,
1881 last.map_or_else(|| "retry exhausted".to_string(), |error| error.message),
1882 false,
1883 ))
1884 }
1885 }
1886 })
1887 }
1888
1889 async fn execute_step(&self, step_id: &str, _path: &str) -> Result<Value, WorkflowFailure> {
1890 self.check_cancelled()?;
1891 let step = self.compiled.steps.get(step_id).cloned().ok_or_else(|| {
1892 failure(
1893 WorkflowFailureCode::UnknownReference,
1894 format!("unknown step {step_id}"),
1895 false,
1896 )
1897 })?;
1898 let instance_id = self.instance_id(step_id);
1899 let input = match &step.kind {
1900 WorkflowStepKind::Tool { args, .. } | WorkflowStepKind::Workflow { args, .. } => {
1901 self.resolve_template(args).await?
1902 }
1903 WorkflowStepKind::Agent { prompt, .. } => self.resolve_template(prompt).await?,
1904 };
1905 let input_hash = hex::encode(Sha256::digest(
1906 serde_json::to_vec(&input).unwrap_or_default(),
1907 ));
1908 self.reserve_step().await?;
1909 self.checkpoint_usage("step_reserved").await?;
1910 self.step_transition(&instance_id, WorkflowRunEventKind::StepQueued, |snapshot| {
1911 let state = snapshot
1912 .steps
1913 .entry(instance_id.clone())
1914 .or_insert_with(|| WorkflowStepSnapshot {
1915 id: instance_id.clone(),
1916 topology: self
1917 .compiled
1918 .definition
1919 .plan
1920 .step_topology(step_id, self.map_index_path.clone()),
1921 status: WorkflowStepStatus::Queued,
1922 input_hash: input_hash.clone(),
1923 output: None,
1924 failure: None,
1925 attempts: 0,
1926 });
1927 state.status = WorkflowStepStatus::Queued;
1928 state.input_hash = input_hash;
1929 state.output = None;
1930 state.failure = None;
1931 })
1932 .await?;
1933 let _permit = if matches!(&step.kind, WorkflowStepKind::Workflow { .. }) {
1934 None
1935 } else {
1936 Some(tokio::select! {
1937 _ = self.cancellation.cancelled() => {
1938 let cancelled_id = instance_id.clone();
1939 self.step_transition(&instance_id, WorkflowRunEventKind::StepCancelled, move |snapshot| {
1940 if let Some(state) = snapshot.steps.get_mut(&cancelled_id) { state.status = WorkflowStepStatus::Cancelled; }
1941 }).await?;
1942 return Err(failure(WorkflowFailureCode::Cancelled, "workflow cancelled", false));
1943 }
1944 _ = self.branch_cancellation.cancelled() => {
1945 let cancelled_id = instance_id.clone();
1946 self.step_transition(&instance_id, WorkflowRunEventKind::StepCancelled, move |snapshot| {
1947 if let Some(state) = snapshot.steps.get_mut(&cancelled_id) {
1948 state.status = WorkflowStepStatus::Cancelled;
1949 }
1950 }).await?;
1951 return Err(failure(WorkflowFailureCode::Cancelled, "workflow branch cancelled", false));
1952 }
1953 permit = self.semaphore.acquire() => permit.map_err(|_| failure(WorkflowFailureCode::ExecutionFailed, "workflow semaphore closed", false))?,
1954 })
1955 };
1956 let started_id = instance_id.clone();
1957 self.step_transition(
1958 &instance_id,
1959 WorkflowRunEventKind::StepStarted,
1960 move |snapshot| {
1961 if let Some(state) = snapshot.steps.get_mut(&started_id) {
1962 state.status = WorkflowStepStatus::Running;
1963 state.attempts += 1;
1964 }
1965 },
1966 )
1967 .await?;
1968 let result = self.dispatch(&step, input, &instance_id).await;
1969 let result = match result {
1970 Ok(output) => {
1971 if let Some(schema) = &step.output_schema {
1972 validate_schema(schema, &output)
1973 .map(|()| output)
1974 .map_err(|message| {
1975 failure(WorkflowFailureCode::InvalidOutput, message, false)
1976 })
1977 } else {
1978 Ok(output)
1979 }
1980 }
1981 Err(error) => Err(error),
1982 };
1983 let result = result.and_then(|output| {
1984 reject_secret_material(&output)
1985 .map(|()| output)
1986 .map_err(|message| failure(WorkflowFailureCode::InvalidOutput, message, false))
1987 });
1988 match result {
1989 Ok(output) => {
1990 let copy = output.clone();
1991 let completed_id = instance_id.clone();
1992 self.step_transition(
1993 &instance_id,
1994 WorkflowRunEventKind::StepCompleted {
1995 output: output.clone(),
1996 },
1997 move |snapshot| {
1998 if let Some(state) = snapshot.steps.get_mut(&completed_id) {
1999 state.status = WorkflowStepStatus::Succeeded;
2000 state.output = Some(copy);
2001 }
2002 },
2003 )
2004 .await?;
2005 Ok(output)
2006 }
2007 Err(error) => {
2008 if error.code == WorkflowFailureCode::Cancelled {
2009 let cancelled_id = instance_id.clone();
2010 self.step_transition(
2011 &instance_id,
2012 WorkflowRunEventKind::StepCancelled,
2013 move |snapshot| {
2014 if let Some(state) = snapshot.steps.get_mut(&cancelled_id) {
2015 state.status = WorkflowStepStatus::Cancelled;
2016 state.failure = Some(failure(
2017 WorkflowFailureCode::Cancelled,
2018 "workflow branch cancelled",
2019 false,
2020 ));
2021 }
2022 },
2023 )
2024 .await?;
2025 return Err(error);
2026 }
2027 if error.code == WorkflowFailureCode::Suspended {
2028 let reason = error.message.clone();
2029 let suspended_id = instance_id.clone();
2030 self.step_transition(
2031 &instance_id,
2032 WorkflowRunEventKind::StepSuspended {
2033 reason: reason.clone(),
2034 },
2035 move |snapshot| {
2036 if let Some(state) = snapshot.steps.get_mut(&suspended_id) {
2037 state.status = WorkflowStepStatus::Suspended;
2038 state.failure =
2039 Some(failure(WorkflowFailureCode::Suspended, reason, true));
2040 }
2041 },
2042 )
2043 .await?;
2044 return Err(error);
2045 }
2046 let copy = error.clone();
2047 let failed_id = instance_id.clone();
2048 self.step_transition(
2049 &instance_id,
2050 WorkflowRunEventKind::StepFailed {
2051 failure: error.clone(),
2052 },
2053 move |snapshot| {
2054 if let Some(state) = snapshot.steps.get_mut(&failed_id) {
2055 state.status = WorkflowStepStatus::Failed;
2056 state.failure = Some(copy);
2057 }
2058 },
2059 )
2060 .await?;
2061 match step.failure {
2062 FailurePolicy::ContinueWithError => Ok(serde_json::json!({"error": error})),
2063 FailurePolicy::SkipDependents => Err(failure(
2064 WorkflowFailureCode::DependencySkipped,
2065 format!("{} (dependents skipped)", error.message),
2066 false,
2067 )),
2068 FailurePolicy::FailFast => Err(error),
2069 }
2070 }
2071 }
2072 }
2073
2074 async fn dispatch(
2075 &self,
2076 step: &WorkflowStepDefinition,
2077 input: Value,
2078 instance_id: &str,
2079 ) -> Result<Value, WorkflowFailure> {
2080 let session_id = { self.snapshot.lock().await.session_id.clone() };
2081 match &step.kind {
2082 WorkflowStepKind::Tool {
2083 tool, capabilities, ..
2084 } => {
2085 self.authorize(
2086 &session_id,
2087 WorkflowPolicyTarget::Tool(tool.clone()),
2088 capabilities,
2089 )
2090 .await?;
2091 let (resolved_input, resolved_secrets) =
2092 self.resolve_secret_handles(&input, &session_id).await?;
2093 let arguments = serde_json::to_string(&resolved_input).map_err(|error| {
2094 failure(WorkflowFailureCode::InvalidInput, error.to_string(), false)
2095 })?;
2096 let call = ToolCall {
2097 id: format!("workflow-{}", Uuid::new_v4()),
2098 tool_type: "function".to_string(),
2099 function: FunctionCall {
2100 name: tool.clone(),
2101 arguments,
2102 },
2103 };
2104 let permission_port = self
2105 .engine
2106 .session_permissions
2107 .read()
2108 .unwrap_or_else(std::sync::PoisonError::into_inner)
2109 .clone();
2110 let session_flags = match permission_port {
2111 Some(port) => port.flags_for_session(&session_id).await.map_err(|error| {
2112 failure(
2113 WorkflowFailureCode::ExecutionFailed,
2114 format!("workflow session permission posture is unavailable: {error}"),
2115 true,
2116 )
2117 })?,
2118 None => ToolExecutionSessionFlags::default(),
2119 };
2120 let context = ToolExecutionContext {
2121 executing_supervisor: None,
2122 session_id: Some(&session_id),
2123 root_session_id: None,
2127 tool_call_id: &call.id,
2128 event_tx: None,
2129 available_tool_schemas: None,
2130 bypass_permissions: session_flags.bypass_permissions,
2131 auto_approve_permissions: session_flags.auto_approve_permissions,
2132 plan_read_only: session_flags.plan_read_only,
2133 can_async_resume: false,
2134 bash_completion_sink: None,
2135 pre_parsed_args: Some(&resolved_input),
2136 };
2137 if session_flags.plan_read_only
2138 && !bamboo_tools::orchestrator::plan_mode_allows_tool(&call.function.name)
2139 {
2140 return Err(failure(
2141 WorkflowFailureCode::ExecutionFailed,
2142 format!("Plan mode: {} operation blocked", call.function.name),
2143 false,
2144 ));
2145 }
2146 let outcome = self
2147 .engine
2148 .tools
2149 .execute_with_context_outcome(&call, context)
2150 .await
2151 .map_err(|error| {
2152 let (code, message, retryable) = match error {
2153 bamboo_agent_core::tools::ToolError::NotFound(_) => (
2154 WorkflowFailureCode::UnknownReference,
2155 "workflow tool is not available",
2156 false,
2157 ),
2158 bamboo_agent_core::tools::ToolError::InvalidArguments(_) => (
2159 WorkflowFailureCode::InvalidInput,
2160 "workflow tool arguments were rejected",
2161 false,
2162 ),
2163 bamboo_agent_core::tools::ToolError::Execution(_) => (
2164 WorkflowFailureCode::ExecutionFailed,
2165 "workflow tool execution was denied or failed",
2166 true,
2167 ),
2168 };
2169 failure(code, message, retryable)
2170 })?;
2171 match outcome {
2172 ToolOutcome::Completed(result) => {
2173 let output = parse_tool_result(result)?;
2174 if contains_any_secret_material(&output, &resolved_secrets) {
2175 return Err(failure(
2176 WorkflowFailureCode::InvalidOutput,
2177 "workflow tool output contained resolved secret material",
2178 false,
2179 ));
2180 }
2181 Ok(output)
2182 }
2183 ToolOutcome::NeedsHuman { question, .. } => {
2184 self.persist_suspension(WorkflowSuspensionContext::ToolApproval {
2185 step_id: instance_id.to_string(),
2186 tool: tool.clone(),
2187 tool_call_id: question.tool_call_id,
2188 })
2189 .await?;
2190 Err(failure(
2191 WorkflowFailureCode::Suspended,
2192 "workflow tool requires human approval",
2193 true,
2194 ))
2195 }
2196 ToolOutcome::Running(handle) => {
2197 let tool_call_id = handle.tool_call_id.clone();
2198 (handle.kill)();
2199 self.persist_suspension(WorkflowSuspensionContext::ToolRunning {
2200 step_id: instance_id.to_string(),
2201 tool: tool.clone(),
2202 tool_call_id,
2203 killed: true,
2204 })
2205 .await?;
2206 Err(failure(
2207 WorkflowFailureCode::Suspended,
2208 "workflow tool is running without a durable workflow resume handle",
2209 true,
2210 ))
2211 }
2212 }
2213 }
2214 WorkflowStepKind::Agent {
2215 agent,
2216 model,
2217 effort,
2218 capabilities,
2219 structured_output_attempts,
2220 ..
2221 } => {
2222 if contains_secret_handle(&input) {
2223 return Err(failure(
2224 WorkflowFailureCode::PermissionDenied,
2225 "secret capability handles are supported only for tool arguments",
2226 false,
2227 ));
2228 }
2229 self.authorize(
2230 &session_id,
2231 WorkflowPolicyTarget::Agent(agent.clone()),
2232 capabilities,
2233 )
2234 .await?;
2235 let spec = self.pinned_agents.get(agent).cloned().ok_or_else(|| {
2236 failure(
2237 WorkflowFailureCode::PermissionDenied,
2238 "named agent was not pinned during preflight",
2239 false,
2240 )
2241 })?;
2242 let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2243 if !requested.is_subset(&spec.allowed_capabilities) {
2244 return Err(failure(
2245 WorkflowFailureCode::PermissionDenied,
2246 "named agent capability intersection changed",
2247 false,
2248 ));
2249 }
2250 let mut last_error = None;
2251 for _ in 0..*structured_output_attempts {
2252 self.ensure_agent_usage_budget_available().await?;
2253 self.reserve_agent().await?;
2254 if !spec.cost_supported {
2255 self.ledger.lock().await.cost_micros = None;
2256 }
2257 self.checkpoint_usage("agent_reserved").await?;
2258 match self
2259 .engine
2260 .agents
2261 .execute(
2262 &spec,
2263 input.clone(),
2264 model.as_deref(),
2265 effort.as_deref(),
2266 &requested,
2267 &session_id,
2268 &self.root_run_id,
2269 self.branch_cancellation.child_token(),
2270 )
2271 .await
2272 {
2273 Ok(result) => {
2274 let _drain_guard = self.drain_lock.lock().await;
2275 let mut usage = self.ledger.lock().await;
2276 if result
2277 .attempt_id
2278 .as_ref()
2279 .is_some_and(|id| !self.engine.agents.acknowledge_result(id))
2280 {
2281 self.check_cancelled()?;
2282 return Err(failure(
2283 WorkflowFailureCode::Cancelled,
2284 "workflow Agent result already drained",
2285 false,
2286 ));
2287 }
2288 let exceeded =
2290 self.record_usage(&mut usage, result.tokens, result.cost_micros);
2291 drop(usage);
2292 self.checkpoint_usage("agent_usage_recorded").await?;
2293 self.check_cancelled()?;
2294 if let Some(error) = result.failure {
2295 return Err(error);
2296 }
2297 if let Some(error) = exceeded {
2298 return Err(error);
2299 }
2300 if let Some(schema) = &step.output_schema {
2301 if let Err(error) = validate_schema(schema, &result.output) {
2302 last_error = Some(error);
2303 continue;
2304 }
2305 }
2306 return Ok(result.output);
2307 }
2308 Err(_error) => {
2309 self.check_cancelled()?;
2310 return Err(failure(
2311 WorkflowFailureCode::ExecutionFailed,
2312 "named agent execution or usage observation unavailable",
2313 false,
2314 ));
2315 }
2316 }
2317 }
2318 Err(failure(
2319 WorkflowFailureCode::InvalidOutput,
2320 last_error.unwrap_or_else(|| "agent structured output exhausted".to_string()),
2321 false,
2322 ))
2323 }
2324 WorkflowStepKind::Workflow {
2325 workflow_id,
2326 revision,
2327 ..
2328 } => {
2329 if self.depth + 1 >= self.root_limits.max_nesting_depth {
2330 return Err(failure(
2331 WorkflowFailureCode::BudgetExceeded,
2332 "nested workflow depth exceeded",
2333 false,
2334 ));
2335 }
2336 let definition = self
2337 .bundle
2338 .get(workflow_id, *revision)
2339 .cloned()
2340 .ok_or_else(|| {
2341 failure(
2342 WorkflowFailureCode::UnknownReference,
2343 format!("persisted bundle missing workflow {workflow_id}@{revision}"),
2344 false,
2345 )
2346 })?;
2347 let nested = StartWorkflowRun {
2348 definition,
2349 args: input,
2350 session_id,
2351 workspace_trusted: self.workspace_trusted,
2352 allowed_capabilities: self.allowed_capabilities.iter().cloned().collect(),
2353 };
2354 let parent_run_id = self.snapshot.lock().await.run_id.clone();
2355 let result = Box::pin(self.engine.run_internal(
2356 nested,
2357 self.bundle.clone(),
2358 self.pinned_agents.clone(),
2359 Some(parent_run_id),
2360 Some(instance_id.to_string()),
2361 self.depth + 1,
2362 self.branch_cancellation.clone(),
2363 self.ledger.clone(),
2364 self.root_limits.clone(),
2365 self.semaphore.clone(),
2366 None,
2367 Some(self.root_run_id.clone()),
2368 ))
2369 .await
2370 .map_err(|_error| {
2371 failure(
2372 WorkflowFailureCode::ExecutionFailed,
2373 "nested workflow execution failed",
2374 false,
2375 )
2376 })?;
2377 result.output.ok_or_else(|| {
2378 result.failure.unwrap_or_else(|| {
2379 failure(
2380 WorkflowFailureCode::ExecutionFailed,
2381 "nested workflow returned no output",
2382 false,
2383 )
2384 })
2385 })
2386 }
2387 }
2388 }
2389
2390 async fn authorize(
2391 &self,
2392 session_id: &str,
2393 target: WorkflowPolicyTarget,
2394 capabilities: &[String],
2395 ) -> Result<(), WorkflowFailure> {
2396 let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2397 if !requested.is_subset(&self.allowed_capabilities) {
2398 return Err(failure(
2399 WorkflowFailureCode::PermissionDenied,
2400 "step capability exceeds root policy",
2401 false,
2402 ));
2403 }
2404 match self
2405 .engine
2406 .policy
2407 .authorize(session_id, &target, &requested, self.workspace_trusted)
2408 .await
2409 {
2410 PermissionDecision::Allow => Ok(()),
2411 PermissionDecision::Deny(_reason) => Err(failure(
2412 if self.workspace_trusted {
2413 WorkflowFailureCode::PermissionDenied
2414 } else {
2415 WorkflowFailureCode::UntrustedWorkspace
2416 },
2417 "workflow policy denied this step",
2418 false,
2419 )),
2420 }
2421 }
2422
2423 async fn step_transition(
2424 &self,
2425 step_id: &str,
2426 kind: WorkflowRunEventKind,
2427 mutate: impl FnOnce(&mut WorkflowRunSnapshot),
2428 ) -> Result<(), WorkflowFailure> {
2429 let usage = self.ledger.lock().await.clone();
2430 let mut snapshot = self.snapshot.lock().await;
2431 snapshot.usage = usage;
2432 self.engine
2433 .transition(&mut snapshot, Some(step_id.to_string()), kind, mutate)
2434 .await
2435 .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2436 }
2437
2438 async fn reserve_step(&self) -> Result<(), WorkflowFailure> {
2439 let mut usage = self.ledger.lock().await;
2440 if usage.steps >= self.root_limits.max_steps {
2441 return Err(failure(
2442 WorkflowFailureCode::BudgetExceeded,
2443 "workflow step budget exceeded",
2444 false,
2445 ));
2446 }
2447 usage.steps += 1;
2448 Ok(())
2449 }
2450
2451 async fn reserve_retry(&self) -> Result<(), WorkflowFailure> {
2452 let mut usage = self.ledger.lock().await;
2453 if usage.retries >= self.root_limits.max_retries {
2454 return Err(failure(
2455 WorkflowFailureCode::BudgetExceeded,
2456 "workflow retry budget exceeded",
2457 false,
2458 ));
2459 }
2460 usage.retries += 1;
2461 Ok(())
2462 }
2463
2464 async fn reserve_agent(&self) -> Result<(), WorkflowFailure> {
2465 let mut usage = self.ledger.lock().await;
2466 if usage.agents >= self.root_limits.max_agents {
2467 return Err(failure(
2468 WorkflowFailureCode::BudgetExceeded,
2469 "workflow agent budget exceeded",
2470 false,
2471 ));
2472 }
2473 usage.agents += 1;
2474 Ok(())
2475 }
2476
2477 fn record_usage(
2478 &self,
2479 usage: &mut WorkflowBudgetUsage,
2480 tokens: u64,
2481 cost_micros: Option<u64>,
2482 ) -> Option<WorkflowFailure> {
2483 let next_tokens = usage.tokens.saturating_add(tokens);
2484 let next_cost = usage
2485 .cost_micros
2486 .zip(cost_micros)
2487 .map(|(a, b)| a.saturating_add(b));
2488 usage.tokens = next_tokens;
2489 usage.cost_micros = next_cost;
2490 if self
2491 .root_limits
2492 .max_tokens
2493 .is_some_and(|limit| next_tokens > limit)
2494 || self
2495 .root_limits
2496 .max_cost_micros
2497 .is_some_and(|limit| next_cost.is_none_or(|cost| cost > limit))
2498 {
2499 return Some(failure(
2500 WorkflowFailureCode::BudgetExceeded,
2501 "workflow token/cost budget exceeded",
2502 false,
2503 ));
2504 }
2505 None
2506 }
2507
2508 async fn drain_agents(&self) -> (bool, Option<String>) {
2509 let mut ledger = self.ledger.lock().await;
2512 let (results, error) = self.engine.agents.drain_cancelled(&self.root_run_id).await;
2513 let mut unavailable = false;
2514 for result in results {
2515 match result {
2516 Ok(result) => {
2517 self.record_usage(&mut ledger, result.tokens, result.cost_micros);
2518 }
2519 Err(_) => {
2520 unavailable = true;
2521 ledger.cost_micros = None;
2522 }
2523 }
2524 }
2525 (unavailable, error)
2526 }
2527
2528 async fn ensure_agent_usage_budget_available(&self) -> Result<(), WorkflowFailure> {
2529 let usage = self.ledger.lock().await;
2530 if self
2531 .root_limits
2532 .max_tokens
2533 .is_some_and(|limit| usage.tokens >= limit)
2534 || self
2535 .root_limits
2536 .max_cost_micros
2537 .is_some_and(|limit| usage.cost_micros.is_none_or(|cost| cost >= limit))
2538 {
2539 return Err(failure(
2540 WorkflowFailureCode::BudgetExceeded,
2541 "workflow token/cost budget exhausted before agent dispatch",
2542 false,
2543 ));
2544 }
2545 Ok(())
2546 }
2547
2548 async fn checkpoint_agent_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2549 let usage = self.ledger.lock().await.clone();
2550 let mut snapshot = self.snapshot.lock().await;
2551 self.engine
2552 .transition(
2553 &mut snapshot,
2554 None,
2555 WorkflowRunEventKind::Phase {
2556 name: name.to_string(),
2557 },
2558 move |snapshot| {
2559 snapshot.usage.tokens = usage.tokens;
2562 snapshot.usage.cost_micros = usage.cost_micros;
2563 },
2564 )
2565 .await
2566 .map_err(|e| failure(WorkflowFailureCode::Storage, e.to_string(), false))
2567 }
2568
2569 async fn checkpoint_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2570 let usage = self.ledger.lock().await.clone();
2571 let mut snapshot = self.snapshot.lock().await;
2572 self.engine
2573 .transition(
2574 &mut snapshot,
2575 None,
2576 WorkflowRunEventKind::Phase {
2577 name: name.to_string(),
2578 },
2579 move |snapshot| snapshot.usage = usage,
2580 )
2581 .await
2582 .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2583 }
2584
2585 async fn persist_suspension(
2586 &self,
2587 context: WorkflowSuspensionContext,
2588 ) -> Result<(), WorkflowFailure> {
2589 let mut snapshot = self.snapshot.lock().await;
2590 self.engine
2591 .transition(
2592 &mut snapshot,
2593 None,
2594 WorkflowRunEventKind::Phase {
2595 name: "suspension_context_persisted".to_string(),
2596 },
2597 move |snapshot| snapshot.suspension = Some(context),
2598 )
2599 .await
2600 .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2601 }
2602
2603 async fn cancel_active_parallel_steps(
2604 &self,
2605 nodes: &[WorkflowPlan],
2606 ) -> Result<(), WorkflowFailure> {
2607 let sibling_steps = nodes
2608 .iter()
2609 .flat_map(plan_step_ids)
2610 .collect::<BTreeSet<_>>();
2611 let active = {
2612 let snapshot = self.snapshot.lock().await;
2613 snapshot
2614 .steps
2615 .iter()
2616 .filter(|(_, step)| {
2617 matches!(
2618 step.status,
2619 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
2620 ) && sibling_steps
2621 .iter()
2622 .any(|step_id| self.step_is_in_scope(step, step_id))
2623 })
2624 .map(|(id, _)| id.clone())
2625 .collect::<Vec<_>>()
2626 };
2627 for step_id in active {
2628 let state_id = step_id.clone();
2629 self.step_transition(
2630 &step_id,
2631 WorkflowRunEventKind::StepCancelled,
2632 move |snapshot| {
2633 if let Some(step) = snapshot.steps.get_mut(&state_id) {
2634 step.status = WorkflowStepStatus::Cancelled;
2635 step.failure = Some(failure(
2636 WorkflowFailureCode::Cancelled,
2637 "parallel sibling cancelled by fail_fast",
2638 false,
2639 ));
2640 }
2641 },
2642 )
2643 .await?;
2644 }
2645 Ok(())
2646 }
2647
2648 async fn resolve_secret_handles(
2649 &self,
2650 value: &Value,
2651 session_id: &str,
2652 ) -> Result<(Value, Vec<String>), WorkflowFailure> {
2653 fn walk<'a>(
2654 context: &'a RunContext,
2655 value: &'a Value,
2656 session_id: &'a str,
2657 ) -> SecretResolutionFuture<'a> {
2658 Box::pin(async move {
2659 match value {
2660 Value::Object(object) if object.contains_key("$secret") => {
2661 let handle: bamboo_domain::WorkflowSecretHandle =
2662 serde_json::from_value(value.clone()).map_err(|_| {
2663 failure(
2664 WorkflowFailureCode::InvalidInput,
2665 "malformed secret capability handle",
2666 false,
2667 )
2668 })?;
2669 let material = context
2670 .engine
2671 .secrets
2672 .resolve(session_id, &handle.capability)
2673 .await
2674 .map_err(|_| {
2675 failure(
2676 WorkflowFailureCode::PermissionDenied,
2677 "secret capability resolution denied",
2678 false,
2679 )
2680 })?;
2681 let material = material.into_exposed();
2682 Ok((Value::String(material.clone()), vec![material]))
2683 }
2684 Value::Object(object) => {
2685 let mut resolved = serde_json::Map::new();
2686 let mut secrets = Vec::new();
2687 for (key, child) in object {
2688 let (child, mut child_secrets) =
2689 walk(context, child, session_id).await?;
2690 resolved.insert(key.clone(), child);
2691 secrets.append(&mut child_secrets);
2692 }
2693 Ok((Value::Object(resolved), secrets))
2694 }
2695 Value::Array(array) => {
2696 let mut resolved = Vec::with_capacity(array.len());
2697 let mut secrets = Vec::new();
2698 for child in array {
2699 let (child, mut child_secrets) =
2700 walk(context, child, session_id).await?;
2701 resolved.push(child);
2702 secrets.append(&mut child_secrets);
2703 }
2704 Ok((Value::Array(resolved), secrets))
2705 }
2706 value => Ok((value.clone(), Vec::new())),
2707 }
2708 })
2709 }
2710 walk(self, value, session_id).await
2711 }
2712
2713 fn skip_plan<'a>(&'a self, plan: &'a WorkflowPlan, reason: &'a str) -> NodeFuture<'a> {
2714 Box::pin(async move {
2715 match plan {
2716 WorkflowPlan::Step { step } => {
2717 let instance_id = self.instance_id(step);
2718 let mut instances = {
2721 let snapshot = self.snapshot.lock().await;
2722 snapshot
2723 .steps
2724 .iter()
2725 .filter(|(_, state)| self.step_is_in_scope(state, step))
2726 .map(|(id, _)| id.clone())
2727 .collect::<Vec<_>>()
2728 };
2729 if instances.is_empty() {
2730 instances.push(instance_id);
2731 }
2732 for instance_id in instances {
2733 let reason_owned = reason.to_string();
2734 let state_id = instance_id.clone();
2735 self.step_transition(
2736 &instance_id,
2737 WorkflowRunEventKind::StepSkipped {
2738 reason: reason.to_string(),
2739 },
2740 move |snapshot| {
2741 let state = snapshot.steps.entry(state_id.clone()).or_insert(
2742 WorkflowStepSnapshot {
2743 id: state_id,
2744 topology: self
2745 .compiled
2746 .definition
2747 .plan
2748 .step_topology(step, self.map_index_path.clone()),
2749 status: WorkflowStepStatus::Skipped,
2750 input_hash: String::new(),
2751 output: None,
2752 failure: None,
2753 attempts: 0,
2754 },
2755 );
2756 state.status = WorkflowStepStatus::Skipped;
2757 state.output = None;
2758 state.failure = Some(failure(
2759 WorkflowFailureCode::DependencySkipped,
2760 reason_owned,
2761 false,
2762 ));
2763 },
2764 )
2765 .await?;
2766 }
2767 }
2768 WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
2769 for node in nodes {
2770 self.skip_plan(node, reason).await?;
2771 }
2772 }
2773 WorkflowPlan::Choice {
2774 then_branch,
2775 else_branch,
2776 ..
2777 } => {
2778 self.skip_plan(then_branch, reason).await?;
2779 self.skip_plan(else_branch, reason).await?;
2780 }
2781 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
2782 self.skip_plan(body, reason).await?;
2783 }
2784 }
2785 Ok(Value::Null)
2786 })
2787 }
2788
2789 async fn resolve_template(&self, value: &Value) -> Result<Value, WorkflowFailure> {
2790 Box::pin(self.resolve_template_inner(value)).await
2791 }
2792
2793 fn resolve_template_inner<'a>(
2794 &'a self,
2795 value: &'a Value,
2796 ) -> Pin<Box<dyn Future<Output = Result<Value, WorkflowFailure>> + Send + 'a>> {
2797 Box::pin(async move {
2798 match value {
2799 Value::Object(object) if object.get("from").is_some() => {
2800 let reference: ValueRef =
2801 serde_json::from_value(value.clone()).map_err(|error| {
2802 failure(
2803 WorkflowFailureCode::InvalidInput,
2804 format!("malformed value reference: {error}"),
2805 false,
2806 )
2807 })?;
2808 self.resolve_ref(&reference).await
2809 }
2810 Value::Object(object) => {
2811 let mut resolved = serde_json::Map::new();
2812 for (key, child) in object {
2813 resolved.insert(key.clone(), self.resolve_template_inner(child).await?);
2814 }
2815 Ok(Value::Object(resolved))
2816 }
2817 Value::Array(array) => {
2818 let mut resolved = Vec::with_capacity(array.len());
2819 for child in array {
2820 resolved.push(self.resolve_template_inner(child).await?);
2821 }
2822 Ok(Value::Array(resolved))
2823 }
2824 value => Ok(value.clone()),
2825 }
2826 })
2827 }
2828
2829 async fn resolve_ref(&self, reference: &ValueRef) -> Result<Value, WorkflowFailure> {
2830 let (root, pointer) = match reference {
2831 ValueRef::Args { pointer } => (
2832 self.snapshot.lock().await.validated_args.clone(),
2833 pointer.as_str(),
2834 ),
2835 ValueRef::Step { step, pointer } => {
2836 let snapshot = self.snapshot.lock().await;
2837 let exact = self.instance_id(step);
2838 let output = snapshot
2839 .steps
2840 .get(&exact)
2841 .or_else(|| snapshot.steps.get(step))
2842 .and_then(|state| state.output.clone())
2843 .ok_or_else(|| {
2844 failure(
2845 WorkflowFailureCode::UnknownReference,
2846 format!("step output '{step}' unavailable in execution scope"),
2847 false,
2848 )
2849 })?;
2850 (output, pointer.as_str())
2851 }
2852 ValueRef::Item { name, pointer } => (
2853 self.items.get(name).cloned().ok_or_else(|| {
2854 failure(
2855 WorkflowFailureCode::UnknownReference,
2856 format!("map item '{name}' unavailable"),
2857 false,
2858 )
2859 })?,
2860 pointer.as_str(),
2861 ),
2862 ValueRef::Literal { value } => return Ok(value.clone()),
2863 };
2864 if pointer.is_empty() {
2865 Ok(root)
2866 } else {
2867 root.pointer(pointer).cloned().ok_or_else(|| {
2868 failure(
2869 WorkflowFailureCode::UnknownReference,
2870 format!("JSON pointer '{pointer}' not found"),
2871 false,
2872 )
2873 })
2874 }
2875 }
2876
2877 fn instance_id(&self, step_id: &str) -> String {
2881 if self.scope == "root" {
2882 return step_id.to_owned();
2883 }
2884 let mut id = format!("{step_id}@{}", self.scope);
2885 while self.compiled.steps.contains_key(&id) {
2886 id.push('@');
2887 }
2888 id
2889 }
2890
2891 fn step_is_in_scope(&self, state: &WorkflowStepSnapshot, step_id: &str) -> bool {
2892 state.topology.as_ref().map_or_else(
2893 || instance_is_in_scope(&state.id, step_id, &self.scope),
2894 |topology| {
2895 topology.planned_step_id == step_id
2896 && topology.map_index_path.starts_with(&self.map_index_path)
2897 },
2898 )
2899 }
2900
2901 fn check_cancelled(&self) -> Result<(), WorkflowFailure> {
2902 if self.cancellation.is_cancelled() || self.branch_cancellation.is_cancelled() {
2903 Err(failure(
2904 WorkflowFailureCode::Cancelled,
2905 "workflow cancelled",
2906 false,
2907 ))
2908 } else {
2909 Ok(())
2910 }
2911 }
2912}
2913
2914fn event(
2915 snapshot: &WorkflowRunSnapshot,
2916 step_id: Option<String>,
2917 kind: WorkflowRunEventKind,
2918) -> WorkflowRunEvent {
2919 WorkflowRunEvent {
2920 run_id: snapshot.run_id.clone(),
2921 sequence: snapshot.last_sequence,
2922 at: Utc::now(),
2923 step_id,
2924 kind,
2925 }
2926}
2927fn failure(
2928 code: WorkflowFailureCode,
2929 message: impl Into<String>,
2930 retryable: bool,
2931) -> WorkflowFailure {
2932 WorkflowFailure {
2933 code,
2934 message: message.into(),
2935 retryable,
2936 }
2937}
2938fn storage(error: std::io::Error) -> WorkflowRunError {
2939 WorkflowRunError::Storage(error.to_string())
2940}
2941fn exceeds_optional(requested: Option<u64>, ceiling: Option<u64>) -> bool {
2942 match (requested, ceiling) {
2943 (Some(requested), Some(ceiling)) => requested > ceiling,
2944 _ => false,
2945 }
2946}
2947
2948fn enforce_budget_within(
2949 requested: &WorkflowBudgets,
2950 ceiling: &WorkflowBudgets,
2951) -> Result<(), &'static str> {
2952 if requested.max_concurrency > ceiling.max_concurrency {
2953 Err("max_concurrency")
2954 } else if requested.max_agents > ceiling.max_agents {
2955 Err("max_agents")
2956 } else if requested.max_steps > ceiling.max_steps {
2957 Err("max_steps")
2958 } else if requested.max_retries > ceiling.max_retries {
2959 Err("max_retries")
2960 } else if requested.max_nesting_depth > ceiling.max_nesting_depth {
2961 Err("max_nesting_depth")
2962 } else if requested.wall_time_ms > ceiling.wall_time_ms {
2963 Err("wall_time_ms")
2964 } else if exceeds_optional(requested.max_tokens, ceiling.max_tokens) {
2965 Err("max_tokens")
2966 } else if exceeds_optional(requested.max_cost_micros, ceiling.max_cost_micros) {
2967 Err("max_cost_micros")
2968 } else {
2969 Ok(())
2970 }
2971}
2972
2973fn remaining_wall_time(snapshot: &WorkflowRunSnapshot) -> u64 {
2974 let elapsed = Utc::now()
2975 .signed_duration_since(snapshot.created_at)
2976 .num_milliseconds()
2977 .max(0) as u64;
2978 snapshot
2979 .definition
2980 .budgets
2981 .wall_time_ms
2982 .saturating_sub(elapsed)
2983}
2984
2985fn resolve_checkpoint_template(
2986 value: &Value,
2987 args: &Value,
2988 outputs: &BTreeMap<String, Value>,
2989) -> Result<Value, WorkflowRunError> {
2990 match value {
2991 Value::Object(object) if object.contains_key("from") => {
2992 let reference: ValueRef = serde_json::from_value(value.clone())
2993 .map_err(|_| WorkflowRunError::Preflight("invalid checkpoint reference".into()))?;
2994 let (root, pointer) = match &reference {
2995 ValueRef::Args { pointer } => (args, pointer),
2996 ValueRef::Step { step, pointer } => (
2997 outputs.get(step).ok_or_else(|| {
2998 WorkflowRunError::Preflight(
2999 "checkpoint reference is not in the completed prefix".into(),
3000 )
3001 })?,
3002 pointer,
3003 ),
3004 ValueRef::Literal { value } => return Ok(value.clone()),
3005 ValueRef::Item { .. } => {
3006 return Err(WorkflowRunError::Preflight(
3007 "map references are unsupported for continuation".into(),
3008 ))
3009 }
3010 };
3011 if pointer.is_empty() {
3012 Ok(root.clone())
3013 } else {
3014 root.pointer(pointer).cloned().ok_or_else(|| {
3015 WorkflowRunError::Preflight("checkpoint reference pointer is missing".into())
3016 })
3017 }
3018 }
3019 Value::Object(object) => object
3020 .iter()
3021 .map(|(key, value)| {
3022 Ok((
3023 key.clone(),
3024 resolve_checkpoint_template(value, args, outputs)?,
3025 ))
3026 })
3027 .collect::<Result<serde_json::Map<_, _>, _>>()
3028 .map(Value::Object),
3029 Value::Array(values) => values
3030 .iter()
3031 .map(|value| resolve_checkpoint_template(value, args, outputs))
3032 .collect::<Result<Vec<_>, _>>()
3033 .map(Value::Array),
3034 value => Ok(value.clone()),
3035 }
3036}
3037
3038fn definition_bundle_hash(bundle: &WorkflowDefinitionBundle) -> Result<String, WorkflowRunError> {
3039 let bytes = serde_json::to_vec(bundle)
3040 .map_err(|_| WorkflowRunError::Preflight("workflow bundle hashing failed".to_string()))?;
3041 Ok(hex::encode(Sha256::digest(bytes)))
3042}
3043fn parse_tool_result(result: ToolResult) -> Result<Value, WorkflowFailure> {
3044 if !result.success {
3045 return Err(failure(
3046 WorkflowFailureCode::ExecutionFailed,
3047 "workflow tool reported failure",
3048 true,
3049 ));
3050 }
3051 Ok(serde_json::from_str(&result.result).unwrap_or(Value::String(result.result)))
3052}
3053fn reject_secret_material(value: &Value) -> Result<(), String> {
3054 reject_secret_material_inner(value, false)
3055}
3056
3057fn reject_secret_material_in_definition(value: &Value) -> Result<(), String> {
3058 reject_secret_material_inner(value, true)
3059}
3060
3061fn reject_secret_material_inner(value: &Value, allow_bindings: bool) -> Result<(), String> {
3062 fn walk(value: &Value, key: Option<&str>, allow_bindings: bool) -> Result<(), String> {
3063 if value.as_object().is_some_and(|object| {
3064 object.len() == 1
3065 && object
3066 .get("$secret")
3067 .and_then(Value::as_str)
3068 .is_some_and(|handle| !handle.trim().is_empty())
3069 }) {
3070 return Ok(());
3071 }
3072 let safe_binding = allow_bindings
3073 && serde_json::from_value::<ValueRef>(value.clone())
3074 .is_ok_and(|reference| !matches!(reference, ValueRef::Literal { .. }));
3075 if key.is_some_and(|key| {
3076 let normalized = key
3077 .chars()
3078 .filter(|character| character.is_ascii_alphanumeric())
3079 .flat_map(char::to_lowercase)
3080 .collect::<String>();
3081 matches!(
3082 normalized.as_str(),
3083 "secret"
3084 | "token"
3085 | "password"
3086 | "credential"
3087 | "credentials"
3088 | "apikey"
3089 | "accesskey"
3090 | "accesstoken"
3091 | "secretkey"
3092 | "privatekey"
3093 )
3094 }) && !safe_binding
3095 {
3096 return Err("secret-bearing fields are not accepted by workflow runs".to_string());
3097 }
3098 if value.as_str().is_some_and(|value| {
3099 let trimmed = value.trim();
3100 trimmed.starts_with("capability://")
3101 || trimmed.starts_with("Bearer ")
3102 || trimmed.starts_with("sk-")
3103 || trimmed.starts_with("ghp_")
3104 || trimmed.starts_with("github_pat_")
3105 }) {
3106 return Err("opaque credential handles are not enabled for workflows".to_string());
3110 }
3111 match value {
3112 Value::Object(object) => {
3113 for (key, value) in object {
3114 if key == "properties" {
3115 let properties = value.as_object().ok_or_else(|| {
3116 "workflow schema properties must be an object".to_string()
3117 })?;
3118 for schema in properties.values() {
3119 walk(schema, None, allow_bindings)?;
3120 }
3121 } else {
3122 walk(value, Some(key), allow_bindings)?;
3123 }
3124 }
3125 }
3126 Value::Array(array) => {
3127 for value in array {
3128 walk(value, None, allow_bindings)?;
3129 }
3130 }
3131 _ => {}
3132 }
3133 Ok(())
3134 }
3135 walk(value, None, allow_bindings)
3136}
3137
3138fn contains_secret_handle(value: &Value) -> bool {
3139 match value {
3140 Value::Object(object) => {
3141 object.contains_key("$secret") || object.values().any(contains_secret_handle)
3142 }
3143 Value::Array(array) => array.iter().any(contains_secret_handle),
3144 _ => false,
3145 }
3146}
3147
3148fn contains_any_secret_material(value: &Value, secrets: &[String]) -> bool {
3149 let matches = |candidate: &str| {
3150 secrets
3151 .iter()
3152 .any(|secret| !secret.is_empty() && candidate.contains(secret))
3153 };
3154 fn walk(value: &Value, matches: &impl Fn(&str) -> bool) -> bool {
3155 match value {
3156 Value::String(value) => matches(value),
3157 Value::Object(object) => object
3158 .iter()
3159 .any(|(key, value)| matches(key) || walk(value, matches)),
3160 Value::Array(array) => array.iter().any(|value| walk(value, matches)),
3161 _ => false,
3162 }
3163 }
3164 walk(value, &matches)
3165}
3166
3167fn plan_leaf_count(plan: &WorkflowPlan) -> usize {
3168 match plan {
3169 WorkflowPlan::Step { .. } => 1,
3170 WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3171 nodes.iter().fold(0usize, |total, node| {
3172 total.saturating_add(plan_leaf_count(node))
3173 })
3174 }
3175 WorkflowPlan::Choice {
3176 then_branch,
3177 else_branch,
3178 ..
3179 } => plan_leaf_count(then_branch).max(plan_leaf_count(else_branch)),
3180 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3181 plan_leaf_count(body)
3182 }
3183 }
3184}
3185
3186fn plan_step_ids(plan: &WorkflowPlan) -> Vec<String> {
3187 match plan {
3188 WorkflowPlan::Step { step } => vec![step.clone()],
3189 WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3190 nodes.iter().flat_map(plan_step_ids).collect()
3191 }
3192 WorkflowPlan::Choice {
3193 then_branch,
3194 else_branch,
3195 ..
3196 } => {
3197 let mut result = plan_step_ids(then_branch);
3198 result.extend(plan_step_ids(else_branch));
3199 result
3200 }
3201 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3202 plan_step_ids(body)
3203 }
3204 }
3205}
3206
3207fn instance_is_in_scope(instance_id: &str, step_id: &str, scope: &str) -> bool {
3208 if scope == "root" {
3209 instance_id == step_id
3210 || instance_id
3211 .strip_prefix(&format!("{step_id}@root"))
3212 .is_some_and(|suffix| suffix.starts_with('['))
3213 } else {
3214 let exact = format!("{step_id}@{scope}");
3215 instance_id == exact
3216 || instance_id
3217 .strip_prefix(&exact)
3218 .is_some_and(|suffix| suffix.starts_with('['))
3219 }
3220}
3221
3222fn plan_frontier(plan: &WorkflowPlan) -> Vec<String> {
3223 match plan {
3224 WorkflowPlan::Step { step } => vec![step.clone()],
3225 WorkflowPlan::Sequence { nodes } => nodes.first().map_or_else(Vec::new, plan_frontier),
3226 WorkflowPlan::Parallel { nodes } => nodes.iter().flat_map(plan_frontier).collect(),
3227 WorkflowPlan::Choice {
3228 then_branch,
3229 else_branch,
3230 ..
3231 } => {
3232 let mut result = plan_frontier(then_branch);
3233 result.extend(plan_frontier(else_branch));
3234 result
3235 }
3236 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3237 plan_frontier(body)
3238 }
3239 }
3240}
3241
3242fn validate_nested_input_contract(
3243 template: &Value,
3244 target_schema: &Value,
3245 compiled: &CompiledWorkflow,
3246) -> Result<(), String> {
3247 fn contains_ref(value: &Value) -> bool {
3248 match value {
3249 Value::Object(object) => {
3250 object.contains_key("from") || object.values().any(contains_ref)
3251 }
3252 Value::Array(array) => array.iter().any(contains_ref),
3253 _ => false,
3254 }
3255 }
3256 if !contains_ref(template) {
3257 return validate_schema(target_schema, template)
3258 .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3259 }
3260 let reference: ValueRef = serde_json::from_value(template.clone()).map_err(|_| {
3261 "nested dynamic input schema cannot be proven compatible in phase 1".to_string()
3262 })?;
3263 let source_schema = match reference {
3264 ValueRef::Args { pointer } => {
3265 schema_at_pointer(&compiled.definition.input_schema, &pointer)
3266 }
3267 ValueRef::Step { step, pointer } => compiled
3268 .steps
3269 .get(&step)
3270 .and_then(|step| step.output_schema.as_ref())
3271 .and_then(|schema| schema_at_pointer(schema, &pointer)),
3272 ValueRef::Literal { value } => {
3273 return validate_schema(target_schema, &value)
3274 .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3275 }
3276 ValueRef::Item { .. } => None,
3277 }
3278 .ok_or_else(|| {
3279 "nested dynamic input source schema is missing or pointer is invalid".to_string()
3280 })?;
3281 if schema_compatible(source_schema, target_schema) {
3282 Ok(())
3283 } else {
3284 Err("nested workflow input schema is not compatible with its pinned target".to_string())
3285 }
3286}
3287
3288fn schema_at_pointer<'a>(schema: &'a Value, pointer: &str) -> Option<&'a Value> {
3289 if pointer.is_empty() {
3290 return Some(schema);
3291 }
3292 let mut current = schema;
3293 for token in pointer.strip_prefix('/')?.split('/') {
3294 let token = token.replace("~1", "/").replace("~0", "~");
3295 current = if token.parse::<usize>().is_ok() {
3296 current.get("items")?
3297 } else {
3298 current.get("properties")?.get(&token)?
3299 };
3300 }
3301 Some(current)
3302}
3303
3304fn schema_compatible(source: &Value, target: &Value) -> bool {
3305 if source == target {
3306 return true;
3307 }
3308 let source_type = source.get("type").and_then(Value::as_str);
3309 let target_type = target.get("type").and_then(Value::as_str);
3310 source_type.is_some() && source_type == target_type && target_type != Some("object")
3311}
3312
3313fn effective_limits(requested: &WorkflowBudgets, ceilings: &WorkflowBudgets) -> WorkflowBudgets {
3314 WorkflowBudgets {
3315 max_concurrency: requested.max_concurrency.min(ceilings.max_concurrency),
3316 max_agents: requested.max_agents.min(ceilings.max_agents),
3317 max_steps: requested.max_steps.min(ceilings.max_steps),
3318 max_retries: requested.max_retries.min(ceilings.max_retries),
3319 max_nesting_depth: requested.max_nesting_depth.min(ceilings.max_nesting_depth),
3320 wall_time_ms: requested.wall_time_ms.min(ceilings.wall_time_ms),
3321 max_tokens: match (requested.max_tokens, ceilings.max_tokens) {
3322 (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3323 (Some(requested), None) => Some(requested),
3324 (None, ceiling) => ceiling,
3325 },
3326 max_cost_micros: match (requested.max_cost_micros, ceilings.max_cost_micros) {
3327 (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3328 (Some(requested), None) => Some(requested),
3329 (None, ceiling) => ceiling,
3330 },
3331 }
3332}