1use crate::execution_identity::{
8 ExecutionIdentityV1, DYNAMIC_WORKFLOW_CLAIM_IDENTITY_DOMAIN_V1,
9 DYNAMIC_WORKFLOW_CONTINUATION_IDENTITY_DOMAIN_V1, DYNAMIC_WORKFLOW_INPUT_IDENTITY_DOMAIN_V1,
10 FLOW_STEP_IDENTITY_DOMAIN_V1,
11};
12use crate::llm::{ModelGenerationAdmission, ModelGenerationConcurrency};
13use crate::task_scheduler::{
14 TaskLease, TaskPriority as SchedulerTaskPriority, TaskScheduler, TaskSchedulerQuota,
15};
16use crate::tools::{
17 registry_tool_invoker, Tool, ToolContext, ToolInvoker, ToolOutput, ToolRegistry, ToolResult,
18};
19use crate::{
20 agent::AgentEvent,
21 flow_graph::{
22 FileFlowDecisionLedger, FlowDecisionClaimOutcome, FlowDecisionClaimState,
23 FlowDecisionLedger, FlowGraphObserver, MemoryFlowDecisionLedger,
24 },
25 planning::{Complexity, ExecutionPlan, Task, TaskStatus},
26};
27use a3s_flow::{
28 CancellationRequest, FanoutFlowEventObserver, FlowEngine, FlowEvent, FlowEventEnvelope,
29 FlowEventObserver, FlowEventStore, FlowRuntime, InMemoryEventStore, LocalFileEventStore,
30 RuntimeBuildCompatibility, RuntimeBuildId, RuntimeCommand, StepInvocation, StepStatus,
31 WorkflowInvocation, WorkflowRunSnapshot, WorkflowRunStatus, WorkflowSpec,
32};
33use anyhow::{Context, Result};
34use async_trait::async_trait;
35use chrono::Utc;
36use serde::{Deserialize, Serialize};
37use serde_json::{json, Map, Value};
38use std::collections::{BTreeMap, BTreeSet};
39use std::fs::OpenOptions;
40use std::future::Future;
41use std::num::NonZeroUsize;
42use std::path::{Path, PathBuf};
43use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
44use std::sync::{Arc, Weak};
45use std::time::{Duration, SystemTime, UNIX_EPOCH};
46use tokio::sync::{broadcast, Mutex, OwnedSemaphorePermit, Semaphore};
47use tokio_util::sync::CancellationToken;
48
49const DYNAMIC_WORKFLOW_TOOL: &str = "dynamic_workflow";
50const GENERATE_OBJECT_TOOL: &str = "generate_object";
51const PROGRAM_TOOL: &str = "program";
52const TASK_TOOL: &str = "task";
53const PARALLEL_TASK_TOOL: &str = "parallel_task";
54const MAX_INLINE_RETRY_RESUMES: usize = 8;
55const MAX_INLINE_RETRY_DELAY: Duration = Duration::from_secs(5);
56const DEFAULT_MAX_CONCURRENT_STEPS: usize = 4;
57const MAX_MAX_CONCURRENT_STEPS: usize = 32;
58const MAX_FLOW_STEP_ID_BYTES: usize = 256;
59const MAX_FLOW_STEP_INPUT_BYTES: usize = 64 * 1024;
60const MAX_DYNAMIC_WORKFLOW_INPUT_BYTES: usize = 128 * 1024;
61const DEFAULT_DYNAMIC_WORKFLOW_LEASE_MS: u64 = 30_000;
62const MAX_DYNAMIC_WORKFLOW_SETTLE: Duration = Duration::from_secs(5);
63const MAX_DYNAMIC_WORKFLOW_CONTROL_REASON_BYTES: usize = 4 * 1024;
64const DYNAMIC_WORKFLOW_LEASE_RELATIVE_PATH: &str = "leases";
65
66fn dynamic_workflow_scheduler_quota_limit(limits: &DynamicWorkflowScriptLimits) -> usize {
67 limits
68 .max_concurrent_steps
69 .unwrap_or(DEFAULT_MAX_CONCURRENT_STEPS)
70 .clamp(1, MAX_MAX_CONCURRENT_STEPS)
71}
72
73fn provider_quota_for_context(
74 context: &ToolContext,
75) -> Option<crate::task_scheduler::TaskSchedulerQuota> {
76 if context
77 .model_generation_admission()
78 .is_some_and(|admission| admission.has_scheduler_quota())
79 {
80 return None;
84 }
85 let client = context.llm_client()?;
86 let pool = client.model_generation_pool()?;
87 crate::task_scheduler::TaskSchedulerQuota::new(
88 pool.identity.clone(),
89 pool.max_concurrency().get(),
90 )
91 .ok()
92}
93
94pub const DYNAMIC_WORKFLOW_RUNTIME_BUILD_ID: &str =
99 concat!("a3s-code-core-", env!("CARGO_PKG_VERSION"));
100
101const LEGACY_UNPINNED_RUNTIME_BUILD_ID: &str = "<unpinned>";
104
105pub const DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH: &str = ".a3s/workflow";
107
108#[derive(Debug, Clone)]
117pub struct CrossProcessFlowEventStore {
118 root: PathBuf,
119 inner: LocalFileEventStore,
120 process_lock: Arc<Mutex<()>>,
121}
122
123impl CrossProcessFlowEventStore {
124 pub fn new(root: impl Into<PathBuf>) -> Self {
126 let root = root.into();
127 Self {
128 inner: LocalFileEventStore::new(root.clone()),
129 root,
130 process_lock: Arc::new(Mutex::new(())),
131 }
132 }
133
134 pub fn root(&self) -> &Path {
136 &self.root
137 }
138
139 async fn acquire_file_lock(&self) -> a3s_flow::Result<std::fs::File> {
140 tokio::fs::create_dir_all(&self.root).await?;
141 let lock_path = self.root.join(".flow-events.lock");
142 match tokio::fs::symlink_metadata(&lock_path).await {
143 Ok(metadata) if metadata.file_type().is_symlink() => {
144 return Err(a3s_flow::FlowError::Store(format!(
145 "refusing to use symlinked event-store lock {}",
146 lock_path.display()
147 )))
148 }
149 Ok(metadata) if !metadata.is_file() => {
150 return Err(a3s_flow::FlowError::Store(format!(
151 "event-store lock {} exists but is not a file",
152 lock_path.display()
153 )))
154 }
155 Ok(_) => {}
156 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
157 Err(error) => return Err(a3s_flow::FlowError::Io(error)),
158 }
159 tokio::task::spawn_blocking(move || {
160 use fs2::FileExt;
161 let file = OpenOptions::new()
162 .create(true)
163 .truncate(false)
164 .read(true)
165 .write(true)
166 .open(&lock_path)
167 .map_err(a3s_flow::FlowError::Io)?;
168 file.lock_exclusive().map_err(a3s_flow::FlowError::Io)?;
169 Ok(file)
170 })
171 .await
172 .map_err(|error| {
173 a3s_flow::FlowError::Store(format!("event-store lock task failed: {error}"))
174 })?
175 }
176}
177
178#[async_trait]
179impl FlowEventStore for CrossProcessFlowEventStore {
180 async fn append(&self, run_id: &str, event: FlowEvent) -> a3s_flow::Result<FlowEventEnvelope> {
181 let _process_guard = self.process_lock.lock().await;
182 let _file_guard = self.acquire_file_lock().await?;
183 self.inner.append(run_id, event).await
184 }
185
186 async fn append_if_sequence(
187 &self,
188 run_id: &str,
189 expected_sequence: u64,
190 event: FlowEvent,
191 ) -> a3s_flow::Result<FlowEventEnvelope> {
192 let _process_guard = self.process_lock.lock().await;
193 let _file_guard = self.acquire_file_lock().await?;
194 self.inner
195 .append_if_sequence(run_id, expected_sequence, event)
196 .await
197 }
198
199 async fn list(&self, run_id: &str) -> a3s_flow::Result<Vec<FlowEventEnvelope>> {
200 let _process_guard = self.process_lock.lock().await;
201 let _file_guard = self.acquire_file_lock().await?;
202 self.inner.list(run_id).await
203 }
204
205 async fn list_run_ids(&self) -> a3s_flow::Result<Vec<String>> {
206 let _process_guard = self.process_lock.lock().await;
207 let _file_guard = self.acquire_file_lock().await?;
208 self.inner.list_run_ids().await
209 }
210}
211
212pub fn dynamic_workflow_store_path(workspace_root: impl AsRef<Path>) -> PathBuf {
214 workspace_root
215 .as_ref()
216 .join(DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH)
217}
218
219pub async fn recover_dynamic_workflow_step_output(
224 workspace_root: impl AsRef<Path>,
225 run_id: &str,
226 expected_query: &str,
227 step_id: &str,
228) -> Result<Option<Value>> {
229 if !safe_workflow_run_id(run_id) || expected_query.is_empty() || step_id.is_empty() {
230 return Ok(None);
231 }
232 let workspace_root = workspace_root.as_ref();
233 let store_root = dynamic_workflow_store_path(workspace_root);
234 let log_path = store_root.join(format!("{run_id}.jsonl"));
235 match tokio::fs::symlink_metadata(&log_path).await {
236 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
237 return Ok(None)
238 }
239 Ok(_) => {}
240 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
241 Err(error) => {
242 return Err(error).with_context(|| {
243 format!("inspect dynamic workflow history {}", log_path.display())
244 })
245 }
246 }
247 validate_dynamic_workflow_directory(&workspace_root.join(".a3s"), ".a3s").await?;
248 validate_dynamic_workflow_directory(&store_root, ".a3s/workflow").await?;
249 validate_dynamic_workflow_log(&log_path).await?;
250
251 let events = LocalFileEventStore::new(store_root).list(run_id).await?;
252 let input_matches = events.iter().any(|envelope| {
253 matches!(
254 &envelope.event,
255 FlowEvent::RunCreated { input, .. }
256 if input.get("query").and_then(Value::as_str) == Some(expected_query)
257 )
258 });
259 if !input_matches {
260 return Ok(None);
261 }
262 Ok(events
263 .iter()
264 .rev()
265 .find_map(|envelope| match &envelope.event {
266 FlowEvent::StepCompleted {
267 step_id: completed_step_id,
268 output,
269 } if completed_step_id == step_id => Some(output.clone()),
270 _ => None,
271 }))
272}
273
274#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(rename_all = "camelCase")]
277pub struct DynamicWorkflowScriptLimits {
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub timeout_ms: Option<u64>,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub max_tool_calls: Option<usize>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub max_output_bytes: Option<usize>,
284 #[serde(default, skip_serializing)]
287 pub max_concurrent_generations: Option<usize>,
288 #[serde(default, skip_serializing)]
291 pub max_concurrent_steps: Option<usize>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(rename_all = "camelCase")]
297pub struct DynamicWorkflowAdmissionStats {
298 pub max_concurrent_steps: usize,
300 pub admitted_steps: usize,
302 pub active_steps: usize,
304 pub peak_active_steps: usize,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "camelCase")]
314pub struct DynamicWorkflowHealthSnapshot {
315 pub claim_attempts: u64,
317 pub claims: u64,
319 pub takeovers: u64,
321 pub already_completed: u64,
323 pub busy: u64,
325 pub conflicts: u64,
327 pub renewals: u64,
329 pub lease_lost: u64,
331 pub completions: u64,
333 pub releases: u64,
335 pub cancellations: u64,
337 pub failures: u64,
339 pub in_flight: u64,
341}
342
343#[derive(Default)]
344struct DynamicWorkflowMetrics {
345 claim_attempts: AtomicU64,
346 claims: AtomicU64,
347 takeovers: AtomicU64,
348 already_completed: AtomicU64,
349 busy: AtomicU64,
350 conflicts: AtomicU64,
351 renewals: AtomicU64,
352 lease_lost: AtomicU64,
353 completions: AtomicU64,
354 releases: AtomicU64,
355 cancellations: AtomicU64,
356 failures: AtomicU64,
357 in_flight: AtomicU64,
358}
359
360impl DynamicWorkflowMetrics {
361 fn snapshot(&self) -> DynamicWorkflowHealthSnapshot {
362 DynamicWorkflowHealthSnapshot {
363 claim_attempts: self.claim_attempts.load(Ordering::Relaxed),
364 claims: self.claims.load(Ordering::Relaxed),
365 takeovers: self.takeovers.load(Ordering::Relaxed),
366 already_completed: self.already_completed.load(Ordering::Relaxed),
367 busy: self.busy.load(Ordering::Relaxed),
368 conflicts: self.conflicts.load(Ordering::Relaxed),
369 renewals: self.renewals.load(Ordering::Relaxed),
370 lease_lost: self.lease_lost.load(Ordering::Relaxed),
371 completions: self.completions.load(Ordering::Relaxed),
372 releases: self.releases.load(Ordering::Relaxed),
373 cancellations: self.cancellations.load(Ordering::Relaxed),
374 failures: self.failures.load(Ordering::Relaxed),
375 in_flight: self.in_flight.load(Ordering::Relaxed),
376 }
377 }
378
379 fn increment(counter: &AtomicU64) {
380 counter
381 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
382 Some(value.saturating_add(1))
383 })
384 .ok();
385 }
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392#[serde(rename_all = "camelCase")]
393pub struct DynamicWorkflowControlDiagnostics {
394 pub workflow: DynamicWorkflowHealthSnapshot,
395 #[serde(skip_serializing_if = "Option::is_none")]
396 pub scheduler: Option<crate::task_scheduler::TaskSchedulerHealthSnapshot>,
397 #[serde(skip_serializing_if = "Option::is_none")]
401 pub scheduler_quota: Option<crate::task_scheduler::TaskSchedulerQuotaSnapshot>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410#[serde(rename_all = "camelCase")]
411pub struct DynamicWorkflowControlSnapshot {
412 pub run_id: String,
414 pub status: WorkflowRunStatus,
416 pub last_sequence: u64,
418 pub step_count: usize,
420 pub completed_steps: usize,
422 pub open_steps: usize,
424 pub cancellation_requested: bool,
426 pub continuation_identity: ExecutionIdentityV1,
428 pub plan_identity: ExecutionIdentityV1,
430 pub runtime_build_id: Option<String>,
433 pub worker_lease: FlowDecisionClaimState,
435}
436
437#[must_use = "a dynamic workflow control handle should be used or explicitly dropped"]
446#[derive(Clone)]
447pub struct DynamicWorkflowControl {
448 registry: Arc<ToolRegistry>,
449 context: ToolContext,
450 flow_event_store: Option<Arc<dyn FlowEventStore>>,
451 run_id: String,
452 source: Arc<str>,
453 input: Value,
454 allowed_tools: Vec<String>,
455 limits: DynamicWorkflowScriptLimits,
456 graph_observer: Option<FlowGraphObserver>,
457 task_scheduler: Option<Arc<TaskScheduler>>,
458 admit_steps_globally: bool,
459 runtime_build_compatibility: Option<RuntimeBuildCompatibility>,
460 continuation_lease_ledger: Option<Arc<dyn FlowDecisionLedger>>,
461 continuation_lease_ms: u64,
462 memory_continuation_lease_ledger: Arc<MemoryFlowDecisionLedger>,
463 metrics: Arc<DynamicWorkflowMetrics>,
464}
465
466#[derive(Clone)]
467struct DynamicStepAdmission {
468 semaphore: Arc<Semaphore>,
469 max_concurrent_steps: usize,
470 admitted_steps: Arc<AtomicUsize>,
471 active_steps: Arc<AtomicUsize>,
472 peak_active_steps: Arc<AtomicUsize>,
473}
474
475impl DynamicStepAdmission {
476 fn new(max_concurrent_steps: usize) -> Self {
477 Self {
478 semaphore: Arc::new(Semaphore::new(max_concurrent_steps)),
479 max_concurrent_steps,
480 admitted_steps: Arc::new(AtomicUsize::new(0)),
481 active_steps: Arc::new(AtomicUsize::new(0)),
482 peak_active_steps: Arc::new(AtomicUsize::new(0)),
483 }
484 }
485
486 async fn acquire(
487 &self,
488 identity: ExecutionIdentityV1,
489 cancellation: &tokio_util::sync::CancellationToken,
490 ) -> a3s_flow::Result<DynamicStepLease> {
491 let permit = tokio::select! {
492 biased;
493 _ = cancellation.cancelled() => {
494 return Err(a3s_flow::FlowError::Runtime(
495 "dynamic workflow step admission cancelled".to_string(),
496 ));
497 }
498 permit = Arc::clone(&self.semaphore).acquire_owned() => {
499 permit.map_err(|_| a3s_flow::FlowError::Runtime(
500 "dynamic workflow step admission is closed".to_string(),
501 ))?
502 }
503 };
504 if cancellation.is_cancelled() {
505 drop(permit);
506 return Err(a3s_flow::FlowError::Runtime(
507 "dynamic workflow step admission cancelled".to_string(),
508 ));
509 }
510
511 let active = self.active_steps.fetch_add(1, Ordering::AcqRel) + 1;
512 self.admitted_steps.fetch_add(1, Ordering::Relaxed);
513 let mut observed = self.peak_active_steps.load(Ordering::Acquire);
514 while active > observed {
515 match self.peak_active_steps.compare_exchange(
516 observed,
517 active,
518 Ordering::AcqRel,
519 Ordering::Acquire,
520 ) {
521 Ok(_) => break,
522 Err(current) => observed = current,
523 }
524 }
525 tracing::trace!(
526 execution_identity = identity.key(),
527 active_steps = active,
528 max_concurrent_steps = self.max_concurrent_steps,
529 "dynamic workflow step admitted"
530 );
531 Ok(DynamicStepLease {
532 _permit: permit,
533 identity,
534 active_steps: Arc::clone(&self.active_steps),
535 task_lease: None,
536 })
537 }
538
539 fn stats(&self) -> DynamicWorkflowAdmissionStats {
540 DynamicWorkflowAdmissionStats {
541 max_concurrent_steps: self.max_concurrent_steps,
542 admitted_steps: self.admitted_steps.load(Ordering::Acquire),
543 active_steps: self.active_steps.load(Ordering::Acquire),
544 peak_active_steps: self.peak_active_steps.load(Ordering::Acquire),
545 }
546 }
547}
548
549struct DynamicStepLease {
550 _permit: OwnedSemaphorePermit,
551 identity: ExecutionIdentityV1,
552 active_steps: Arc<AtomicUsize>,
553 task_lease: Option<TaskLease>,
554}
555
556impl DynamicStepLease {
557 fn with_task_lease(mut self, task_lease: TaskLease) -> Self {
558 self.task_lease = Some(task_lease);
559 self
560 }
561}
562
563impl Drop for DynamicStepLease {
564 fn drop(&mut self) {
565 let active = self.active_steps.fetch_sub(1, Ordering::AcqRel) - 1;
566 tracing::trace!(
567 execution_identity = self.identity.key(),
568 active_steps = active,
569 "dynamic workflow step admission released"
570 );
571 }
572}
573
574#[derive(Clone)]
576pub struct DynamicWorkflowRuntime {
577 invoker: Arc<dyn ToolInvoker>,
578 context: ToolContext,
579 source: Arc<str>,
580 allowed_tools: Vec<String>,
581 limits: DynamicWorkflowScriptLimits,
582 parallel_generation_admission: Option<ModelGenerationAdmission>,
583 step_admission: DynamicStepAdmission,
584 task_scheduler: Option<Arc<TaskScheduler>>,
588 admit_steps_globally: bool,
589 scheduler_quota: Option<crate::task_scheduler::TaskSchedulerQuota>,
592 provider_quota: Option<crate::task_scheduler::TaskSchedulerQuota>,
595 continuation_lease: Option<Arc<DynamicWorkflowLease>>,
596}
597
598impl DynamicWorkflowRuntime {
599 pub fn new(
600 registry: Arc<ToolRegistry>,
601 context: ToolContext,
602 source: impl Into<String>,
603 ) -> Self {
604 let provider_quota = provider_quota_for_context(&context);
605 let allowed_tools = default_allowed_tools(®istry);
606 let invoker = context
610 .tool_invoker()
611 .unwrap_or_else(|| registry_tool_invoker(registry));
612 Self {
613 invoker,
614 context,
615 source: Arc::from(source.into()),
616 allowed_tools,
617 limits: DynamicWorkflowScriptLimits::default(),
618 parallel_generation_admission: None,
619 step_admission: DynamicStepAdmission::new(DEFAULT_MAX_CONCURRENT_STEPS),
620 task_scheduler: None,
621 admit_steps_globally: false,
622 scheduler_quota: None,
623 provider_quota,
624 continuation_lease: None,
625 }
626 }
627
628 pub fn with_allowed_tools(mut self, allowed_tools: impl IntoIterator<Item = String>) -> Self {
629 self.allowed_tools = sanitize_allowed_tools(allowed_tools);
630 self
631 }
632
633 pub fn with_limits(mut self, limits: DynamicWorkflowScriptLimits) -> Self {
634 let generation_concurrency = limits.max_concurrent_generations.unwrap_or(1).clamp(1, 4);
635 self.parallel_generation_admission = NonZeroUsize::new(generation_concurrency)
636 .filter(|maximum| maximum.get() > 1)
637 .map(|maximum| {
638 let admission =
639 ModelGenerationAdmission::new(ModelGenerationConcurrency::bounded(maximum));
640 match self.context.model_generation_admission() {
641 Some(session_admission) => match admission.clone().with_scheduler_quota_from(
642 &session_admission,
643 "dynamic-workflow-model-generation",
644 ) {
645 Ok(admission) => admission,
646 Err(error) => {
647 tracing::warn!(
648 %error,
649 "failed to project session provider quota into dynamic workflow admission"
650 );
651 admission
652 }
653 },
654 None => admission,
655 }
656 });
657 let step_concurrency = limits
658 .max_concurrent_steps
659 .unwrap_or(DEFAULT_MAX_CONCURRENT_STEPS)
660 .clamp(1, MAX_MAX_CONCURRENT_STEPS);
661 self.step_admission = DynamicStepAdmission::new(step_concurrency);
662 self.limits = limits;
663 self
664 }
665
666 pub fn with_task_scheduler(
673 mut self,
674 scheduler: Arc<TaskScheduler>,
675 admit_steps_globally: bool,
676 ) -> Self {
677 self.task_scheduler = Some(scheduler);
678 self.admit_steps_globally = admit_steps_globally;
679 self
680 }
681
682 pub fn with_task_scheduler_quota(
687 mut self,
688 quota: crate::task_scheduler::TaskSchedulerQuota,
689 ) -> Self {
690 self.scheduler_quota = Some(quota);
691 self
692 }
693
694 fn with_continuation_lease(mut self, lease: Arc<DynamicWorkflowLease>) -> Self {
695 self.continuation_lease = Some(lease);
696 self
697 }
698
699 pub fn admission_stats(&self) -> DynamicWorkflowAdmissionStats {
701 self.step_admission.stats()
702 }
703
704 pub async fn scheduler_quota_snapshot(
709 &self,
710 ) -> std::result::Result<
711 Option<crate::task_scheduler::TaskSchedulerQuotaSnapshot>,
712 crate::task_scheduler::TaskSchedulerError,
713 > {
714 match (&self.task_scheduler, &self.scheduler_quota) {
715 (Some(scheduler), Some(quota)) => scheduler.quota_snapshot(quota).await.map(Some),
716 _ => Ok(None),
717 }
718 }
719
720 async fn admit_step(&self, invocation: &StepInvocation) -> a3s_flow::Result<DynamicStepLease> {
721 self.ensure_continuation_lease().await?;
722 let identity = dynamic_workflow_step_identity(
723 &invocation.run_id,
724 &invocation.step_id,
725 &invocation.step_name,
726 &invocation.input,
727 )
728 .map_err(|error| a3s_flow::FlowError::Runtime(error.to_string()))?;
729 let mut lease = self
730 .step_admission
731 .acquire(identity.clone(), &self.context.cancellation_token())
732 .await?;
733
734 if self.admit_steps_globally
739 && !matches!(
740 invocation.step_name.as_str(),
741 TASK_TOOL | PARALLEL_TASK_TOOL
742 )
743 {
744 let Some(scheduler) = self.task_scheduler.as_ref() else {
745 return Err(a3s_flow::FlowError::Runtime(
746 "global Flow-step admission requires a configured task scheduler".to_string(),
747 ));
748 };
749 let label = format!(
750 "flow:{}:{}:{}",
751 invocation.run_id, invocation.step_id, invocation.step_name
752 );
753 let mut quotas = Vec::with_capacity(2);
754 if let Some(quota) = self.scheduler_quota.as_ref() {
755 quotas.push(quota.clone());
756 }
757 if let Some(quota) = self.provider_quota.as_ref() {
758 if !quotas
759 .iter()
760 .any(|candidate| candidate.identity == quota.identity)
761 {
762 quotas.push(quota.clone());
763 }
764 }
765 let task_lease = if quotas.is_empty() {
766 scheduler
767 .acquire_with_identity(
768 SchedulerTaskPriority::Foreground,
769 label,
770 Some(identity),
771 &self.context.cancellation_token(),
772 )
773 .await
774 } else {
775 scheduler
776 .acquire_with_quotas(
777 SchedulerTaskPriority::Foreground,
778 label,
779 "as,
780 Some(identity),
781 &self.context.cancellation_token(),
782 )
783 .await
784 }
785 .map_err(|error| a3s_flow::FlowError::Runtime(error.to_string()))?;
786 lease = lease.with_task_lease(task_lease);
787 }
788 Ok(lease)
789 }
790
791 async fn ensure_continuation_lease(&self) -> a3s_flow::Result<()> {
792 let Some(lease) = self.continuation_lease.as_ref() else {
793 return Ok(());
794 };
795 if lease
796 .renew()
797 .await
798 .map_err(|error| a3s_flow::FlowError::Runtime(error.to_string()))?
799 {
800 Ok(())
801 } else {
802 Err(a3s_flow::FlowError::Runtime(
803 "dynamic workflow worker lease is no longer owned before admission".to_string(),
804 ))
805 }
806 }
807
808 async fn run_script(
809 &self,
810 payload: Value,
811 context: &ToolContext,
812 ) -> a3s_flow::Result<ToolResult> {
813 let mut args = json!({
814 "type": "script",
815 "language": "javascript",
816 "source": self.source.as_ref(),
817 "inputs": payload,
818 "allowed_tools": self.allowed_tools,
819 });
820 if let Some(object) = args.as_object_mut() {
821 if let Ok(Value::Object(limits)) = serde_json::to_value(&self.limits) {
822 if !limits.is_empty() {
823 object.insert("limits".to_string(), Value::Object(limits));
824 }
825 }
826 }
827
828 let result = self
829 .invoker
830 .invoke(
831 crate::tools::ToolInvocation::runtime_internal(PROGRAM_TOOL, args),
832 context,
833 )
834 .await;
835 if result.exit_code != 0 {
836 return Err(a3s_flow::FlowError::Runtime(result.output));
837 }
838 Ok(result)
839 }
840
841 async fn context_for_step(
842 &self,
843 run_id: &str,
844 step_id: &str,
845 step_name: &str,
846 ) -> a3s_flow::Result<ToolContext> {
847 if step_name != GENERATE_OBJECT_TOOL {
848 return Ok(self.context.clone().with_run_id(run_id.to_string()));
849 }
850 if let (Some(admission), Some(client)) = (
851 self.parallel_generation_admission.as_ref(),
852 self.context.llm_client(),
853 ) {
854 let fork_id = format!("{run_id}:{step_id}");
855 if let Some(forked_client) = client.fork_for_session(&fork_id) {
856 let permit = admission
857 .acquire(&self.context.cancellation_token())
858 .await
859 .map_err(|error| {
860 a3s_flow::FlowError::Runtime(format!(
861 "parallel model-generation admission failed before workflow step: {error}"
862 ))
863 })?;
864 return self
865 .context
866 .clone()
867 .with_run_id(run_id.to_string())
868 .with_llm_client(forked_client)
869 .with_model_generation_permit(admission.clone(), Arc::new(permit))
870 .map_err(|error| {
871 a3s_flow::FlowError::Runtime(format!(
872 "bind parallel model-generation admission to workflow step: {error}"
873 ))
874 });
875 }
876 }
877 let Some(admission) = self.context.model_generation_admission() else {
878 return Ok(self.context.clone().with_run_id(run_id.to_string()));
879 };
880 let permit = admission
881 .acquire(&self.context.cancellation_token())
882 .await
883 .map_err(|error| {
884 a3s_flow::FlowError::Runtime(format!(
885 "model-generation admission failed before workflow step: {error}"
886 ))
887 })?;
888 self.context
889 .clone()
890 .with_run_id(run_id.to_string())
891 .with_model_generation_permit(admission, Arc::new(permit))
892 .map_err(|error| {
893 a3s_flow::FlowError::Runtime(format!(
894 "bind model-generation admission to workflow step: {error}"
895 ))
896 })
897 }
898
899 async fn run_tool_step(
900 &self,
901 run_id: &str,
902 tool_name: &str,
903 args: Value,
904 ) -> a3s_flow::Result<Value> {
905 let context = self.context.clone().with_run_id(run_id.to_string());
906 let result = self
907 .invoker
908 .invoke(
909 self.context
910 .nested_tool_invocation(tool_name.to_string(), args),
911 &context,
912 )
913 .await;
914 if result.exit_code != 0 {
915 return Err(a3s_flow::FlowError::Runtime(result.output));
916 }
917 Ok(json!({
918 "tool": result.name,
919 "output": result.output,
920 "exit_code": result.exit_code,
921 "metadata": result.metadata,
922 }))
923 }
924}
925
926#[async_trait]
927impl FlowRuntime for DynamicWorkflowRuntime {
928 async fn run_workflow(
929 &self,
930 invocation: WorkflowInvocation,
931 ) -> a3s_flow::Result<RuntimeCommand> {
932 self.ensure_continuation_lease().await?;
933 let payload = invocation_payload("workflow", &invocation.run_id, &invocation.history)
934 .with("input", invocation.input);
935 let context = self
936 .context
937 .clone()
938 .with_run_id(invocation.run_id.to_string());
939 let result = self.run_script(payload.into_value(), &context).await?;
940 serde_json::from_value(script_result(&result)?).map_err(a3s_flow::FlowError::from)
941 }
942
943 async fn run_step(&self, invocation: StepInvocation) -> a3s_flow::Result<Value> {
944 let _admission = self.admit_step(&invocation).await?;
945 if matches!(
946 invocation.step_name.as_str(),
947 TASK_TOOL | PARALLEL_TASK_TOOL
948 ) {
949 return self
950 .run_tool_step(&invocation.run_id, &invocation.step_name, invocation.input)
951 .await;
952 }
953
954 let context = self
955 .context_for_step(
956 &invocation.run_id,
957 &invocation.step_id,
958 &invocation.step_name,
959 )
960 .await?;
961 let payload = invocation_payload("step", &invocation.run_id, &invocation.history)
962 .with("step_id", invocation.step_id)
963 .with("step_name", invocation.step_name)
964 .with("input", invocation.input);
965 let result = self.run_script(payload.into_value(), &context).await?;
966 script_result(&result)
967 }
968}
969
970pub fn dynamic_workflow_step_identity(
976 run_id: &str,
977 step_id: &str,
978 step_name: &str,
979 input: &Value,
980) -> Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
981 for (field, value) in [
982 ("run_id", run_id),
983 ("step_id", step_id),
984 ("step_name", step_name),
985 ] {
986 if value.is_empty()
987 || value.len() > MAX_FLOW_STEP_ID_BYTES
988 || value.contains('\0')
989 || value.lines().count() != 1
990 {
991 return Err(
992 crate::execution_identity::ExecutionIdentityError::InvalidClaimField(field),
993 );
994 }
995 }
996 let encoded_input = serde_json::to_vec(input).map_err(|error| {
997 crate::execution_identity::ExecutionIdentityError::Serialization(error.to_string())
998 })?;
999 if encoded_input.len() > MAX_FLOW_STEP_INPUT_BYTES {
1000 return Err(
1001 crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1002 "dynamic Flow step input exceeds {} bytes",
1003 MAX_FLOW_STEP_INPUT_BYTES
1004 )),
1005 );
1006 }
1007 ExecutionIdentityV1::derive(
1008 FLOW_STEP_IDENTITY_DOMAIN_V1,
1009 &json!({
1010 "run_id": run_id,
1011 "step_id": step_id,
1012 "step_name": step_name,
1013 "input": input,
1014 }),
1015 )
1016}
1017
1018fn dynamic_workflow_input_identity(
1019 input: &Value,
1020) -> Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
1021 let encoded = serde_json::to_vec(input).map_err(|error| {
1022 crate::execution_identity::ExecutionIdentityError::Serialization(error.to_string())
1023 })?;
1024 if encoded.len() > MAX_DYNAMIC_WORKFLOW_INPUT_BYTES {
1025 return Err(
1026 crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1027 "dynamic workflow input exceeds {} bytes",
1028 MAX_DYNAMIC_WORKFLOW_INPUT_BYTES
1029 )),
1030 );
1031 }
1032 ExecutionIdentityV1::derive(DYNAMIC_WORKFLOW_INPUT_IDENTITY_DOMAIN_V1, input)
1033}
1034
1035pub fn dynamic_workflow_claim_identity(
1043 run_id: &str,
1044 source: &str,
1045 input: &Value,
1046 runtime_build_id: &str,
1047 history: &[FlowEventEnvelope],
1048) -> std::result::Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
1049 dynamic_workflow_continuation_identity(run_id, source, input, runtime_build_id, history)?;
1050 let input_identity = dynamic_workflow_input_identity(input)?;
1051 let effective_runtime_build_id = history
1052 .iter()
1053 .find_map(|envelope| match &envelope.event {
1054 FlowEvent::RunCreated { spec, .. } => Some(
1055 spec.runtime_build_id
1056 .as_ref()
1057 .map(ToString::to_string)
1058 .unwrap_or_else(|| LEGACY_UNPINNED_RUNTIME_BUILD_ID.to_string()),
1059 ),
1060 _ => None,
1061 })
1062 .unwrap_or_else(|| runtime_build_id.to_string());
1063 ExecutionIdentityV1::derive(
1064 DYNAMIC_WORKFLOW_CLAIM_IDENTITY_DOMAIN_V1,
1065 &json!({
1066 "run_id": run_id,
1067 "source_hash": source_hash(source),
1068 "input_identity": input_identity.digest,
1069 "runtime_build_id": effective_runtime_build_id,
1070 }),
1071 )
1072}
1073
1074pub fn dynamic_workflow_continuation_identity(
1084 run_id: &str,
1085 source: &str,
1086 input: &Value,
1087 runtime_build_id: &str,
1088 history: &[FlowEventEnvelope],
1089) -> std::result::Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
1090 if !safe_workflow_run_id(run_id) {
1091 return Err(crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_id"));
1092 }
1093 if source.is_empty() {
1094 return Err(crate::execution_identity::ExecutionIdentityError::InvalidClaimField("source"));
1095 }
1096 RuntimeBuildId::new(runtime_build_id.to_string()).map_err(|error| {
1097 crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1098 "invalid dynamic workflow runtime build id: {error}"
1099 ))
1100 })?;
1101 let input_identity = dynamic_workflow_input_identity(input)?;
1102 let expected_source_hash = source_hash(source);
1103 let expected_spec = WorkflowSpec::rust_embedded(
1104 "a3s-code.dynamic-workflow",
1105 expected_source_hash.as_str(),
1106 "ptc",
1107 "run",
1108 );
1109 let mut saw_run_created = false;
1110 let mut persisted_runtime_build: Option<String> = None;
1111 let mut step_identities = BTreeMap::<String, String>::new();
1112 let mut terminal_seen = false;
1113
1114 for (index, envelope) in history.iter().enumerate() {
1115 if envelope.run_id != run_id {
1116 return Err(
1117 crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_id"),
1118 );
1119 }
1120 if envelope.sequence != index as u64 + 1 {
1121 return Err(
1122 crate::execution_identity::ExecutionIdentityError::InvalidClaimField("sequence"),
1123 );
1124 }
1125 if terminal_seen {
1126 return Err(
1127 crate::execution_identity::ExecutionIdentityError::InvalidClaimField("terminal"),
1128 );
1129 }
1130 if index == 0 && !matches!(&envelope.event, FlowEvent::RunCreated { .. }) {
1131 return Err(
1132 crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_created"),
1133 );
1134 }
1135
1136 match &envelope.event {
1137 FlowEvent::RunCreated {
1138 spec,
1139 input: persisted_input,
1140 } => {
1141 if saw_run_created {
1142 return Err(
1143 crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1144 "run_created",
1145 ),
1146 );
1147 }
1148 saw_run_created = true;
1149 if spec.name != expected_spec.name
1150 || spec.runtime != expected_spec.runtime
1151 || !spec.patch_markers.is_empty()
1152 || !spec.signal_names.is_empty()
1153 {
1154 return Err(
1155 crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1156 "workflow_spec",
1157 ),
1158 );
1159 }
1160 if spec.version != expected_source_hash {
1161 return Err(
1162 crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1163 "source",
1164 ),
1165 );
1166 }
1167 let persisted_input_identity = dynamic_workflow_input_identity(persisted_input)?;
1168 if persisted_input_identity != input_identity {
1169 return Err(
1170 crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1171 "input",
1172 ),
1173 );
1174 }
1175 if let Some(build_id) = &spec.runtime_build_id {
1176 RuntimeBuildId::new(build_id.as_str().to_string()).map_err(|error| {
1177 crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1178 "invalid persisted dynamic workflow runtime build id: {error}"
1179 ))
1180 })?;
1181 persisted_runtime_build = Some(build_id.as_str().to_string());
1182 } else {
1183 persisted_runtime_build = Some(LEGACY_UNPINNED_RUNTIME_BUILD_ID.to_string());
1184 }
1185 }
1186 FlowEvent::StepCreated {
1187 step_id,
1188 step_name,
1189 input,
1190 retry,
1191 } => {
1192 let admission_identity =
1193 dynamic_workflow_step_identity(run_id, step_id, step_name, input)?;
1194 let identity = ExecutionIdentityV1::derive(
1199 FLOW_STEP_IDENTITY_DOMAIN_V1,
1200 &json!({
1201 "admission": admission_identity.digest,
1202 "retry": retry,
1203 }),
1204 )?;
1205 if step_identities
1206 .insert(step_id.clone(), identity.digest)
1207 .is_some()
1208 {
1209 return Err(
1210 crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1211 "step_definition",
1212 ),
1213 );
1214 }
1215 }
1216 _ => {}
1217 }
1218
1219 terminal_seen = matches!(
1220 &envelope.event,
1221 FlowEvent::RunCompleted { .. }
1222 | FlowEvent::RunFailed { .. }
1223 | FlowEvent::RunCancelled { .. }
1224 | FlowEvent::RunTimedOut { .. }
1225 | FlowEvent::RunRetryExhausted { .. }
1226 | FlowEvent::RunHostShutdown { .. }
1227 | FlowEvent::RunContinuedAsNew { .. }
1228 );
1229 }
1230
1231 if !history.is_empty() && !saw_run_created {
1232 return Err(
1233 crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_created"),
1234 );
1235 }
1236 let effective_runtime_build = persisted_runtime_build
1237 .as_deref()
1238 .unwrap_or(runtime_build_id);
1239 if effective_runtime_build != LEGACY_UNPINNED_RUNTIME_BUILD_ID {
1240 RuntimeBuildId::new(effective_runtime_build.to_string()).map_err(|error| {
1241 crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1242 "invalid effective dynamic workflow runtime build id: {error}"
1243 ))
1244 })?;
1245 }
1246 let plan = dynamic_workflow_execution_plan(history);
1247 let plan_identity = plan.definition_identity()?;
1248 ExecutionIdentityV1::derive(
1249 DYNAMIC_WORKFLOW_CONTINUATION_IDENTITY_DOMAIN_V1,
1250 &json!({
1251 "run_id": run_id,
1252 "source_hash": expected_source_hash,
1253 "input_identity": input_identity.digest,
1254 "runtime_build_id": effective_runtime_build,
1255 "plan_identity": plan_identity.digest,
1256 "step_identities": step_identities,
1257 }),
1258 )
1259}
1260
1261pub fn dynamic_workflow_execution_plan(history: &[FlowEventEnvelope]) -> ExecutionPlan {
1268 let mut plan = ExecutionPlan::new("dynamic workflow", Complexity::Medium);
1269 for envelope in history {
1270 match &envelope.event {
1271 FlowEvent::StepCreated {
1272 step_id,
1273 step_name,
1274 input,
1275 ..
1276 } => {
1277 let task = Task::new(
1278 step_id.clone(),
1279 workflow_step_description(step_id, step_name, Some(input)),
1280 )
1281 .with_tool(step_name.clone());
1282 plan.upsert_step(task);
1283 }
1284 FlowEvent::StepStarted { step_id, .. } => {
1285 plan.mark_status(step_id, TaskStatus::InProgress);
1286 }
1287 FlowEvent::StepRetrying { step_id, .. } => {
1288 plan.mark_status(step_id, TaskStatus::InProgress);
1289 }
1290 FlowEvent::StepCompleted { step_id, .. } => {
1291 plan.mark_status(step_id, TaskStatus::Completed);
1292 }
1293 FlowEvent::StepFailed { step_id, .. } => {
1294 plan.mark_status(step_id, TaskStatus::Failed);
1295 }
1296 FlowEvent::RunFailed { .. }
1297 | FlowEvent::RunTimedOut { .. }
1298 | FlowEvent::RunRetryExhausted { .. } => {
1299 for task in &mut plan.steps {
1300 if task.status.is_active() {
1301 task.status = TaskStatus::Failed;
1302 }
1303 }
1304 }
1305 FlowEvent::RunCancelled { .. } | FlowEvent::RunHostShutdown { .. } => {
1306 for task in &mut plan.steps {
1307 if task.status.is_active() {
1308 task.status = TaskStatus::Cancelled;
1309 }
1310 }
1311 }
1312 _ => {}
1313 }
1314 }
1315 plan
1316}
1317
1318struct WorkflowProgressState {
1319 plan: ExecutionPlan,
1320}
1321
1322impl WorkflowProgressState {
1323 fn new(plan: ExecutionPlan) -> Self {
1324 Self { plan }
1325 }
1326
1327 fn upsert_step(
1328 &mut self,
1329 step_id: &str,
1330 step_name: &str,
1331 input: Option<&Value>,
1332 status: TaskStatus,
1333 ) {
1334 let content = workflow_step_description(step_id, step_name, input);
1335 self.plan.upsert_step(
1336 Task::new(step_id.to_string(), content)
1337 .with_tool(step_name)
1338 .with_status(status),
1339 );
1340 }
1341
1342 fn mark_status(&mut self, step_id: &str, status: TaskStatus) {
1343 self.plan.mark_status(step_id, status);
1344 }
1345
1346 fn step_position(&self, step_id: &str) -> (usize, usize) {
1347 let total = self.plan.steps.len().max(1);
1348 let number = self
1349 .plan
1350 .steps
1351 .iter()
1352 .position(|task| task.id == step_id)
1353 .map(|idx| idx + 1)
1354 .unwrap_or(total);
1355 (number, total)
1356 }
1357
1358 fn step_description(&self, step_id: &str) -> String {
1359 self.plan
1360 .steps
1361 .iter()
1362 .find(|task| task.id == step_id)
1363 .map(|task| task.content.clone())
1364 .unwrap_or_else(|| step_id.to_string())
1365 }
1366
1367 fn tasks(&self) -> &[Task] {
1368 &self.plan.steps
1369 }
1370
1371 fn snapshot(&self) -> ExecutionPlan {
1372 self.plan.clone()
1373 }
1374}
1375
1376struct AgentEventFlowObserver {
1377 tx: broadcast::Sender<AgentEvent>,
1378 session_id: String,
1379 state: Mutex<WorkflowProgressState>,
1380}
1381
1382impl AgentEventFlowObserver {
1383 fn new(tx: broadcast::Sender<AgentEvent>, session_id: String, plan: ExecutionPlan) -> Self {
1384 Self {
1385 tx,
1386 session_id,
1387 state: Mutex::new(WorkflowProgressState::new(plan)),
1388 }
1389 }
1390
1391 fn emit_task_update(&self, tasks: &[Task]) {
1392 let _ = self.tx.send(AgentEvent::TaskUpdated {
1393 session_id: self.session_id.clone(),
1394 tasks: tasks.to_vec(),
1395 });
1396 }
1397}
1398
1399#[async_trait]
1400impl FlowEventObserver for AgentEventFlowObserver {
1401 async fn observe(&self, envelope: FlowEventEnvelope) {
1402 match envelope.event {
1403 FlowEvent::RunStarted => {
1404 let _ = self.tx.send(AgentEvent::PlanningStart {
1405 prompt: "dynamic_workflow".to_string(),
1406 });
1407 let state = self.state.lock().await;
1408 if !state.plan.steps.is_empty() {
1409 let plan = state.snapshot();
1410 let _ = self.tx.send(AgentEvent::PlanningEnd {
1411 estimated_steps: plan.steps.len(),
1412 plan,
1413 });
1414 }
1415 }
1416 FlowEvent::StepCreated {
1417 step_id,
1418 step_name,
1419 input,
1420 ..
1421 } => {
1422 let mut state = self.state.lock().await;
1423 state.upsert_step(&step_id, &step_name, Some(&input), TaskStatus::Pending);
1424 self.emit_task_update(state.tasks());
1425 let plan = state.snapshot();
1426 let _ = self.tx.send(AgentEvent::PlanningEnd {
1427 estimated_steps: plan.steps.len(),
1428 plan,
1429 });
1430 }
1431 FlowEvent::StepStarted { step_id, .. } => {
1432 let mut state = self.state.lock().await;
1433 state.mark_status(&step_id, TaskStatus::InProgress);
1434 self.emit_task_update(state.tasks());
1435 let (step_number, total_steps) = state.step_position(&step_id);
1436 let _ = self.tx.send(AgentEvent::StepStart {
1437 description: state.step_description(&step_id),
1438 step_id,
1439 step_number,
1440 total_steps,
1441 });
1442 }
1443 FlowEvent::StepCompleted { step_id, .. } => {
1444 let mut state = self.state.lock().await;
1445 state.mark_status(&step_id, TaskStatus::Completed);
1446 self.emit_task_update(state.tasks());
1447 let (step_number, total_steps) = state.step_position(&step_id);
1448 let _ = self.tx.send(AgentEvent::StepEnd {
1449 step_id,
1450 status: TaskStatus::Completed,
1451 step_number,
1452 total_steps,
1453 });
1454 }
1455 FlowEvent::StepRetrying { step_id, .. } => {
1456 let mut state = self.state.lock().await;
1457 state.mark_status(&step_id, TaskStatus::InProgress);
1458 self.emit_task_update(state.tasks());
1459 }
1460 FlowEvent::StepFailed { step_id, .. } => {
1461 let mut state = self.state.lock().await;
1462 state.mark_status(&step_id, TaskStatus::Failed);
1463 self.emit_task_update(state.tasks());
1464 let (step_number, total_steps) = state.step_position(&step_id);
1465 let _ = self.tx.send(AgentEvent::StepEnd {
1466 step_id,
1467 status: TaskStatus::Failed,
1468 step_number,
1469 total_steps,
1470 });
1471 }
1472 FlowEvent::RunFailed { .. }
1473 | FlowEvent::RunTimedOut { .. }
1474 | FlowEvent::RunRetryExhausted { .. } => {
1475 let mut state = self.state.lock().await;
1476 for task in &mut state.plan.steps {
1477 if task.status.is_active() {
1478 task.status = TaskStatus::Failed;
1479 }
1480 }
1481 self.emit_task_update(state.tasks());
1482 }
1483 FlowEvent::RunCancelled { .. } | FlowEvent::RunHostShutdown { .. } => {
1484 let mut state = self.state.lock().await;
1485 for task in &mut state.plan.steps {
1486 if task.status.is_active() {
1487 task.status = TaskStatus::Cancelled;
1488 }
1489 }
1490 self.emit_task_update(state.tasks());
1491 }
1492 _ => {}
1493 }
1494 }
1495}
1496
1497fn workflow_step_description(step_id: &str, step_name: &str, input: Option<&Value>) -> String {
1498 if matches!(step_name, TASK_TOOL | PARALLEL_TASK_TOOL) {
1499 let count = input
1500 .and_then(|value| value.get("tasks"))
1501 .and_then(Value::as_array)
1502 .map(Vec::len)
1503 .unwrap_or(0);
1504 if count > 0 {
1505 return bounded_workflow_description(&format!(
1506 "Fan out {count} parallel subagent task(s)"
1507 ));
1508 }
1509 }
1510
1511 let description = input
1512 .and_then(|value| value.get("description").or_else(|| value.get("title")))
1513 .and_then(Value::as_str)
1514 .map(str::to_string)
1515 .unwrap_or_else(|| {
1516 if step_name == step_id {
1517 step_id.to_string()
1518 } else {
1519 format!("{step_name}: {step_id}")
1520 }
1521 });
1522 bounded_workflow_description(&description)
1523}
1524
1525fn bounded_workflow_description(value: &str) -> String {
1526 const MAX_DESCRIPTION_BYTES: usize = 512;
1527 if value.len() <= MAX_DESCRIPTION_BYTES {
1528 return value.to_string();
1529 }
1530 let mut end = MAX_DESCRIPTION_BYTES.saturating_sub("…".len());
1531 while end > 0 && !value.is_char_boundary(end) {
1532 end -= 1;
1533 }
1534 format!("{}…", &value[..end])
1535}
1536
1537#[derive(Clone)]
1538struct DynamicWorkflowLease {
1539 ledger: Arc<dyn FlowDecisionLedger>,
1540 decision_id: String,
1541 request_hash: String,
1542 identity: ExecutionIdentityV1,
1543 owner_id: String,
1544 lease_ms: u64,
1545 attempt: u32,
1546 metrics: Arc<DynamicWorkflowMetrics>,
1547}
1548
1549impl DynamicWorkflowLease {
1550 async fn renew(&self) -> Result<bool> {
1551 let result = self
1552 .ledger
1553 .renew_with_identity(
1554 &self.decision_id,
1555 &self.request_hash,
1556 &self.identity,
1557 &self.owner_id,
1558 dynamic_workflow_now_ms(),
1559 self.lease_ms,
1560 )
1561 .await;
1562 match &result {
1563 Ok(true) => DynamicWorkflowMetrics::increment(&self.metrics.renewals),
1564 Ok(false) => DynamicWorkflowMetrics::increment(&self.metrics.lease_lost),
1565 Err(_) => {
1566 DynamicWorkflowMetrics::increment(&self.metrics.lease_lost);
1567 DynamicWorkflowMetrics::increment(&self.metrics.failures);
1568 }
1569 }
1570 result
1571 }
1572
1573 async fn complete(&self) -> Result<()> {
1574 let result = self
1575 .ledger
1576 .complete_with_identity(
1577 &self.decision_id,
1578 &self.request_hash,
1579 &self.identity,
1580 &self.owner_id,
1581 dynamic_workflow_now_ms(),
1582 )
1583 .await;
1584 match &result {
1585 Ok(()) => DynamicWorkflowMetrics::increment(&self.metrics.completions),
1586 Err(_) => DynamicWorkflowMetrics::increment(&self.metrics.failures),
1587 }
1588 result
1589 }
1590
1591 async fn release(&self) -> Result<()> {
1592 let result = self
1593 .ledger
1594 .release_with_identity(
1595 &self.decision_id,
1596 &self.request_hash,
1597 &self.identity,
1598 &self.owner_id,
1599 )
1600 .await;
1601 match &result {
1602 Ok(()) => DynamicWorkflowMetrics::increment(&self.metrics.releases),
1603 Err(_) => DynamicWorkflowMetrics::increment(&self.metrics.failures),
1604 }
1605 result
1606 }
1607}
1608
1609enum DynamicWorkflowLeaseClaim {
1610 Owned(DynamicWorkflowLease),
1611 AlreadyCompleted,
1612}
1613
1614fn dynamic_workflow_lease_key(identity: &ExecutionIdentityV1) -> (String, String) {
1615 (
1616 format!("dynamic-workflow:{}", identity.digest),
1617 identity.digest.clone(),
1618 )
1619}
1620
1621async fn claim_dynamic_workflow_lease(
1622 ledger: Arc<dyn FlowDecisionLedger>,
1623 identity: ExecutionIdentityV1,
1624 lease_ms: u64,
1625 metrics: Arc<DynamicWorkflowMetrics>,
1626) -> Result<DynamicWorkflowLeaseClaim> {
1627 DynamicWorkflowMetrics::increment(&metrics.claim_attempts);
1628 let mut in_flight = DynamicWorkflowClaimInFlight::new(Arc::clone(&metrics));
1629 identity
1630 .validate()
1631 .map_err(|error| anyhow::anyhow!(error))?;
1632 let (decision_id, request_hash) = dynamic_workflow_lease_key(&identity);
1633 let owner_id = format!("dynamic-workflow-worker-{}", uuid::Uuid::new_v4());
1634 let outcome = ledger
1635 .claim_with_identity(
1636 &decision_id,
1637 &request_hash,
1638 &identity,
1639 &owner_id,
1640 dynamic_workflow_now_ms(),
1641 lease_ms,
1642 )
1643 .await
1644 .context("admit dynamic workflow worker lease")?;
1645 let result = match outcome {
1646 FlowDecisionClaimOutcome::Claimed { attempt } => {
1647 DynamicWorkflowMetrics::increment(&metrics.claims);
1648 if attempt > 1 {
1649 DynamicWorkflowMetrics::increment(&metrics.takeovers);
1650 }
1651 Ok(DynamicWorkflowLeaseClaim::Owned(DynamicWorkflowLease {
1652 ledger,
1653 decision_id,
1654 request_hash,
1655 identity,
1656 owner_id,
1657 lease_ms,
1658 attempt,
1659 metrics,
1660 }))
1661 }
1662 FlowDecisionClaimOutcome::Completed => {
1663 DynamicWorkflowMetrics::increment(&metrics.already_completed);
1664 Ok(DynamicWorkflowLeaseClaim::AlreadyCompleted)
1665 }
1666 FlowDecisionClaimOutcome::Busy {
1667 lease_expires_at_ms,
1668 } => {
1669 DynamicWorkflowMetrics::increment(&metrics.busy);
1670 anyhow::bail!("dynamic workflow worker lease is busy until {lease_expires_at_ms}")
1671 }
1672 FlowDecisionClaimOutcome::Conflict => {
1673 DynamicWorkflowMetrics::increment(&metrics.conflicts);
1674 anyhow::bail!("dynamic workflow worker lease identity conflicts with its claim")
1675 }
1676 };
1677 in_flight.finish();
1678 result
1679}
1680
1681struct DynamicWorkflowClaimInFlight {
1682 metrics: Arc<DynamicWorkflowMetrics>,
1683 finished: bool,
1684}
1685
1686impl DynamicWorkflowClaimInFlight {
1687 fn new(metrics: Arc<DynamicWorkflowMetrics>) -> Self {
1688 DynamicWorkflowMetrics::increment(&metrics.in_flight);
1689 Self {
1690 metrics,
1691 finished: false,
1692 }
1693 }
1694
1695 fn finish(&mut self) {
1696 if !self.finished {
1697 self.metrics.in_flight.fetch_sub(1, Ordering::Relaxed);
1698 self.finished = true;
1699 }
1700 }
1701}
1702
1703impl Drop for DynamicWorkflowClaimInFlight {
1704 fn drop(&mut self) {
1705 if !self.finished {
1706 self.finish();
1707 DynamicWorkflowMetrics::increment(&self.metrics.failures);
1708 }
1709 }
1710}
1711
1712fn dynamic_workflow_now_ms() -> u64 {
1713 SystemTime::now()
1714 .duration_since(UNIX_EPOCH)
1715 .unwrap_or_default()
1716 .as_millis()
1717 .min(u128::from(u64::MAX)) as u64
1718}
1719
1720fn is_terminal_workflow_event(event: &FlowEvent) -> bool {
1721 matches!(
1722 event,
1723 FlowEvent::RunCompleted { .. }
1724 | FlowEvent::RunFailed { .. }
1725 | FlowEvent::RunCancelled { .. }
1726 | FlowEvent::RunTimedOut { .. }
1727 | FlowEvent::RunRetryExhausted { .. }
1728 | FlowEvent::RunHostShutdown { .. }
1729 | FlowEvent::RunContinuedAsNew { .. }
1730 )
1731}
1732
1733fn history_is_terminal(history: &[FlowEventEnvelope]) -> bool {
1734 history
1735 .last()
1736 .is_some_and(|envelope| is_terminal_workflow_event(&envelope.event))
1737}
1738
1739pub struct DynamicWorkflowTool {
1741 registry: DynamicWorkflowRegistry,
1742 flow_event_store: Option<Arc<dyn FlowEventStore>>,
1743 graph_observer: Option<FlowGraphObserver>,
1744 task_scheduler: Option<Arc<TaskScheduler>>,
1745 admit_steps_globally: bool,
1746 runtime_build_compatibility: Option<RuntimeBuildCompatibility>,
1747 continuation_lease_ledger: Option<Arc<dyn FlowDecisionLedger>>,
1748 continuation_lease_ms: u64,
1749 memory_continuation_lease_ledger: Arc<MemoryFlowDecisionLedger>,
1750 metrics: Arc<DynamicWorkflowMetrics>,
1751}
1752
1753enum DynamicWorkflowRegistry {
1754 Standalone(Arc<ToolRegistry>),
1755 RegistryBound(Weak<ToolRegistry>),
1756}
1757
1758impl DynamicWorkflowRegistry {
1759 fn resolve(&self) -> Option<Arc<ToolRegistry>> {
1760 match self {
1761 Self::Standalone(registry) => Some(Arc::clone(registry)),
1762 Self::RegistryBound(registry) => registry.upgrade(),
1763 }
1764 }
1765}
1766
1767impl DynamicWorkflowTool {
1768 pub fn new(registry: Arc<ToolRegistry>) -> Self {
1769 Self {
1770 registry: DynamicWorkflowRegistry::Standalone(registry),
1771 flow_event_store: None,
1772 graph_observer: None,
1773 task_scheduler: None,
1774 admit_steps_globally: false,
1775 runtime_build_compatibility: None,
1776 continuation_lease_ledger: None,
1777 continuation_lease_ms: DEFAULT_DYNAMIC_WORKFLOW_LEASE_MS,
1778 memory_continuation_lease_ledger: Arc::new(MemoryFlowDecisionLedger::new()),
1779 metrics: Arc::new(DynamicWorkflowMetrics::default()),
1780 }
1781 }
1782
1783 fn new_registry_bound(registry: Arc<ToolRegistry>) -> Self {
1784 Self {
1785 registry: DynamicWorkflowRegistry::RegistryBound(Arc::downgrade(®istry)),
1786 flow_event_store: None,
1787 graph_observer: None,
1788 task_scheduler: None,
1789 admit_steps_globally: false,
1790 runtime_build_compatibility: None,
1791 continuation_lease_ledger: None,
1792 continuation_lease_ms: DEFAULT_DYNAMIC_WORKFLOW_LEASE_MS,
1793 memory_continuation_lease_ledger: Arc::new(MemoryFlowDecisionLedger::new()),
1794 metrics: Arc::new(DynamicWorkflowMetrics::default()),
1795 }
1796 }
1797
1798 pub fn health(&self) -> DynamicWorkflowHealthSnapshot {
1801 self.metrics.snapshot()
1802 }
1803
1804 pub fn with_graph_observer(mut self, observer: FlowGraphObserver) -> Self {
1807 self.graph_observer = Some(observer);
1808 self
1809 }
1810
1811 pub fn with_flow_event_store(mut self, store: Arc<dyn FlowEventStore>) -> Self {
1819 self.flow_event_store = Some(store);
1820 self
1821 }
1822
1823 pub fn with_task_scheduler(
1827 mut self,
1828 scheduler: Arc<TaskScheduler>,
1829 admit_steps_globally: bool,
1830 ) -> Self {
1831 self.task_scheduler = Some(scheduler);
1832 self.admit_steps_globally = admit_steps_globally;
1833 self
1834 }
1835
1836 pub fn with_runtime_build_compatibility(
1842 mut self,
1843 compatibility: RuntimeBuildCompatibility,
1844 ) -> Self {
1845 self.runtime_build_compatibility = Some(compatibility);
1846 self
1847 }
1848
1849 pub fn with_continuation_lease_ledger(
1859 mut self,
1860 ledger: Arc<dyn FlowDecisionLedger>,
1861 lease_ms: u64,
1862 ) -> Self {
1863 self.continuation_lease_ledger = Some(ledger);
1864 self.continuation_lease_ms = lease_ms.max(1);
1865 self
1866 }
1867
1868 pub fn with_continuation_lease_ms(mut self, lease_ms: u64) -> Self {
1871 self.continuation_lease_ms = lease_ms.max(1);
1872 self
1873 }
1874
1875 pub fn control(
1882 &self,
1883 run_id: impl Into<String>,
1884 source: impl Into<String>,
1885 input: Value,
1886 ctx: &ToolContext,
1887 ) -> Result<DynamicWorkflowControl> {
1888 let registry = self
1889 .registry
1890 .resolve()
1891 .ok_or_else(|| anyhow::anyhow!("tool registry is closed"))?;
1892 let run_id = run_id.into();
1893 if !safe_workflow_run_id(&run_id) {
1894 anyhow::bail!(
1895 "dynamic workflow run_id must contain only ASCII letters, numbers, '-' or '_'"
1896 );
1897 }
1898 let source = source.into();
1899 if source.is_empty() {
1900 anyhow::bail!("dynamic workflow source must not be empty");
1901 }
1902 dynamic_workflow_input_identity(&input)
1903 .map_err(|error| anyhow::anyhow!("invalid dynamic workflow input: {error}"))?;
1904 let allowed_tools = default_allowed_tools(®istry);
1905 Ok(DynamicWorkflowControl {
1906 registry,
1907 context: ctx.clone(),
1908 flow_event_store: self.flow_event_store.clone(),
1909 run_id,
1910 source: Arc::from(source),
1911 input,
1912 allowed_tools,
1913 limits: DynamicWorkflowScriptLimits::default(),
1914 graph_observer: self.graph_observer.clone(),
1915 task_scheduler: self.task_scheduler.clone(),
1916 admit_steps_globally: self.admit_steps_globally,
1917 runtime_build_compatibility: self.runtime_build_compatibility.clone(),
1918 continuation_lease_ledger: self.continuation_lease_ledger.clone(),
1919 continuation_lease_ms: self.continuation_lease_ms,
1920 memory_continuation_lease_ledger: Arc::clone(&self.memory_continuation_lease_ledger),
1921 metrics: Arc::clone(&self.metrics),
1922 })
1923 }
1924
1925 async fn continuation_lease_ledger_for_context(
1926 &self,
1927 ctx: &ToolContext,
1928 ) -> Result<Arc<dyn FlowDecisionLedger>> {
1929 dynamic_workflow_lease_ledger_for_context(
1930 self.continuation_lease_ledger.as_ref(),
1931 &self.memory_continuation_lease_ledger,
1932 ctx,
1933 )
1934 .await
1935 }
1936}
1937
1938impl DynamicWorkflowControl {
1939 pub fn run_id(&self) -> &str {
1941 &self.run_id
1942 }
1943
1944 pub fn health(&self) -> DynamicWorkflowHealthSnapshot {
1947 self.metrics.snapshot()
1948 }
1949
1950 pub async fn diagnostics(&self) -> Result<DynamicWorkflowControlDiagnostics> {
1954 let scheduler = match self.task_scheduler.as_ref() {
1955 Some(scheduler) => Some(scheduler.health().await.map_err(|error| {
1956 anyhow::anyhow!("read dynamic workflow scheduler health: {error}")
1957 })?),
1958 None => None,
1959 };
1960 let scheduler_quota = if self.admit_steps_globally {
1961 if let (Some(scheduler), Some(quota)) = (
1962 self.task_scheduler.as_ref(),
1963 self.prepare(true).await?.scheduler_quota,
1969 ) {
1970 Some(scheduler.quota_snapshot("a).await.map_err(|error| {
1971 anyhow::anyhow!("read dynamic workflow scheduler quota: {error}")
1972 })?)
1973 } else {
1974 None
1975 }
1976 } else {
1977 None
1978 };
1979 Ok(DynamicWorkflowControlDiagnostics {
1980 workflow: self.metrics.snapshot(),
1981 scheduler,
1982 scheduler_quota,
1983 })
1984 }
1985
1986 pub fn with_allowed_tools(mut self, allowed_tools: impl IntoIterator<Item = String>) -> Self {
1988 self.allowed_tools = sanitize_allowed_tools(allowed_tools);
1989 self
1990 }
1991
1992 pub fn with_limits(mut self, limits: DynamicWorkflowScriptLimits) -> Self {
1994 self.limits = limits;
1995 self
1996 }
1997
1998 pub fn with_graph_observer(mut self, observer: FlowGraphObserver) -> Self {
2000 self.graph_observer = Some(observer);
2001 self
2002 }
2003
2004 pub fn with_task_scheduler(
2006 mut self,
2007 scheduler: Arc<TaskScheduler>,
2008 admit_steps_globally: bool,
2009 ) -> Self {
2010 self.task_scheduler = Some(scheduler);
2011 self.admit_steps_globally = admit_steps_globally;
2012 self
2013 }
2014
2015 pub fn with_runtime_build_compatibility(
2017 mut self,
2018 compatibility: RuntimeBuildCompatibility,
2019 ) -> Self {
2020 self.runtime_build_compatibility = Some(compatibility);
2021 self
2022 }
2023
2024 pub fn with_continuation_lease_ledger(
2026 mut self,
2027 ledger: Arc<dyn FlowDecisionLedger>,
2028 lease_ms: u64,
2029 ) -> Self {
2030 self.continuation_lease_ledger = Some(ledger);
2031 self.continuation_lease_ms = lease_ms.max(1);
2032 self
2033 }
2034
2035 pub fn with_continuation_lease_ms(mut self, lease_ms: u64) -> Self {
2037 self.continuation_lease_ms = lease_ms.max(1);
2038 self
2039 }
2040}
2041
2042async fn dynamic_workflow_lease_ledger_for_context(
2043 explicit: Option<&Arc<dyn FlowDecisionLedger>>,
2044 memory: &Arc<MemoryFlowDecisionLedger>,
2045 ctx: &ToolContext,
2046) -> Result<Arc<dyn FlowDecisionLedger>> {
2047 if let Some(ledger) = explicit {
2048 return Ok(Arc::clone(ledger));
2049 }
2050 let Some(root) = ctx.workspace_services.local_root() else {
2051 let ledger: Arc<dyn FlowDecisionLedger> = memory.clone();
2052 return Ok(ledger);
2053 };
2054 let workflow_root = dynamic_workflow_store_path(root);
2055 validate_dynamic_workflow_directory(&root.join(".a3s"), ".a3s").await?;
2056 validate_dynamic_workflow_directory(&workflow_root, ".a3s/workflow").await?;
2057 let lease_root = workflow_root.join(DYNAMIC_WORKFLOW_LEASE_RELATIVE_PATH);
2058 validate_dynamic_workflow_directory(&lease_root, ".a3s/workflow/leases").await?;
2059 Ok(Arc::new(FileFlowDecisionLedger::new(lease_root)))
2060}
2061
2062#[async_trait]
2063impl Tool for DynamicWorkflowTool {
2064 fn name(&self) -> &str {
2065 DYNAMIC_WORKFLOW_TOOL
2066 }
2067
2068 fn description(&self) -> &str {
2069 "Run a local dynamic workflow with A3S Flow. The workflow source is a sandboxed JavaScript PTC script that may call allowed ctx tools; A3S Flow records workflow and step history."
2070 }
2071
2072 fn parameters(&self) -> Value {
2073 json!({
2074 "type": "object",
2075 "additionalProperties": false,
2076 "properties": {
2077 "source": {
2078 "type": "string",
2079 "description": "JavaScript PTC source defining async function run(ctx, inputs). For inputs.kind='workflow', return a Flow command: {type:'complete', output}, {type:'fail', error}, {type:'schedule_step', step_id, step_name, input, retry?}, or {type:'schedule_steps', steps:[...]}. For inputs.kind='step', return the step JSON output. A scheduled step with step_name='task' bypasses QuickJS and calls the host task tool directly with input as its arguments; legacy parallel_task steps remain readable."
2080 },
2081 "input": {
2082 "type": "object",
2083 "description": "Initial workflow input."
2084 },
2085 "run_id": {
2086 "type": "string",
2087 "description": "Optional durable run id. Reusing it with the same source and input is idempotent."
2088 },
2089 "allowed_tools": {
2090 "type": "array",
2091 "description": "Tool names the workflow script may call through ctx. Defaults to all registered tools except program, dynamic_workflow, and the legacy parallel_task alias. Direct task fan-out is blocked inside QuickJS; schedule a host task step instead. Login-registered tools such as runtime are allowed when present.",
2092 "items": { "type": "string" }
2093 },
2094 "limits": {
2095 "type": "object",
2096 "additionalProperties": false,
2097 "properties": {
2098 "timeoutMs": { "type": "integer", "minimum": 1 },
2099 "maxToolCalls": { "type": "integer", "minimum": 1 },
2100 "maxOutputBytes": { "type": "integer", "minimum": 1 },
2101 "maxConcurrentGenerations": {
2102 "type": "integer",
2103 "minimum": 1,
2104 "maximum": 4,
2105 "description": "Optional bounded fan-out for independently session-bound generate_object steps. Providers without session forking remain single-flight."
2106 },
2107 "maxConcurrentSteps": {
2108 "type": "integer",
2109 "minimum": 1,
2110 "maximum": 32,
2111 "description": "Optional per-workflow limit for concurrently executing Flow step bodies."
2112 }
2113 }
2114 }
2115 },
2116 "required": ["source"]
2117 })
2118 }
2119
2120 async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
2121 let Some(registry) = self.registry.resolve() else {
2122 return Ok(ToolOutput::error("Tool registry is closed"));
2123 };
2124 let Some(source) = args.get("source").and_then(Value::as_str) else {
2125 return Ok(ToolOutput::error("dynamic_workflow requires source"));
2126 };
2127 let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
2128 let allowed_tools = args
2129 .get("allowed_tools")
2130 .and_then(Value::as_array)
2131 .map(|items| {
2132 items
2133 .iter()
2134 .filter_map(Value::as_str)
2135 .map(ToString::to_string)
2136 .collect::<Vec<_>>()
2137 })
2138 .unwrap_or_else(|| default_allowed_tools(®istry));
2139 let limits: DynamicWorkflowScriptLimits = args
2140 .get("limits")
2141 .cloned()
2142 .and_then(|value| serde_json::from_value(value).ok())
2143 .unwrap_or_default();
2144
2145 let (runtime_build_id, runtime_build_compatibility) =
2146 match dynamic_workflow_runtime_configuration(self.runtime_build_compatibility.as_ref())
2147 {
2148 Ok(configuration) => configuration,
2149 Err(error) => {
2150 return Ok(ToolOutput::error(format!(
2151 "invalid dynamic workflow runtime build identity: {error}"
2152 )))
2153 }
2154 };
2155 let source_hash = source_hash(source);
2156 let base_spec = WorkflowSpec::rust_embedded(
2157 "a3s-code.dynamic-workflow",
2158 source_hash.as_str(),
2159 "ptc",
2160 "run",
2161 );
2162
2163 if ctx.is_cancelled() {
2164 return Ok(ToolOutput::error(
2165 "dynamic_workflow was cancelled before admission",
2166 ));
2167 }
2168 let requested_run_id = args.get("run_id").and_then(Value::as_str);
2169 if requested_run_id.is_some_and(|run_id| !safe_workflow_run_id(run_id)) {
2170 return Ok(ToolOutput::error(
2171 "dynamic_workflow run_id must contain only ASCII letters, numbers, '-' or '_'",
2172 ));
2173 }
2174 let run_id = requested_run_id
2175 .map(ToString::to_string)
2176 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2177 let store = match self.flow_event_store.clone() {
2178 Some(store) => Ok(store),
2179 None => flow_store_for_context(ctx, Some(&run_id)).await,
2180 };
2181 let store = match store {
2182 Ok(store) => store,
2183 Err(error) => return Ok(ToolOutput::error(error.to_string())),
2184 };
2185 let prior_history = match store.list(&run_id).await {
2186 Ok(history) => history,
2187 Err(a3s_flow::FlowError::RunNotFound(_)) => Vec::new(),
2188 Err(error) => return Ok(ToolOutput::error(error.to_string())),
2189 };
2190 let (spec, effective_runtime_build_id) = prior_history
2196 .iter()
2197 .find_map(|envelope| match &envelope.event {
2198 FlowEvent::RunCreated { spec, .. } => Some(spec.clone()),
2199 _ => None,
2200 })
2201 .map(|persisted_spec| {
2202 let runtime_build_id = persisted_spec
2203 .runtime_build_id
2204 .as_ref()
2205 .map(ToString::to_string);
2206 (persisted_spec, runtime_build_id)
2207 })
2208 .unwrap_or_else(|| {
2209 let spec = base_spec.with_runtime_build(runtime_build_id.clone());
2210 (spec, Some(runtime_build_id.to_string()))
2211 });
2212 let claim_identity = match dynamic_workflow_claim_identity(
2213 &run_id,
2214 source,
2215 &input,
2216 runtime_build_id.as_str(),
2217 &prior_history,
2218 ) {
2219 Ok(identity) => identity,
2220 Err(error) => {
2221 return Ok(ToolOutput::error(format!(
2222 "dynamic workflow continuation identity rejected: {error}"
2223 )))
2224 }
2225 };
2226 let lease_ledger = match self.continuation_lease_ledger_for_context(ctx).await {
2227 Ok(ledger) => ledger,
2228 Err(error) => return Ok(ToolOutput::error(error.to_string())),
2229 };
2230 let lease_claim = match claim_dynamic_workflow_lease(
2231 lease_ledger,
2232 claim_identity.clone(),
2233 self.continuation_lease_ms,
2234 Arc::clone(&self.metrics),
2235 )
2236 .await
2237 {
2238 Ok(claim) => claim,
2239 Err(error) => return Ok(ToolOutput::error(error.to_string())),
2240 };
2241 let (lease, mut lease_state) = match lease_claim {
2242 DynamicWorkflowLeaseClaim::Owned(lease) => (Some(lease), "claimed"),
2243 DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2244 if !history_is_terminal(&prior_history) {
2245 return Ok(ToolOutput::error(
2246 "dynamic workflow worker claim is completed but its durable run is not terminal",
2247 ));
2248 }
2249 (None, "already_completed")
2250 }
2251 };
2252 let parent_cancellation = ctx.cancellation_token();
2253 let child_cancellation = parent_cancellation.child_token();
2254 let workflow_context = ctx
2255 .clone()
2256 .with_run_id(run_id.clone())
2257 .with_cancellation(child_cancellation.clone());
2258 let scheduler_quota = if self.admit_steps_globally && self.task_scheduler.is_some() {
2259 Some(
2260 TaskSchedulerQuota::new(
2261 lease
2262 .as_ref()
2263 .map(|lease| lease.identity.clone())
2264 .unwrap_or_else(|| claim_identity.clone()),
2265 dynamic_workflow_scheduler_quota_limit(&limits),
2266 )
2267 .map_err(|error| {
2268 anyhow::anyhow!("build dynamic workflow scheduler quota: {error}")
2269 })?,
2270 )
2271 } else {
2272 None
2273 };
2274 let mut runtime = DynamicWorkflowRuntime::new(registry, workflow_context.clone(), source)
2275 .with_allowed_tools(allowed_tools)
2276 .with_limits(limits);
2277 if let Some(scheduler) = &self.task_scheduler {
2278 runtime = runtime.with_task_scheduler(Arc::clone(scheduler), self.admit_steps_globally);
2279 if let Some(quota) = scheduler_quota {
2280 runtime = runtime.with_task_scheduler_quota(quota);
2281 }
2282 }
2283 if let Some(lease) = lease.as_ref() {
2284 runtime = runtime.with_continuation_lease(Arc::new(lease.clone()));
2285 }
2286 let runtime = Arc::new(runtime);
2287 let runtime_for_metadata = Arc::clone(&runtime);
2288 let initial_plan = if prior_history.is_empty() {
2289 ExecutionPlan::new("dynamic workflow", Complexity::Medium)
2290 } else {
2291 dynamic_workflow_execution_plan(&prior_history)
2292 };
2293 let mut observers: Vec<Arc<dyn FlowEventObserver>> = Vec::new();
2294 if let Some(tx) = ctx.agent_event_tx.clone() {
2295 observers.push(Arc::new(AgentEventFlowObserver::new(
2296 tx,
2297 ctx.session_id.clone().unwrap_or_default(),
2298 initial_plan,
2299 )));
2300 }
2301 if let Some(observer) = &self.graph_observer {
2302 observers.push(Arc::new(observer.clone()));
2303 }
2304 let mut engine_builder = FlowEngine::builder(runtime)
2305 .with_store(store)
2306 .with_runtime_build_compatibility(runtime_build_compatibility);
2307 if !observers.is_empty() {
2308 engine_builder = engine_builder
2309 .with_observer(Arc::new(FanoutFlowEventObserver::from_observers(observers)));
2310 }
2311 let engine = engine_builder.build();
2312 let input_for_identity = input.clone();
2313
2314 let execution_result = if let Some(lease) = lease.as_ref() {
2315 drive_dynamic_workflow_with_lease(
2316 &engine,
2317 &run_id,
2318 spec,
2319 input,
2320 DynamicWorkflowDriveContext {
2321 source,
2322 input_for_identity: &input_for_identity,
2323 runtime_build_id: runtime_build_id.as_str(),
2324 workflow_context: &workflow_context,
2325 parent_cancellation,
2326 child_cancellation,
2327 },
2328 lease,
2329 )
2330 .await
2331 } else {
2332 let started_run_id = match engine.start_with_id(&run_id, spec, input).await {
2333 Ok(run_id) => run_id,
2334 Err(error) => return Ok(ToolOutput::error(error.to_string())),
2335 };
2336 if started_run_id != run_id {
2337 return Ok(ToolOutput::error(format!(
2338 "dynamic workflow engine returned unexpected run id `{started_run_id}` for `{run_id}`"
2339 )));
2340 }
2341 let snapshot = match drive_inline_retries(&engine, &run_id, &workflow_context).await {
2342 Ok(snapshot) => snapshot,
2343 Err(error) => return Ok(ToolOutput::error(error.to_string())),
2344 };
2345 collect_dynamic_workflow_execution(
2346 &engine,
2347 &run_id,
2348 snapshot,
2349 source,
2350 &input_for_identity,
2351 runtime_build_id.as_str(),
2352 )
2353 .await
2354 };
2355 let execution = match execution_result {
2356 Ok(execution) => execution,
2357 Err(err) => return Ok(ToolOutput::error(err.to_string())),
2358 };
2359 let DynamicWorkflowExecution {
2360 snapshot,
2361 history,
2362 continuation_identity,
2363 } = execution;
2364 if lease.is_some() {
2365 lease_state = if snapshot.status.is_terminal() {
2366 "completed"
2367 } else {
2368 "released"
2369 };
2370 }
2371
2372 let output = match &snapshot.output {
2373 Some(output) => {
2374 serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string())
2375 }
2376 None => snapshot
2377 .error
2378 .clone()
2379 .unwrap_or_else(|| format!("workflow status: {:?}", snapshot.status)),
2380 };
2381
2382 let status = snapshot.status;
2383 let plan = dynamic_workflow_execution_plan(&history);
2384 let plan_identity = match plan.definition_identity() {
2385 Ok(identity) => identity,
2386 Err(error) => return Ok(ToolOutput::error(error.to_string())),
2387 };
2388 let metadata = json!({
2389 "dynamic_workflow": {
2390 "run_id": run_id,
2391 "status": format!("{:?}", snapshot.status),
2392 "last_sequence": snapshot.last_sequence,
2393 "source_hash": source_hash,
2394 "runtime_build_id": effective_runtime_build_id,
2395 "snapshot": snapshot,
2396 "history": history,
2397 "plan": plan,
2398 "plan_identity": plan_identity,
2399 "continuation_identity": continuation_identity,
2400 "worker_lease": {
2401 "state": lease_state,
2402 "attempt": lease.as_ref().map(|lease| lease.attempt),
2403 },
2404 "admission": runtime_for_metadata.admission_stats(),
2405 }
2406 });
2407 let output = match status {
2408 WorkflowRunStatus::Completed => ToolOutput::success(output),
2409 WorkflowRunStatus::Failed | WorkflowRunStatus::Cancelled => ToolOutput::error(output),
2410 _ => ToolOutput::error(format!(
2411 "dynamic_workflow ended without a terminal result: {status:?}; {output}"
2412 )),
2413 };
2414
2415 Ok(output.with_metadata(metadata))
2416 }
2417}
2418
2419struct DynamicWorkflowControlPreparation {
2420 store: Arc<dyn FlowEventStore>,
2421 prior_history: Vec<FlowEventEnvelope>,
2422 spec: WorkflowSpec,
2423 effective_runtime_build_id: Option<String>,
2424 runtime_build_id: RuntimeBuildId,
2425 runtime_build_compatibility: RuntimeBuildCompatibility,
2426 claim_identity: ExecutionIdentityV1,
2427 scheduler_quota: Option<TaskSchedulerQuota>,
2428 lease_ledger: Arc<dyn FlowDecisionLedger>,
2429}
2430
2431fn dynamic_workflow_runtime_configuration(
2432 configured: Option<&RuntimeBuildCompatibility>,
2433) -> Result<(RuntimeBuildId, RuntimeBuildCompatibility)> {
2434 let runtime_build_id =
2435 match configured {
2436 Some(compatibility) => compatibility.current_build_id().clone(),
2437 None => RuntimeBuildId::new(DYNAMIC_WORKFLOW_RUNTIME_BUILD_ID.to_string()).map_err(
2438 |error| anyhow::anyhow!("invalid dynamic workflow runtime build identity: {error}"),
2439 )?,
2440 };
2441 let compatibility = configured.cloned().unwrap_or_else(|| {
2442 RuntimeBuildCompatibility::new(runtime_build_id.clone()).accept_unpinned()
2443 });
2444 Ok((runtime_build_id, compatibility))
2445}
2446
2447impl DynamicWorkflowControl {
2448 async fn prepare(&self, allow_missing: bool) -> Result<DynamicWorkflowControlPreparation> {
2449 let (runtime_build_id, runtime_build_compatibility) =
2450 dynamic_workflow_runtime_configuration(self.runtime_build_compatibility.as_ref())?;
2451 let source_hash = source_hash(self.source.as_ref());
2452 let base_spec = WorkflowSpec::rust_embedded(
2453 "a3s-code.dynamic-workflow",
2454 source_hash.as_str(),
2455 "ptc",
2456 "run",
2457 );
2458 let store = match self.flow_event_store.clone() {
2459 Some(store) => store,
2460 None => flow_store_for_context(&self.context, Some(&self.run_id)).await?,
2461 };
2462 let prior_history = match store.list(&self.run_id).await {
2463 Ok(history) => history,
2464 Err(a3s_flow::FlowError::RunNotFound(_)) if allow_missing => Vec::new(),
2465 Err(error) => return Err(error.into()),
2466 };
2467 if !allow_missing && prior_history.is_empty() {
2468 return Err(a3s_flow::FlowError::RunNotFound(self.run_id.clone()).into());
2469 }
2470 let (spec, effective_runtime_build_id) = prior_history
2471 .iter()
2472 .find_map(|envelope| match &envelope.event {
2473 FlowEvent::RunCreated { spec, .. } => Some(spec.clone()),
2474 _ => None,
2475 })
2476 .map(|persisted_spec| {
2477 let runtime_build_id = persisted_spec
2478 .runtime_build_id
2479 .as_ref()
2480 .map(ToString::to_string);
2481 (persisted_spec, runtime_build_id)
2482 })
2483 .unwrap_or_else(|| {
2484 let spec = base_spec.with_runtime_build(runtime_build_id.clone());
2485 (spec, Some(runtime_build_id.to_string()))
2486 });
2487 let claim_identity = dynamic_workflow_claim_identity(
2488 &self.run_id,
2489 self.source.as_ref(),
2490 &self.input,
2491 runtime_build_id.as_str(),
2492 &prior_history,
2493 )
2494 .map_err(|error| {
2495 anyhow::anyhow!("dynamic workflow continuation identity rejected: {error}")
2496 })?;
2497 let scheduler_quota = if self.admit_steps_globally && self.task_scheduler.is_some() {
2498 Some(
2499 TaskSchedulerQuota::new(
2500 claim_identity.clone(),
2501 dynamic_workflow_scheduler_quota_limit(&self.limits),
2502 )
2503 .map_err(|error| {
2504 anyhow::anyhow!("build dynamic workflow scheduler quota: {error}")
2505 })?,
2506 )
2507 } else {
2508 None
2509 };
2510 let lease_ledger = dynamic_workflow_lease_ledger_for_context(
2511 self.continuation_lease_ledger.as_ref(),
2512 &self.memory_continuation_lease_ledger,
2513 &self.context,
2514 )
2515 .await?;
2516 Ok(DynamicWorkflowControlPreparation {
2517 store,
2518 prior_history,
2519 spec,
2520 effective_runtime_build_id,
2521 runtime_build_id,
2522 runtime_build_compatibility,
2523 claim_identity,
2524 scheduler_quota,
2525 lease_ledger,
2526 })
2527 }
2528
2529 fn build_engine(
2530 &self,
2531 preparation: &DynamicWorkflowControlPreparation,
2532 workflow_context: ToolContext,
2533 lease: Option<&DynamicWorkflowLease>,
2534 ) -> FlowEngine {
2535 let mut runtime = DynamicWorkflowRuntime::new(
2536 Arc::clone(&self.registry),
2537 workflow_context,
2538 self.source.as_ref(),
2539 )
2540 .with_allowed_tools(self.allowed_tools.clone())
2541 .with_limits(self.limits.clone());
2542 if let Some(scheduler) = &self.task_scheduler {
2543 runtime = runtime.with_task_scheduler(Arc::clone(scheduler), self.admit_steps_globally);
2544 if let Some(quota) = preparation.scheduler_quota.clone() {
2545 runtime = runtime.with_task_scheduler_quota(quota);
2546 }
2547 }
2548 if let Some(lease) = lease {
2549 runtime = runtime.with_continuation_lease(Arc::new(lease.clone()));
2550 }
2551 let runtime = Arc::new(runtime);
2552 let initial_plan = if preparation.prior_history.is_empty() {
2553 ExecutionPlan::new("dynamic workflow", Complexity::Medium)
2554 } else {
2555 dynamic_workflow_execution_plan(&preparation.prior_history)
2556 };
2557 let mut observers: Vec<Arc<dyn FlowEventObserver>> = Vec::new();
2558 if let Some(tx) = self.context.agent_event_tx.clone() {
2559 observers.push(Arc::new(AgentEventFlowObserver::new(
2560 tx,
2561 self.context.session_id.clone().unwrap_or_default(),
2562 initial_plan,
2563 )));
2564 }
2565 if let Some(observer) = &self.graph_observer {
2566 observers.push(Arc::new(observer.clone()));
2567 }
2568 let mut builder = FlowEngine::builder(runtime)
2569 .with_store(Arc::clone(&preparation.store))
2570 .with_runtime_build_compatibility(preparation.runtime_build_compatibility.clone());
2571 if !observers.is_empty() {
2572 builder =
2573 builder.with_observer(Arc::new(FanoutFlowEventObserver::from_observers(observers)));
2574 }
2575 builder.build()
2576 }
2577
2578 async fn read_state(
2579 &self,
2580 preparation: &DynamicWorkflowControlPreparation,
2581 engine: &FlowEngine,
2582 ) -> Result<(WorkflowRunSnapshot, Vec<FlowEventEnvelope>)> {
2583 for _ in 0..3 {
2584 let before_history = engine.history(&self.run_id).await?;
2589 let snapshot = engine.snapshot(&self.run_id).await?;
2590 let history = engine.history(&self.run_id).await?;
2591 let before_sequence = before_history
2592 .last()
2593 .map(|event| event.sequence)
2594 .unwrap_or(0);
2595 let last_sequence = history.last().map(|event| event.sequence).unwrap_or(0);
2596 if before_sequence == last_sequence && snapshot.last_sequence == last_sequence {
2597 return Ok((snapshot, history));
2598 }
2599 }
2600 let expected = preparation
2601 .prior_history
2602 .last()
2603 .map(|event| event.sequence)
2604 .unwrap_or(0);
2605 anyhow::bail!(
2606 "dynamic workflow state changed repeatedly while inspecting run `{}` (initial sequence {expected})",
2607 self.run_id
2608 )
2609 }
2610
2611 async fn summarize(
2612 &self,
2613 preparation: &DynamicWorkflowControlPreparation,
2614 snapshot: WorkflowRunSnapshot,
2615 history: Vec<FlowEventEnvelope>,
2616 ) -> Result<DynamicWorkflowControlSnapshot> {
2617 let continuation_identity = dynamic_workflow_continuation_identity(
2618 &self.run_id,
2619 self.source.as_ref(),
2620 &self.input,
2621 preparation.runtime_build_id.as_str(),
2622 &history,
2623 )
2624 .map_err(|error| {
2625 anyhow::anyhow!(
2626 "dynamic workflow continuation identity rejected during inspection: {error}"
2627 )
2628 })?;
2629 let plan = dynamic_workflow_execution_plan(&history);
2630 let plan_identity = plan.definition_identity()?;
2631 let (decision_id, request_hash) = dynamic_workflow_lease_key(&preparation.claim_identity);
2632 let worker_lease = preparation
2633 .lease_ledger
2634 .inspect_with_identity(&decision_id, &request_hash, &preparation.claim_identity)
2635 .await?;
2636 let completed_steps = snapshot
2637 .steps
2638 .values()
2639 .filter(|step| step.status == StepStatus::Completed)
2640 .count();
2641 let open_steps = snapshot
2642 .steps
2643 .values()
2644 .filter(|step| {
2645 !matches!(
2646 step.status,
2647 StepStatus::Completed | StepStatus::Failed | StepStatus::Cancelled
2648 )
2649 })
2650 .count();
2651 Ok(DynamicWorkflowControlSnapshot {
2652 run_id: self.run_id.clone(),
2653 status: snapshot.status,
2654 last_sequence: snapshot.last_sequence,
2655 step_count: snapshot.steps.len(),
2656 completed_steps,
2657 open_steps,
2658 cancellation_requested: snapshot.cancellation.is_some(),
2659 continuation_identity,
2660 plan_identity,
2661 runtime_build_id: preparation.effective_runtime_build_id.clone(),
2662 worker_lease,
2663 })
2664 }
2665
2666 async fn claim(
2667 &self,
2668 preparation: &DynamicWorkflowControlPreparation,
2669 ) -> Result<DynamicWorkflowLeaseClaim> {
2670 claim_dynamic_workflow_lease(
2671 Arc::clone(&preparation.lease_ledger),
2672 preparation.claim_identity.clone(),
2673 self.continuation_lease_ms,
2674 Arc::clone(&self.metrics),
2675 )
2676 .await
2677 }
2678
2679 pub async fn inspect(&self) -> Result<DynamicWorkflowControlSnapshot> {
2681 let preparation = self.prepare(false).await?;
2682 let engine = self.build_engine(&preparation, self.context.clone(), None);
2683 let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2684 self.summarize(&preparation, snapshot, history).await
2685 }
2686
2687 pub async fn history(&self) -> Result<Vec<FlowEventEnvelope>> {
2693 let preparation = self.prepare(false).await?;
2694 let history = preparation.store.list(&self.run_id).await?;
2695 dynamic_workflow_continuation_identity(
2696 &self.run_id,
2697 self.source.as_ref(),
2698 &self.input,
2699 preparation.runtime_build_id.as_str(),
2700 &history,
2701 )
2702 .map_err(|error| anyhow::anyhow!("dynamic workflow history rejected: {error}"))?;
2703 Ok(history)
2704 }
2705
2706 pub async fn request_cancellation(
2712 &self,
2713 reason: Option<String>,
2714 ) -> Result<DynamicWorkflowControlSnapshot> {
2715 validate_dynamic_workflow_control_reason(reason.as_deref())?;
2716 if self.context.is_cancelled() {
2717 anyhow::bail!("dynamic workflow control was cancelled before admission");
2718 }
2719 let preparation = self.prepare(false).await?;
2720 let claim = self.claim(&preparation).await?;
2721 let lease = match claim {
2722 DynamicWorkflowLeaseClaim::Owned(lease) => lease,
2723 DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2724 let engine = self.build_engine(&preparation, self.context.clone(), None);
2725 let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2726 if !snapshot.status.is_terminal() {
2727 anyhow::bail!(
2728 "dynamic workflow worker claim is completed but its durable run is not terminal"
2729 );
2730 }
2731 return self.summarize(&preparation, snapshot, history).await;
2732 }
2733 };
2734 let parent_cancellation = self.context.cancellation_token();
2735 let child_cancellation = parent_cancellation.child_token();
2736 let workflow_context = self
2737 .context
2738 .clone()
2739 .with_cancellation(child_cancellation.clone());
2740 let engine = self.build_engine(&preparation, workflow_context.clone(), Some(&lease));
2741 let future = engine.request_cancellation(&self.run_id, CancellationRequest::new(reason));
2742 let execution = drive_dynamic_workflow_control_future(
2743 &engine,
2744 &self.run_id,
2745 DynamicWorkflowDriveContext {
2746 source: self.source.as_ref(),
2747 input_for_identity: &self.input,
2748 runtime_build_id: preparation.runtime_build_id.as_str(),
2749 workflow_context: &workflow_context,
2750 parent_cancellation,
2751 child_cancellation,
2752 },
2753 &lease,
2754 future,
2755 )
2756 .await?;
2757 self.summarize(&preparation, execution.snapshot, execution.history)
2758 .await
2759 }
2760
2761 pub async fn force_cancel(
2764 &self,
2765 reason: Option<String>,
2766 ) -> Result<DynamicWorkflowControlSnapshot> {
2767 validate_dynamic_workflow_control_reason(reason.as_deref())?;
2768 if self.context.is_cancelled() {
2769 anyhow::bail!("dynamic workflow control was cancelled before admission");
2770 }
2771 let preparation = self.prepare(false).await?;
2772 let claim = self.claim(&preparation).await?;
2773 let lease = match claim {
2774 DynamicWorkflowLeaseClaim::Owned(lease) => lease,
2775 DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2776 let engine = self.build_engine(&preparation, self.context.clone(), None);
2777 let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2778 if !snapshot.status.is_terminal() {
2779 anyhow::bail!(
2780 "dynamic workflow worker claim is completed but its durable run is not terminal"
2781 );
2782 }
2783 return self.summarize(&preparation, snapshot, history).await;
2784 }
2785 };
2786 let parent_cancellation = self.context.cancellation_token();
2787 let child_cancellation = parent_cancellation.child_token();
2788 let workflow_context = self
2789 .context
2790 .clone()
2791 .with_cancellation(child_cancellation.clone());
2792 let engine = self.build_engine(&preparation, workflow_context.clone(), Some(&lease));
2793 let future = async {
2794 engine.force_cancel(&self.run_id, reason).await?;
2795 engine.snapshot(&self.run_id).await
2796 };
2797 let execution = drive_dynamic_workflow_control_future(
2798 &engine,
2799 &self.run_id,
2800 DynamicWorkflowDriveContext {
2801 source: self.source.as_ref(),
2802 input_for_identity: &self.input,
2803 runtime_build_id: preparation.runtime_build_id.as_str(),
2804 workflow_context: &workflow_context,
2805 parent_cancellation,
2806 child_cancellation,
2807 },
2808 &lease,
2809 future,
2810 )
2811 .await?;
2812 self.summarize(&preparation, execution.snapshot, execution.history)
2813 .await
2814 }
2815
2816 pub async fn drive(&self) -> Result<DynamicWorkflowControlSnapshot> {
2819 if self.context.is_cancelled() {
2820 anyhow::bail!("dynamic workflow control was cancelled before admission");
2821 }
2822 let preparation = self.prepare(true).await?;
2823 let claim = self.claim(&preparation).await?;
2824 let lease = match claim {
2825 DynamicWorkflowLeaseClaim::Owned(lease) => lease,
2826 DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2827 let engine = self.build_engine(&preparation, self.context.clone(), None);
2828 let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2829 if !snapshot.status.is_terminal() {
2830 anyhow::bail!(
2831 "dynamic workflow worker claim is completed but its durable run is not terminal"
2832 );
2833 }
2834 return self.summarize(&preparation, snapshot, history).await;
2835 }
2836 };
2837 let parent_cancellation = self.context.cancellation_token();
2838 let child_cancellation = parent_cancellation.child_token();
2839 let workflow_context = self
2840 .context
2841 .clone()
2842 .with_cancellation(child_cancellation.clone());
2843 let engine = self.build_engine(&preparation, workflow_context.clone(), Some(&lease));
2844 let execution = drive_dynamic_workflow_with_lease(
2845 &engine,
2846 &self.run_id,
2847 preparation.spec.clone(),
2848 self.input.clone(),
2849 DynamicWorkflowDriveContext {
2850 source: self.source.as_ref(),
2851 input_for_identity: &self.input,
2852 runtime_build_id: preparation.runtime_build_id.as_str(),
2853 workflow_context: &workflow_context,
2854 parent_cancellation,
2855 child_cancellation,
2856 },
2857 &lease,
2858 )
2859 .await?;
2860 self.summarize(&preparation, execution.snapshot, execution.history)
2861 .await
2862 }
2863}
2864
2865struct DynamicWorkflowExecution {
2866 snapshot: WorkflowRunSnapshot,
2867 history: Vec<FlowEventEnvelope>,
2868 continuation_identity: ExecutionIdentityV1,
2869}
2870
2871struct DynamicWorkflowDriveContext<'a> {
2872 source: &'a str,
2873 input_for_identity: &'a Value,
2874 runtime_build_id: &'a str,
2875 workflow_context: &'a ToolContext,
2876 parent_cancellation: CancellationToken,
2877 child_cancellation: CancellationToken,
2878}
2879
2880async fn collect_dynamic_workflow_execution(
2881 engine: &FlowEngine,
2882 run_id: &str,
2883 snapshot: WorkflowRunSnapshot,
2884 source: &str,
2885 input: &Value,
2886 runtime_build_id: &str,
2887) -> Result<DynamicWorkflowExecution> {
2888 let history = engine.history(run_id).await?;
2889 let continuation_identity =
2890 dynamic_workflow_continuation_identity(run_id, source, input, runtime_build_id, &history)
2891 .map_err(|error| {
2892 anyhow::anyhow!("dynamic workflow continuation identity rejected after replay: {error}")
2893 })?;
2894 Ok(DynamicWorkflowExecution {
2895 snapshot,
2896 history,
2897 continuation_identity,
2898 })
2899}
2900
2901fn validate_dynamic_workflow_control_reason(reason: Option<&str>) -> Result<()> {
2902 let Some(reason) = reason else {
2903 return Ok(());
2904 };
2905 if reason.len() > MAX_DYNAMIC_WORKFLOW_CONTROL_REASON_BYTES {
2906 anyhow::bail!(
2907 "dynamic workflow cancellation reason exceeds {} bytes",
2908 MAX_DYNAMIC_WORKFLOW_CONTROL_REASON_BYTES
2909 );
2910 }
2911 if reason.contains('\0') {
2912 anyhow::bail!("dynamic workflow cancellation reason contains a NUL byte");
2913 }
2914 Ok(())
2915}
2916
2917async fn drive_dynamic_workflow_control_future<F>(
2918 engine: &FlowEngine,
2919 run_id: &str,
2920 drive_context: DynamicWorkflowDriveContext<'_>,
2921 lease: &DynamicWorkflowLease,
2922 future: F,
2923) -> Result<DynamicWorkflowExecution>
2924where
2925 F: Future<Output = a3s_flow::Result<WorkflowRunSnapshot>>,
2926{
2927 let DynamicWorkflowDriveContext {
2928 source,
2929 input_for_identity,
2930 runtime_build_id,
2931 workflow_context: _workflow_context,
2932 parent_cancellation,
2933 child_cancellation,
2934 } = drive_context;
2935 tokio::pin!(future);
2936
2937 async fn collect_result(
2938 engine: &FlowEngine,
2939 run_id: &str,
2940 result: a3s_flow::Result<WorkflowRunSnapshot>,
2941 source: &str,
2942 input: &Value,
2943 runtime_build_id: &str,
2944 ) -> Result<DynamicWorkflowExecution> {
2945 let snapshot = result?;
2946 collect_dynamic_workflow_execution(
2947 engine,
2948 run_id,
2949 snapshot,
2950 source,
2951 input,
2952 runtime_build_id,
2953 )
2954 .await
2955 }
2956
2957 let heartbeat_period = Duration::from_millis((lease.lease_ms / 3).max(1));
2958 let first_heartbeat = tokio::time::Instant::now() + heartbeat_period;
2959 let mut heartbeat = tokio::time::interval_at(first_heartbeat, heartbeat_period);
2960 loop {
2961 tokio::select! {
2962 biased;
2963 result = &mut future => {
2964 let result = collect_result(
2965 engine,
2966 run_id,
2967 result,
2968 source,
2969 input_for_identity,
2970 runtime_build_id,
2971 ).await;
2972 return settle_dynamic_workflow_lease(lease, result).await;
2973 }
2974 _ = parent_cancellation.cancelled() => {
2975 DynamicWorkflowMetrics::increment(&lease.metrics.cancellations);
2976 child_cancellation.cancel();
2977 match tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut future).await {
2978 Ok(result) => {
2979 let result = collect_result(
2980 engine,
2981 run_id,
2982 result,
2983 source,
2984 input_for_identity,
2985 runtime_build_id,
2986 ).await;
2987 match settle_dynamic_workflow_lease(lease, result).await {
2988 Ok(execution) if execution.snapshot.status.is_terminal() => {
2989 return Ok(execution)
2990 }
2991 Ok(_) => anyhow::bail!("dynamic workflow control cancelled by its parent"),
2992 Err(error) => return Err(error).context("settle dynamic workflow control cancellation"),
2993 }
2994 }
2995 Err(_) => anyhow::bail!(
2996 "dynamic workflow control cancellation did not settle within {} seconds; worker lease remains fenced until expiry",
2997 MAX_DYNAMIC_WORKFLOW_SETTLE.as_secs()
2998 ),
2999 }
3000 }
3001 _ = heartbeat.tick() => {
3002 match lease.renew().await {
3003 Ok(true) => {}
3004 Ok(false) => {
3005 child_cancellation.cancel();
3006 if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut future)
3007 .await
3008 .is_ok()
3009 {
3010 let _ = lease.release().await;
3011 }
3012 anyhow::bail!("dynamic workflow control worker lease was lost before completion");
3013 }
3014 Err(error) => {
3015 child_cancellation.cancel();
3016 if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut future)
3017 .await
3018 .is_ok()
3019 {
3020 let _ = lease.release().await;
3021 }
3022 return Err(error).context("renew dynamic workflow control worker lease");
3023 }
3024 }
3025 }
3026 }
3027 }
3028}
3029
3030async fn settle_dynamic_workflow_lease(
3031 lease: &DynamicWorkflowLease,
3032 result: Result<DynamicWorkflowExecution>,
3033) -> Result<DynamicWorkflowExecution> {
3034 match result {
3035 Ok(execution) if execution.snapshot.status.is_terminal() => {
3036 lease
3037 .complete()
3038 .await
3039 .context("complete dynamic workflow worker lease")?;
3040 Ok(execution)
3041 }
3042 Ok(execution) => {
3043 lease
3044 .release()
3045 .await
3046 .context("release suspended dynamic workflow worker lease")?;
3047 Ok(execution)
3048 }
3049 Err(error) => {
3050 if let Err(release_error) = lease.release().await {
3051 tracing::warn!(
3052 error = %release_error,
3053 "failed to release dynamic workflow worker lease after execution error"
3054 );
3055 }
3056 Err(error)
3057 }
3058 }
3059}
3060
3061async fn drive_dynamic_workflow_with_lease(
3062 engine: &FlowEngine,
3063 run_id: &str,
3064 spec: WorkflowSpec,
3065 input: Value,
3066 drive_context: DynamicWorkflowDriveContext<'_>,
3067 lease: &DynamicWorkflowLease,
3068) -> Result<DynamicWorkflowExecution> {
3069 let DynamicWorkflowDriveContext {
3070 source,
3071 input_for_identity,
3072 runtime_build_id,
3073 workflow_context,
3074 parent_cancellation,
3075 child_cancellation,
3076 } = drive_context;
3077 let execution = async {
3078 let started_run_id = engine.start_with_id(run_id, spec, input).await?;
3079 if started_run_id != run_id {
3080 anyhow::bail!(
3081 "dynamic workflow engine returned unexpected run id `{started_run_id}` for `{run_id}`"
3082 );
3083 }
3084 let snapshot = drive_inline_retries(engine, run_id, workflow_context).await?;
3085 collect_dynamic_workflow_execution(
3086 engine,
3087 run_id,
3088 snapshot,
3089 source,
3090 input_for_identity,
3091 runtime_build_id,
3092 )
3093 .await
3094 };
3095 tokio::pin!(execution);
3096
3097 let heartbeat_period = Duration::from_millis((lease.lease_ms / 3).max(1));
3098 let first_heartbeat = tokio::time::Instant::now() + heartbeat_period;
3099 let mut heartbeat = tokio::time::interval_at(first_heartbeat, heartbeat_period);
3100 loop {
3101 tokio::select! {
3102 biased;
3103 result = &mut execution => {
3104 return settle_dynamic_workflow_lease(lease, result).await;
3105 }
3106 _ = parent_cancellation.cancelled() => {
3107 DynamicWorkflowMetrics::increment(&lease.metrics.cancellations);
3108 child_cancellation.cancel();
3109 match tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut execution).await {
3110 Ok(result) => {
3111 match settle_dynamic_workflow_lease(lease, result).await {
3112 Ok(execution) if execution.snapshot.status.is_terminal() => {
3113 return Ok(execution)
3114 }
3115 Ok(_) => anyhow::bail!("dynamic workflow cancelled by its parent"),
3116 Err(error) => return Err(error).context("settle dynamic workflow cancellation"),
3117 }
3118 }
3119 Err(_) => anyhow::bail!(
3120 "dynamic workflow cancellation did not settle within {} seconds; worker lease remains fenced until expiry",
3121 MAX_DYNAMIC_WORKFLOW_SETTLE.as_secs()
3122 ),
3123 }
3124 }
3125 _ = heartbeat.tick() => {
3126 match lease.renew().await {
3127 Ok(true) => {}
3128 Ok(false) => {
3129 child_cancellation.cancel();
3130 if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut execution)
3131 .await
3132 .is_ok()
3133 {
3134 let _ = lease.release().await;
3135 }
3136 anyhow::bail!("dynamic workflow worker lease was lost before execution completed");
3137 }
3138 Err(error) => {
3139 child_cancellation.cancel();
3140 if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut execution)
3141 .await
3142 .is_ok()
3143 {
3144 let _ = lease.release().await;
3145 }
3146 return Err(error).context("renew dynamic workflow worker lease");
3147 }
3148 }
3149 }
3150 }
3151 }
3152}
3153
3154async fn drive_inline_retries(
3164 engine: &FlowEngine,
3165 run_id: &str,
3166 ctx: &ToolContext,
3167) -> Result<WorkflowRunSnapshot> {
3168 for _ in 0..MAX_INLINE_RETRY_RESUMES {
3169 let snapshot = engine.snapshot(run_id).await?;
3170 if snapshot.status.is_terminal() {
3171 return Ok(snapshot);
3172 }
3173 let Some(retry_after) = snapshot
3174 .steps
3175 .values()
3176 .filter(|step| step.status == StepStatus::Pending)
3177 .filter_map(|step| step.retry_after)
3178 .min()
3179 else {
3180 return Ok(snapshot);
3181 };
3182 let delay = retry_after
3183 .signed_duration_since(Utc::now())
3184 .to_std()
3185 .unwrap_or_default();
3186 if delay > MAX_INLINE_RETRY_DELAY {
3187 return Ok(snapshot);
3188 }
3189 let cancellation = ctx.cancellation_token();
3190 tokio::select! {
3191 biased;
3192 _ = cancellation.cancelled() => {
3193 anyhow::bail!("dynamic_workflow cancelled while waiting for a scheduled retry");
3194 }
3195 _ = tokio::time::sleep(delay) => {}
3196 }
3197 engine.drive(run_id).await?;
3198 }
3199 engine.snapshot(run_id).await.map_err(Into::into)
3200}
3201
3202pub fn register_dynamic_workflow(registry: &Arc<ToolRegistry>) {
3203 registry.register(Arc::new(DynamicWorkflowTool::new_registry_bound(
3204 Arc::clone(registry),
3205 )));
3206}
3207
3208pub fn register_dynamic_workflow_with_event_store(
3215 registry: &Arc<ToolRegistry>,
3216 store: Arc<dyn FlowEventStore>,
3217) {
3218 registry.register(Arc::new(
3219 DynamicWorkflowTool::new_registry_bound(Arc::clone(registry)).with_flow_event_store(store),
3220 ));
3221}
3222
3223pub fn register_dynamic_workflow_with_scheduler(
3230 registry: &Arc<ToolRegistry>,
3231 scheduler: Arc<TaskScheduler>,
3232 admit_steps_globally: bool,
3233) {
3234 registry.register(Arc::new(
3235 DynamicWorkflowTool::new_registry_bound(Arc::clone(registry))
3236 .with_task_scheduler(scheduler, admit_steps_globally),
3237 ));
3238}
3239
3240async fn flow_store_for_context(
3241 ctx: &ToolContext,
3242 requested_run_id: Option<&str>,
3243) -> Result<Arc<dyn FlowEventStore>> {
3244 match ctx.workspace_services.local_root() {
3245 Some(root) => {
3246 let store = dynamic_workflow_store_path(root);
3247 validate_dynamic_workflow_directory(&root.join(".a3s"), ".a3s").await?;
3248 validate_dynamic_workflow_directory(&store, ".a3s/workflow").await?;
3249 if let Some(run_id) = requested_run_id.filter(|run_id| safe_workflow_run_id(run_id)) {
3250 validate_dynamic_workflow_log(&store.join(format!("{run_id}.jsonl"))).await?;
3251 }
3252 Ok(Arc::new(CrossProcessFlowEventStore::new(store)))
3253 }
3254 None => Ok(Arc::new(InMemoryEventStore::new())),
3255 }
3256}
3257
3258async fn validate_dynamic_workflow_directory(path: &Path, label: &str) -> Result<()> {
3259 match tokio::fs::symlink_metadata(path).await {
3260 Ok(metadata) if metadata.file_type().is_symlink() => {
3261 anyhow::bail!("refusing to use symlinked dynamic workflow directory {label}")
3262 }
3263 Ok(metadata) if !metadata.is_dir() => {
3264 anyhow::bail!("dynamic workflow path {label} exists but is not a directory")
3265 }
3266 Ok(_) => Ok(()),
3267 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3268 Err(error) => Err(error).with_context(|| format!("inspect dynamic workflow path {label}")),
3269 }
3270}
3271
3272async fn validate_dynamic_workflow_log(path: &Path) -> Result<()> {
3273 match tokio::fs::symlink_metadata(path).await {
3274 Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
3275 "refusing to read or append symlinked dynamic workflow history {}",
3276 path.display()
3277 ),
3278 Ok(metadata) if !metadata.is_file() => anyhow::bail!(
3279 "dynamic workflow history path {} exists but is not a file",
3280 path.display()
3281 ),
3282 Ok(_) => Ok(()),
3283 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3284 Err(error) => Err(error)
3285 .with_context(|| format!("inspect dynamic workflow history {}", path.display())),
3286 }
3287}
3288
3289fn safe_workflow_run_id(run_id: &str) -> bool {
3290 !run_id.is_empty()
3291 && run_id
3292 .chars()
3293 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
3294}
3295
3296struct PayloadBuilder {
3297 value: Map<String, Value>,
3298}
3299
3300impl PayloadBuilder {
3301 fn with(mut self, key: &str, value: impl Serialize) -> Self {
3302 self.value.insert(
3303 key.to_string(),
3304 serde_json::to_value(value).unwrap_or(Value::Null),
3305 );
3306 self
3307 }
3308
3309 fn into_value(self) -> Value {
3310 Value::Object(self.value)
3311 }
3312}
3313
3314fn invocation_payload(kind: &str, run_id: &str, history: &[FlowEventEnvelope]) -> PayloadBuilder {
3315 let mut value = Map::new();
3316 value.insert("kind".to_string(), json!(kind));
3317 value.insert("run_id".to_string(), json!(run_id));
3318 value.insert("history".to_string(), json!(history));
3319 value.insert("step_outputs".to_string(), completed_step_outputs(history));
3320 value.insert("step_failures".to_string(), failed_step_outputs(history));
3321 PayloadBuilder { value }
3322}
3323
3324fn completed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
3325 let mut outputs = Map::new();
3326 for envelope in history {
3327 if let FlowEvent::StepCompleted { step_id, output } = &envelope.event {
3328 outputs.insert(step_id.clone(), output.clone());
3329 }
3330 }
3331 Value::Object(outputs)
3332}
3333
3334fn failed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
3335 let mut outputs = Map::new();
3336 for envelope in history {
3337 if let FlowEvent::StepFailed {
3338 step_id,
3339 attempt,
3340 error,
3341 } = &envelope.event
3342 {
3343 outputs.insert(
3344 step_id.clone(),
3345 json!({
3346 "attempt": attempt,
3347 "error": error,
3348 }),
3349 );
3350 }
3351 }
3352 Value::Object(outputs)
3353}
3354
3355fn script_result(result: &ToolResult) -> a3s_flow::Result<Value> {
3356 result
3357 .metadata
3358 .as_ref()
3359 .and_then(|metadata| metadata.get("script_result"))
3360 .cloned()
3361 .ok_or_else(|| {
3362 a3s_flow::FlowError::Runtime(
3363 "PTC program result did not include script_result metadata".to_string(),
3364 )
3365 })
3366}
3367
3368fn default_allowed_tools(registry: &ToolRegistry) -> Vec<String> {
3369 sanitize_allowed_tools(registry.list())
3370}
3371
3372fn sanitize_allowed_tools(items: impl IntoIterator<Item = String>) -> Vec<String> {
3373 let mut tools = items.into_iter().collect::<BTreeSet<_>>();
3374 tools.remove(PROGRAM_TOOL);
3375 tools.remove(DYNAMIC_WORKFLOW_TOOL);
3376 tools.remove(PARALLEL_TASK_TOOL);
3377 tools.into_iter().collect()
3378}
3379
3380fn source_hash(source: &str) -> String {
3381 sha256::digest(source.as_bytes())
3382}
3383
3384#[cfg(test)]
3385#[path = "dynamic_workflow/tests.rs"]
3386mod tests;