Skip to main content

bamboo_engine/workflow_run/
executor.rs

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