1use crate::llm::{ModelGenerationAdmission, ModelGenerationConcurrency};
8use crate::tools::{
9 registry_tool_invoker, Tool, ToolContext, ToolInvoker, ToolOutput, ToolRegistry, ToolResult,
10};
11use crate::{
12 agent::AgentEvent,
13 flow_graph::FlowGraphObserver,
14 planning::{Complexity, ExecutionPlan, Task, TaskStatus},
15};
16use a3s_flow::{
17 FanoutFlowEventObserver, FlowEngine, FlowEvent, FlowEventEnvelope, FlowEventObserver,
18 FlowEventStore, FlowRuntime, InMemoryEventStore, LocalFileEventStore, RuntimeCommand,
19 StepInvocation, StepStatus, WorkflowInvocation, WorkflowRunSnapshot, WorkflowRunStatus,
20 WorkflowSpec,
21};
22use anyhow::{Context, Result};
23use async_trait::async_trait;
24use chrono::Utc;
25use serde::{Deserialize, Serialize};
26use serde_json::{json, Map, Value};
27use std::collections::BTreeSet;
28use std::num::NonZeroUsize;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::Duration;
32use tokio::sync::{broadcast, Mutex};
33
34const DYNAMIC_WORKFLOW_TOOL: &str = "dynamic_workflow";
35const GENERATE_OBJECT_TOOL: &str = "generate_object";
36const PROGRAM_TOOL: &str = "program";
37const PARALLEL_TASK_TOOL: &str = "parallel_task";
38const MAX_INLINE_RETRY_RESUMES: usize = 8;
39const MAX_INLINE_RETRY_DELAY: Duration = Duration::from_secs(5);
40
41pub const DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH: &str = ".a3s/workflow";
43
44pub fn dynamic_workflow_store_path(workspace_root: impl AsRef<Path>) -> PathBuf {
46 workspace_root
47 .as_ref()
48 .join(DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH)
49}
50
51pub async fn recover_dynamic_workflow_step_output(
56 workspace_root: impl AsRef<Path>,
57 run_id: &str,
58 expected_query: &str,
59 step_id: &str,
60) -> Result<Option<Value>> {
61 if !safe_workflow_run_id(run_id) || expected_query.is_empty() || step_id.is_empty() {
62 return Ok(None);
63 }
64 let workspace_root = workspace_root.as_ref();
65 let store_root = dynamic_workflow_store_path(workspace_root);
66 let log_path = store_root.join(format!("{run_id}.jsonl"));
67 match tokio::fs::symlink_metadata(&log_path).await {
68 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
69 return Ok(None)
70 }
71 Ok(_) => {}
72 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
73 Err(error) => {
74 return Err(error).with_context(|| {
75 format!("inspect dynamic workflow history {}", log_path.display())
76 })
77 }
78 }
79 validate_dynamic_workflow_directory(&workspace_root.join(".a3s"), ".a3s").await?;
80 validate_dynamic_workflow_directory(&store_root, ".a3s/workflow").await?;
81 validate_dynamic_workflow_log(&log_path).await?;
82
83 let events = LocalFileEventStore::new(store_root).list(run_id).await?;
84 let input_matches = events.iter().any(|envelope| {
85 matches!(
86 &envelope.event,
87 FlowEvent::RunCreated { input, .. }
88 if input.get("query").and_then(Value::as_str) == Some(expected_query)
89 )
90 });
91 if !input_matches {
92 return Ok(None);
93 }
94 Ok(events
95 .iter()
96 .rev()
97 .find_map(|envelope| match &envelope.event {
98 FlowEvent::StepCompleted {
99 step_id: completed_step_id,
100 output,
101 } if completed_step_id == step_id => Some(output.clone()),
102 _ => None,
103 }))
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
108#[serde(rename_all = "camelCase")]
109pub struct DynamicWorkflowScriptLimits {
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub timeout_ms: Option<u64>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub max_tool_calls: Option<usize>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub max_output_bytes: Option<usize>,
116 #[serde(default, skip_serializing)]
119 pub max_concurrent_generations: Option<usize>,
120}
121
122#[derive(Clone)]
124pub struct DynamicWorkflowRuntime {
125 invoker: Arc<dyn ToolInvoker>,
126 context: ToolContext,
127 source: Arc<str>,
128 allowed_tools: Vec<String>,
129 limits: DynamicWorkflowScriptLimits,
130 parallel_generation_admission: Option<ModelGenerationAdmission>,
131}
132
133impl DynamicWorkflowRuntime {
134 pub fn new(
135 registry: Arc<ToolRegistry>,
136 context: ToolContext,
137 source: impl Into<String>,
138 ) -> Self {
139 let allowed_tools = default_allowed_tools(®istry);
140 let invoker = context
144 .tool_invoker()
145 .unwrap_or_else(|| registry_tool_invoker(registry));
146 Self {
147 invoker,
148 context,
149 source: Arc::from(source.into()),
150 allowed_tools,
151 limits: DynamicWorkflowScriptLimits::default(),
152 parallel_generation_admission: None,
153 }
154 }
155
156 pub fn with_allowed_tools(mut self, allowed_tools: impl IntoIterator<Item = String>) -> Self {
157 self.allowed_tools = sanitize_allowed_tools(allowed_tools);
158 self
159 }
160
161 pub fn with_limits(mut self, limits: DynamicWorkflowScriptLimits) -> Self {
162 let generation_concurrency = limits.max_concurrent_generations.unwrap_or(1).clamp(1, 4);
163 self.parallel_generation_admission = NonZeroUsize::new(generation_concurrency)
164 .filter(|maximum| maximum.get() > 1)
165 .map(|maximum| {
166 ModelGenerationAdmission::new(ModelGenerationConcurrency::bounded(maximum))
167 });
168 self.limits = limits;
169 self
170 }
171
172 async fn run_script(
173 &self,
174 payload: Value,
175 context: &ToolContext,
176 ) -> a3s_flow::Result<ToolResult> {
177 let mut args = json!({
178 "type": "script",
179 "language": "javascript",
180 "source": self.source.as_ref(),
181 "inputs": payload,
182 "allowed_tools": self.allowed_tools,
183 });
184 if let Some(object) = args.as_object_mut() {
185 if let Ok(Value::Object(limits)) = serde_json::to_value(&self.limits) {
186 if !limits.is_empty() {
187 object.insert("limits".to_string(), Value::Object(limits));
188 }
189 }
190 }
191
192 let result = self
193 .invoker
194 .invoke(context.nested_tool_invocation(PROGRAM_TOOL, args), context)
195 .await;
196 if result.exit_code != 0 {
197 return Err(a3s_flow::FlowError::Runtime(result.output));
198 }
199 Ok(result)
200 }
201
202 async fn context_for_step(
203 &self,
204 run_id: &str,
205 step_id: &str,
206 step_name: &str,
207 ) -> a3s_flow::Result<ToolContext> {
208 if step_name != GENERATE_OBJECT_TOOL {
209 return Ok(self.context.clone());
210 }
211 if let (Some(admission), Some(client)) = (
212 self.parallel_generation_admission.as_ref(),
213 self.context.llm_client(),
214 ) {
215 let fork_id = format!("{run_id}:{step_id}");
216 if let Some(forked_client) = client.fork_for_session(&fork_id) {
217 let permit = admission
218 .acquire(&self.context.cancellation_token())
219 .await
220 .map_err(|error| {
221 a3s_flow::FlowError::Runtime(format!(
222 "parallel model-generation admission failed before workflow step: {error}"
223 ))
224 })?;
225 return self
226 .context
227 .clone()
228 .with_llm_client(forked_client)
229 .with_model_generation_permit(admission.clone(), Arc::new(permit))
230 .map_err(|error| {
231 a3s_flow::FlowError::Runtime(format!(
232 "bind parallel model-generation admission to workflow step: {error}"
233 ))
234 });
235 }
236 }
237 let Some(admission) = self.context.model_generation_admission() else {
238 return Ok(self.context.clone());
239 };
240 let permit = admission
241 .acquire(&self.context.cancellation_token())
242 .await
243 .map_err(|error| {
244 a3s_flow::FlowError::Runtime(format!(
245 "model-generation admission failed before workflow step: {error}"
246 ))
247 })?;
248 self.context
249 .clone()
250 .with_model_generation_permit(admission, Arc::new(permit))
251 .map_err(|error| {
252 a3s_flow::FlowError::Runtime(format!(
253 "bind model-generation admission to workflow step: {error}"
254 ))
255 })
256 }
257
258 async fn run_tool_step(&self, tool_name: &str, args: Value) -> a3s_flow::Result<Value> {
259 let result = self
260 .invoker
261 .invoke(
262 self.context
263 .nested_tool_invocation(tool_name.to_string(), args),
264 &self.context,
265 )
266 .await;
267 if result.exit_code != 0 {
268 return Err(a3s_flow::FlowError::Runtime(result.output));
269 }
270 Ok(json!({
271 "tool": result.name,
272 "output": result.output,
273 "exit_code": result.exit_code,
274 "metadata": result.metadata,
275 }))
276 }
277}
278
279#[async_trait]
280impl FlowRuntime for DynamicWorkflowRuntime {
281 async fn run_workflow(
282 &self,
283 invocation: WorkflowInvocation,
284 ) -> a3s_flow::Result<RuntimeCommand> {
285 let payload = invocation_payload("workflow", &invocation.run_id, &invocation.history)
286 .with("input", invocation.input);
287 let result = self.run_script(payload.into_value(), &self.context).await?;
288 serde_json::from_value(script_result(&result)?).map_err(a3s_flow::FlowError::from)
289 }
290
291 async fn run_step(&self, invocation: StepInvocation) -> a3s_flow::Result<Value> {
292 if invocation.step_name == PARALLEL_TASK_TOOL {
293 return self
294 .run_tool_step(PARALLEL_TASK_TOOL, invocation.input)
295 .await;
296 }
297
298 let context = self
299 .context_for_step(
300 &invocation.run_id,
301 &invocation.step_id,
302 &invocation.step_name,
303 )
304 .await?;
305 let payload = invocation_payload("step", &invocation.run_id, &invocation.history)
306 .with("step_id", invocation.step_id)
307 .with("step_name", invocation.step_name)
308 .with("input", invocation.input);
309 let result = self.run_script(payload.into_value(), &context).await?;
310 script_result(&result)
311 }
312}
313
314struct WorkflowProgressState {
315 tasks: Vec<Task>,
316}
317
318impl WorkflowProgressState {
319 fn new() -> Self {
320 Self { tasks: Vec::new() }
321 }
322
323 fn upsert_step(
324 &mut self,
325 step_id: &str,
326 step_name: &str,
327 input: Option<&Value>,
328 status: TaskStatus,
329 ) {
330 let content = workflow_step_description(step_id, step_name, input);
331 if let Some(task) = self.tasks.iter_mut().find(|task| task.id == step_id) {
332 task.content = content;
333 task.status = status;
334 task.tool = Some(step_name.to_string());
335 } else {
336 self.tasks
337 .push(Task::new(step_id.to_string(), content).with_tool(step_name));
338 if let Some(task) = self.tasks.last_mut() {
339 task.status = status;
340 }
341 }
342 }
343
344 fn mark_status(&mut self, step_id: &str, status: TaskStatus) {
345 if let Some(task) = self.tasks.iter_mut().find(|task| task.id == step_id) {
346 task.status = status;
347 }
348 }
349
350 fn step_position(&self, step_id: &str) -> (usize, usize) {
351 let total = self.tasks.len().max(1);
352 let number = self
353 .tasks
354 .iter()
355 .position(|task| task.id == step_id)
356 .map(|idx| idx + 1)
357 .unwrap_or(total);
358 (number, total)
359 }
360
361 fn step_description(&self, step_id: &str) -> String {
362 self.tasks
363 .iter()
364 .find(|task| task.id == step_id)
365 .map(|task| task.content.clone())
366 .unwrap_or_else(|| step_id.to_string())
367 }
368}
369
370struct AgentEventFlowObserver {
371 tx: broadcast::Sender<AgentEvent>,
372 session_id: String,
373 state: Mutex<WorkflowProgressState>,
374}
375
376impl AgentEventFlowObserver {
377 fn new(tx: broadcast::Sender<AgentEvent>, session_id: String) -> Self {
378 Self {
379 tx,
380 session_id,
381 state: Mutex::new(WorkflowProgressState::new()),
382 }
383 }
384
385 fn emit_task_update(&self, tasks: &[Task]) {
386 let _ = self.tx.send(AgentEvent::TaskUpdated {
387 session_id: self.session_id.clone(),
388 tasks: tasks.to_vec(),
389 });
390 }
391}
392
393#[async_trait]
394impl FlowEventObserver for AgentEventFlowObserver {
395 async fn observe(&self, envelope: FlowEventEnvelope) {
396 match envelope.event {
397 FlowEvent::RunStarted => {
398 let _ = self.tx.send(AgentEvent::PlanningStart {
399 prompt: "dynamic_workflow".to_string(),
400 });
401 }
402 FlowEvent::StepCreated {
403 step_id,
404 step_name,
405 input,
406 ..
407 } => {
408 let mut state = self.state.lock().await;
409 state.upsert_step(&step_id, &step_name, Some(&input), TaskStatus::Pending);
410 self.emit_task_update(&state.tasks);
411 let mut plan = ExecutionPlan::new("dynamic workflow", Complexity::Medium);
412 for task in state.tasks.iter().cloned() {
413 plan.add_step(task);
414 }
415 let _ = self.tx.send(AgentEvent::PlanningEnd {
416 estimated_steps: plan.steps.len(),
417 plan,
418 });
419 }
420 FlowEvent::StepStarted { step_id, .. } => {
421 let mut state = self.state.lock().await;
422 state.mark_status(&step_id, TaskStatus::InProgress);
423 self.emit_task_update(&state.tasks);
424 let (step_number, total_steps) = state.step_position(&step_id);
425 let _ = self.tx.send(AgentEvent::StepStart {
426 description: state.step_description(&step_id),
427 step_id,
428 step_number,
429 total_steps,
430 });
431 }
432 FlowEvent::StepCompleted { step_id, .. } => {
433 let mut state = self.state.lock().await;
434 state.mark_status(&step_id, TaskStatus::Completed);
435 self.emit_task_update(&state.tasks);
436 let (step_number, total_steps) = state.step_position(&step_id);
437 let _ = self.tx.send(AgentEvent::StepEnd {
438 step_id,
439 status: TaskStatus::Completed,
440 step_number,
441 total_steps,
442 });
443 }
444 FlowEvent::StepRetrying { step_id, .. } => {
445 let mut state = self.state.lock().await;
446 state.mark_status(&step_id, TaskStatus::InProgress);
447 self.emit_task_update(&state.tasks);
448 }
449 FlowEvent::StepFailed { step_id, .. } => {
450 let mut state = self.state.lock().await;
451 state.mark_status(&step_id, TaskStatus::Failed);
452 self.emit_task_update(&state.tasks);
453 let (step_number, total_steps) = state.step_position(&step_id);
454 let _ = self.tx.send(AgentEvent::StepEnd {
455 step_id,
456 status: TaskStatus::Failed,
457 step_number,
458 total_steps,
459 });
460 }
461 FlowEvent::RunFailed { .. } => {
462 let mut state = self.state.lock().await;
463 for task in &mut state.tasks {
464 if task.status.is_active() {
465 task.status = TaskStatus::Failed;
466 }
467 }
468 self.emit_task_update(&state.tasks);
469 }
470 FlowEvent::RunCancelled { .. } => {
471 let mut state = self.state.lock().await;
472 for task in &mut state.tasks {
473 if task.status.is_active() {
474 task.status = TaskStatus::Cancelled;
475 }
476 }
477 self.emit_task_update(&state.tasks);
478 }
479 _ => {}
480 }
481 }
482}
483
484fn workflow_step_description(step_id: &str, step_name: &str, input: Option<&Value>) -> String {
485 if step_name == PARALLEL_TASK_TOOL {
486 let count = input
487 .and_then(|value| value.get("tasks"))
488 .and_then(Value::as_array)
489 .map(Vec::len)
490 .unwrap_or(0);
491 if count > 0 {
492 return format!("Fan out {count} parallel subagent task(s)");
493 }
494 }
495
496 input
497 .and_then(|value| value.get("description").or_else(|| value.get("title")))
498 .and_then(Value::as_str)
499 .map(ToString::to_string)
500 .unwrap_or_else(|| {
501 if step_name == step_id {
502 step_id.to_string()
503 } else {
504 format!("{step_name}: {step_id}")
505 }
506 })
507}
508
509pub struct DynamicWorkflowTool {
511 registry: Arc<ToolRegistry>,
512 graph_observer: Option<FlowGraphObserver>,
513}
514
515impl DynamicWorkflowTool {
516 pub fn new(registry: Arc<ToolRegistry>) -> Self {
517 Self {
518 registry,
519 graph_observer: None,
520 }
521 }
522
523 pub fn with_graph_observer(mut self, observer: FlowGraphObserver) -> Self {
526 self.graph_observer = Some(observer);
527 self
528 }
529}
530
531#[async_trait]
532impl Tool for DynamicWorkflowTool {
533 fn name(&self) -> &str {
534 DYNAMIC_WORKFLOW_TOOL
535 }
536
537 fn description(&self) -> &str {
538 "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."
539 }
540
541 fn parameters(&self) -> Value {
542 json!({
543 "type": "object",
544 "additionalProperties": false,
545 "properties": {
546 "source": {
547 "type": "string",
548 "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='parallel_task' bypasses QuickJS and calls the host parallel_task tool directly with input as its arguments."
549 },
550 "input": {
551 "type": "object",
552 "description": "Initial workflow input."
553 },
554 "run_id": {
555 "type": "string",
556 "description": "Optional durable run id. Reusing it with the same source and input is idempotent."
557 },
558 "allowed_tools": {
559 "type": "array",
560 "description": "Tool names the workflow script may call through ctx. Defaults to all registered tools except program, dynamic_workflow, and parallel_task. Login-registered tools such as runtime are allowed when present.",
561 "items": { "type": "string" }
562 },
563 "limits": {
564 "type": "object",
565 "additionalProperties": false,
566 "properties": {
567 "timeoutMs": { "type": "integer", "minimum": 1 },
568 "maxToolCalls": { "type": "integer", "minimum": 1 },
569 "maxOutputBytes": { "type": "integer", "minimum": 1 },
570 "maxConcurrentGenerations": {
571 "type": "integer",
572 "minimum": 1,
573 "maximum": 4,
574 "description": "Optional bounded fan-out for independently session-bound generate_object steps. Providers without session forking remain single-flight."
575 }
576 }
577 }
578 },
579 "required": ["source"]
580 })
581 }
582
583 async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
584 let Some(source) = args.get("source").and_then(Value::as_str) else {
585 return Ok(ToolOutput::error("dynamic_workflow requires source"));
586 };
587 let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
588 let allowed_tools = args
589 .get("allowed_tools")
590 .and_then(Value::as_array)
591 .map(|items| {
592 items
593 .iter()
594 .filter_map(Value::as_str)
595 .map(ToString::to_string)
596 .collect::<Vec<_>>()
597 })
598 .unwrap_or_else(|| default_allowed_tools(&self.registry));
599 let limits = args
600 .get("limits")
601 .cloned()
602 .and_then(|value| serde_json::from_value(value).ok())
603 .unwrap_or_default();
604
605 let runtime = Arc::new(
606 DynamicWorkflowRuntime::new(Arc::clone(&self.registry), ctx.clone(), source)
607 .with_allowed_tools(allowed_tools)
608 .with_limits(limits),
609 );
610 let requested_run_id = args.get("run_id").and_then(Value::as_str);
611 let store = match flow_store_for_context(ctx, requested_run_id).await {
612 Ok(store) => store,
613 Err(error) => return Ok(ToolOutput::error(error.to_string())),
614 };
615 let mut observers: Vec<Arc<dyn FlowEventObserver>> = Vec::new();
616 if let Some(tx) = ctx.agent_event_tx.clone() {
617 observers.push(Arc::new(AgentEventFlowObserver::new(
618 tx,
619 ctx.session_id.clone().unwrap_or_default(),
620 )));
621 }
622 if let Some(observer) = &self.graph_observer {
623 observers.push(Arc::new(observer.clone()));
624 }
625 let engine = if observers.is_empty() {
626 FlowEngine::new(store, runtime)
627 } else {
628 FlowEngine::builder(runtime)
629 .with_store(store)
630 .with_observer(Arc::new(FanoutFlowEventObserver::from_observers(observers)))
631 .build()
632 };
633 let source_hash = source_hash(source);
634 let spec = WorkflowSpec::rust_embedded(
635 "a3s-code.dynamic-workflow",
636 source_hash.as_str(),
637 "ptc",
638 "run",
639 );
640
641 let run_id = match requested_run_id {
642 Some(run_id) => match engine.start_with_id(run_id, spec, input).await {
643 Ok(run_id) => run_id,
644 Err(err) => return Ok(ToolOutput::error(err.to_string())),
645 },
646 None => match engine.start(spec, input).await {
647 Ok(run_id) => run_id,
648 Err(err) => return Ok(ToolOutput::error(err.to_string())),
649 },
650 };
651
652 let snapshot = match drive_inline_retries(&engine, &run_id, ctx).await {
653 Ok(snapshot) => snapshot,
654 Err(err) => return Ok(ToolOutput::error(err.to_string())),
655 };
656 let history = match engine.history(&run_id).await {
657 Ok(history) => history,
658 Err(err) => return Ok(ToolOutput::error(err.to_string())),
659 };
660
661 let output = match &snapshot.output {
662 Some(output) => {
663 serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string())
664 }
665 None => snapshot
666 .error
667 .clone()
668 .unwrap_or_else(|| format!("workflow status: {:?}", snapshot.status)),
669 };
670
671 let status = snapshot.status;
672 let metadata = json!({
673 "dynamic_workflow": {
674 "run_id": run_id,
675 "status": format!("{:?}", snapshot.status),
676 "last_sequence": snapshot.last_sequence,
677 "source_hash": source_hash,
678 "snapshot": snapshot,
679 "history": history,
680 }
681 });
682 let output = match status {
683 WorkflowRunStatus::Completed => ToolOutput::success(output),
684 WorkflowRunStatus::Failed | WorkflowRunStatus::Cancelled => ToolOutput::error(output),
685 _ => ToolOutput::error(format!(
686 "dynamic_workflow ended without a terminal result: {status:?}; {output}"
687 )),
688 };
689
690 Ok(output.with_metadata(metadata))
691 }
692}
693
694async fn drive_inline_retries(
704 engine: &FlowEngine,
705 run_id: &str,
706 ctx: &ToolContext,
707) -> Result<WorkflowRunSnapshot> {
708 for _ in 0..MAX_INLINE_RETRY_RESUMES {
709 let snapshot = engine.snapshot(run_id).await?;
710 if snapshot.status.is_terminal() {
711 return Ok(snapshot);
712 }
713 let Some(retry_after) = snapshot
714 .steps
715 .values()
716 .filter(|step| step.status == StepStatus::Pending)
717 .filter_map(|step| step.retry_after)
718 .min()
719 else {
720 return Ok(snapshot);
721 };
722 let delay = retry_after
723 .signed_duration_since(Utc::now())
724 .to_std()
725 .unwrap_or_default();
726 if delay > MAX_INLINE_RETRY_DELAY {
727 return Ok(snapshot);
728 }
729 let cancellation = ctx.cancellation_token();
730 tokio::select! {
731 biased;
732 _ = cancellation.cancelled() => {
733 anyhow::bail!("dynamic_workflow cancelled while waiting for a scheduled retry");
734 }
735 _ = tokio::time::sleep(delay) => {}
736 }
737 engine.drive(run_id).await?;
738 }
739 engine.snapshot(run_id).await.map_err(Into::into)
740}
741
742pub fn register_dynamic_workflow(registry: &Arc<ToolRegistry>) {
743 registry.register(Arc::new(DynamicWorkflowTool::new(Arc::clone(registry))));
744}
745
746async fn flow_store_for_context(
747 ctx: &ToolContext,
748 requested_run_id: Option<&str>,
749) -> Result<Arc<dyn FlowEventStore>> {
750 match ctx.workspace_services.local_root() {
751 Some(root) => {
752 let store = dynamic_workflow_store_path(root);
753 validate_dynamic_workflow_directory(&root.join(".a3s"), ".a3s").await?;
754 validate_dynamic_workflow_directory(&store, ".a3s/workflow").await?;
755 if let Some(run_id) = requested_run_id.filter(|run_id| safe_workflow_run_id(run_id)) {
756 validate_dynamic_workflow_log(&store.join(format!("{run_id}.jsonl"))).await?;
757 }
758 Ok(Arc::new(LocalFileEventStore::new(store)))
759 }
760 None => Ok(Arc::new(InMemoryEventStore::new())),
761 }
762}
763
764async fn validate_dynamic_workflow_directory(path: &Path, label: &str) -> Result<()> {
765 match tokio::fs::symlink_metadata(path).await {
766 Ok(metadata) if metadata.file_type().is_symlink() => {
767 anyhow::bail!("refusing to use symlinked dynamic workflow directory {label}")
768 }
769 Ok(metadata) if !metadata.is_dir() => {
770 anyhow::bail!("dynamic workflow path {label} exists but is not a directory")
771 }
772 Ok(_) => Ok(()),
773 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
774 Err(error) => Err(error).with_context(|| format!("inspect dynamic workflow path {label}")),
775 }
776}
777
778async fn validate_dynamic_workflow_log(path: &Path) -> Result<()> {
779 match tokio::fs::symlink_metadata(path).await {
780 Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
781 "refusing to read or append symlinked dynamic workflow history {}",
782 path.display()
783 ),
784 Ok(metadata) if !metadata.is_file() => anyhow::bail!(
785 "dynamic workflow history path {} exists but is not a file",
786 path.display()
787 ),
788 Ok(_) => Ok(()),
789 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
790 Err(error) => Err(error)
791 .with_context(|| format!("inspect dynamic workflow history {}", path.display())),
792 }
793}
794
795fn safe_workflow_run_id(run_id: &str) -> bool {
796 !run_id.is_empty()
797 && run_id
798 .chars()
799 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
800}
801
802struct PayloadBuilder {
803 value: Map<String, Value>,
804}
805
806impl PayloadBuilder {
807 fn with(mut self, key: &str, value: impl Serialize) -> Self {
808 self.value.insert(
809 key.to_string(),
810 serde_json::to_value(value).unwrap_or(Value::Null),
811 );
812 self
813 }
814
815 fn into_value(self) -> Value {
816 Value::Object(self.value)
817 }
818}
819
820fn invocation_payload(kind: &str, run_id: &str, history: &[FlowEventEnvelope]) -> PayloadBuilder {
821 let mut value = Map::new();
822 value.insert("kind".to_string(), json!(kind));
823 value.insert("run_id".to_string(), json!(run_id));
824 value.insert("history".to_string(), json!(history));
825 value.insert("step_outputs".to_string(), completed_step_outputs(history));
826 value.insert("step_failures".to_string(), failed_step_outputs(history));
827 PayloadBuilder { value }
828}
829
830fn completed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
831 let mut outputs = Map::new();
832 for envelope in history {
833 if let FlowEvent::StepCompleted { step_id, output } = &envelope.event {
834 outputs.insert(step_id.clone(), output.clone());
835 }
836 }
837 Value::Object(outputs)
838}
839
840fn failed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
841 let mut outputs = Map::new();
842 for envelope in history {
843 if let FlowEvent::StepFailed {
844 step_id,
845 attempt,
846 error,
847 } = &envelope.event
848 {
849 outputs.insert(
850 step_id.clone(),
851 json!({
852 "attempt": attempt,
853 "error": error,
854 }),
855 );
856 }
857 }
858 Value::Object(outputs)
859}
860
861fn script_result(result: &ToolResult) -> a3s_flow::Result<Value> {
862 result
863 .metadata
864 .as_ref()
865 .and_then(|metadata| metadata.get("script_result"))
866 .cloned()
867 .ok_or_else(|| {
868 a3s_flow::FlowError::Runtime(
869 "PTC program result did not include script_result metadata".to_string(),
870 )
871 })
872}
873
874fn default_allowed_tools(registry: &ToolRegistry) -> Vec<String> {
875 sanitize_allowed_tools(registry.list())
876}
877
878fn sanitize_allowed_tools(items: impl IntoIterator<Item = String>) -> Vec<String> {
879 let mut tools = items.into_iter().collect::<BTreeSet<_>>();
880 tools.remove(PROGRAM_TOOL);
881 tools.remove(DYNAMIC_WORKFLOW_TOOL);
882 tools.remove(PARALLEL_TASK_TOOL);
883 tools.into_iter().collect()
884}
885
886fn source_hash(source: &str) -> String {
887 sha256::digest(source.as_bytes())
888}
889
890#[cfg(test)]
891#[path = "dynamic_workflow/tests.rs"]
892mod tests;