[WIP] Workflows

This commit is contained in:
2026-02-27 16:34:17 -06:00
parent 570c52e623
commit daeff10f18
96 changed files with 5889 additions and 2098 deletions

View File

@@ -2,6 +2,22 @@
//!
//! This module manages workflow execution context, including variables,
//! template rendering, and data flow between tasks.
//!
//! ## Function-call expressions
//!
//! Templates support Orquesta-style function calls:
//! - `{{ result() }}` — the last completed task's result
//! - `{{ result().field }}` — nested access into the result
//! - `{{ succeeded() }}` — `true` if the last task succeeded
//! - `{{ failed() }}` — `true` if the last task failed
//! - `{{ timed_out() }}` — `true` if the last task timed out
//!
//! ## Type-preserving rendering
//!
//! When a JSON string value is a *pure* template expression (the entire value
//! is `{{ expr }}`), `render_json` returns the raw `JsonValue` from the
//! expression instead of stringifying it. This means `"{{ item }}"` resolving
//! to integer `5` stays as `5`, not the string `"5"`.
use dashmap::DashMap;
use serde_json::{json, Value as JsonValue};
@@ -31,6 +47,15 @@ pub enum ContextError {
JsonError(#[from] serde_json::Error),
}
/// The status of the last completed task, used by `succeeded()` / `failed()` /
/// `timed_out()` function expressions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskOutcome {
Succeeded,
Failed,
TimedOut,
}
/// Workflow execution context
///
/// Uses Arc for shared immutable data to enable efficient cloning.
@@ -55,6 +80,12 @@ pub struct WorkflowContext {
/// Current item index (for with-items iteration) - per-item data
current_index: Option<usize>,
/// The result of the last completed task (for `result()` expressions)
last_task_result: Option<JsonValue>,
/// The outcome of the last completed task (for `succeeded()` / `failed()`)
last_task_outcome: Option<TaskOutcome>,
}
impl WorkflowContext {
@@ -75,6 +106,46 @@ impl WorkflowContext {
system: Arc::new(system),
current_item: None,
current_index: None,
last_task_result: None,
last_task_outcome: None,
}
}
/// Rebuild a workflow context from persisted workflow execution state.
///
/// This is used when advancing a workflow after a child task completes —
/// the scheduler reconstructs the context from the `workflow_execution`
/// record's stored `variables` plus the results of all completed child
/// executions.
pub fn rebuild(
parameters: JsonValue,
stored_variables: &JsonValue,
task_results: HashMap<String, JsonValue>,
) -> Self {
let variables = DashMap::new();
if let Some(obj) = stored_variables.as_object() {
for (k, v) in obj {
variables.insert(k.clone(), v.clone());
}
}
let results = DashMap::new();
for (k, v) in task_results {
results.insert(k, v);
}
let system = DashMap::new();
system.insert("workflow_start".to_string(), json!(chrono::Utc::now()));
Self {
variables: Arc::new(variables),
parameters: Arc::new(parameters),
task_results: Arc::new(results),
system: Arc::new(system),
current_item: None,
current_index: None,
last_task_result: None,
last_task_outcome: None,
}
}
@@ -112,7 +183,28 @@ impl WorkflowContext {
self.current_index = None;
}
/// Render a template string
/// Record the outcome of the last completed task so that `result()`,
/// `succeeded()`, `failed()`, and `timed_out()` expressions resolve
/// correctly.
pub fn set_last_task_outcome(&mut self, result: JsonValue, outcome: TaskOutcome) {
self.last_task_result = Some(result);
self.last_task_outcome = Some(outcome);
}
/// Export workflow variables as a JSON object suitable for persisting
/// back to the `workflow_execution.variables` column.
pub fn export_variables(&self) -> JsonValue {
let map: HashMap<String, JsonValue> = self
.variables
.iter()
.map(|entry| (entry.key().clone(), entry.value().clone()))
.collect();
json!(map)
}
/// Render a template string, always returning a `String`.
///
/// For type-preserving rendering of JSON values use [`render_json`].
pub fn render_template(&self, template: &str) -> ContextResult<String> {
// Simple template rendering (Jinja2-like syntax)
// Supports: {{ variable }}, {{ task.result }}, {{ parameters.key }}
@@ -143,10 +235,49 @@ impl WorkflowContext {
Ok(result)
}
/// Render a JSON value (recursively render templates in strings)
/// Try to evaluate a string as a single pure template expression.
///
/// Returns `Some(JsonValue)` when the **entire** string is exactly
/// `{{ expr }}` (with optional whitespace), preserving the original
/// JSON type of the evaluated expression. Returns `None` if the
/// string contains literal text around the template or multiple
/// template expressions — in that case the caller should fall back
/// to `render_template` which always stringifies.
fn try_evaluate_pure_expression(&self, s: &str) -> Option<ContextResult<JsonValue>> {
let trimmed = s.trim();
if !trimmed.starts_with("{{") || !trimmed.ends_with("}}") {
return None;
}
// Make sure there is only ONE template expression in the string.
// Count `{{` occurrences — if more than one, it's not a pure expr.
if trimmed.matches("{{").count() != 1 {
return None;
}
let expr = trimmed[2..trimmed.len() - 2].trim();
if expr.is_empty() {
return None;
}
Some(self.evaluate_expression(expr))
}
/// Render a JSON value, recursively resolving `{{ }}` templates in
/// strings.
///
/// **Type-preserving**: when a string value is a *pure* template
/// expression (the entire string is `{{ expr }}`), the raw `JsonValue`
/// from the expression is returned. For example, if `item` is `5`
/// (a JSON number), then `"{{ item }}"` resolves to `5` not `"5"`.
pub fn render_json(&self, value: &JsonValue) -> ContextResult<JsonValue> {
match value {
JsonValue::String(s) => {
// Fast path: try as a pure expression to preserve type
if let Some(result) = self.try_evaluate_pure_expression(s) {
return result;
}
// Fallback: render as string (interpolation with surrounding text)
let rendered = self.render_template(s)?;
Ok(JsonValue::String(rendered))
}
@@ -170,6 +301,28 @@ impl WorkflowContext {
/// Evaluate a template expression
fn evaluate_expression(&self, expr: &str) -> ContextResult<JsonValue> {
// ---------------------------------------------------------------
// Function-call expressions: result(), succeeded(), failed(), timed_out()
// ---------------------------------------------------------------
// We handle these *before* splitting on `.` because the function
// name contains parentheses which would confuse the dot-split.
//
// Supported patterns:
// result() → last task result
// result().foo.bar → nested access into result
// result().data.items → nested access into result
// succeeded() → boolean
// failed() → boolean
// timed_out() → boolean
// ---------------------------------------------------------------
if let Some(result_val) = self.try_evaluate_function_call(expr)? {
return Ok(result_val);
}
// ---------------------------------------------------------------
// Dot-path expressions
// ---------------------------------------------------------------
let parts: Vec<&str> = expr.split('.').collect();
if parts.is_empty() {
@@ -244,7 +397,8 @@ impl WorkflowContext {
Err(ContextError::VariableNotFound(format!("system.{}", key)))
}
}
// Direct variable reference
// Direct variable reference (e.g., `number_list` published by a
// previous task's transition)
var_name => {
if let Some(entry) = self.variables.get(var_name) {
let value = entry.value().clone();
@@ -261,6 +415,56 @@ impl WorkflowContext {
}
}
/// Try to evaluate `expr` as a function-call expression.
///
/// Returns `Ok(Some(value))` if the expression starts with a recognised
/// function call, `Ok(None)` if it does not match, or `Err` on failure.
fn try_evaluate_function_call(&self, expr: &str) -> ContextResult<Option<JsonValue>> {
// succeeded()
if expr == "succeeded()" {
let val = self
.last_task_outcome
.map(|o| o == TaskOutcome::Succeeded)
.unwrap_or(false);
return Ok(Some(json!(val)));
}
// failed()
if expr == "failed()" {
let val = self
.last_task_outcome
.map(|o| o == TaskOutcome::Failed)
.unwrap_or(false);
return Ok(Some(json!(val)));
}
// timed_out()
if expr == "timed_out()" {
let val = self
.last_task_outcome
.map(|o| o == TaskOutcome::TimedOut)
.unwrap_or(false);
return Ok(Some(json!(val)));
}
// result() or result().path.to.field
if expr == "result()" || expr.starts_with("result().") {
let base = self.last_task_result.clone().unwrap_or(JsonValue::Null);
if expr == "result()" {
return Ok(Some(base));
}
// Strip "result()." prefix and navigate the remaining path
let rest = &expr["result().".len()..];
let path_parts: Vec<&str> = rest.split('.').collect();
let val = self.get_nested_value(&base, &path_parts)?;
return Ok(Some(val));
}
Ok(None)
}
/// Get nested value from JSON
fn get_nested_value(&self, value: &JsonValue, path: &[&str]) -> ContextResult<JsonValue> {
let mut current = value;
@@ -313,7 +517,12 @@ impl WorkflowContext {
}
}
/// Publish variables from a task result
/// Publish variables from a task result.
///
/// Each publish directive is a `(name, expression)` pair where the
/// expression is a template string like `"{{ result().data.items }}"`.
/// The expression is rendered with `render_json`-style type preservation
/// so that non-string values (arrays, numbers, booleans) keep their type.
pub fn publish_from_result(
&mut self,
result: &JsonValue,
@@ -323,16 +532,11 @@ impl WorkflowContext {
// If publish map is provided, use it
if let Some(map) = publish_map {
for (var_name, template) in map {
// Create temporary context with result
let mut temp_ctx = self.clone();
temp_ctx.set_var("result", result.clone());
let value_str = temp_ctx.render_template(template)?;
// Try to parse as JSON, otherwise store as string
let value = serde_json::from_str(&value_str)
.unwrap_or_else(|_| JsonValue::String(value_str));
// Use type-preserving rendering: if the entire template is a
// single expression like `{{ result().data.items }}`, preserve
// the underlying JsonValue type (e.g. an array stays an array).
let json_value = JsonValue::String(template.clone());
let value = self.render_json(&json_value)?;
self.set_var(var_name, value);
}
} else {
@@ -405,6 +609,8 @@ impl WorkflowContext {
system: Arc::new(system),
current_item: None,
current_index: None,
last_task_result: None,
last_task_outcome: None,
})
}
}
@@ -513,6 +719,122 @@ mod tests {
assert_eq!(result["nested"]["value"], "Name is test");
}
#[test]
fn test_render_json_type_preserving_number() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_current_item(json!(5), 0);
// Pure expression — should preserve the integer type
let input = json!({"seconds": "{{ item }}"});
let result = ctx.render_json(&input).unwrap();
assert_eq!(result["seconds"], json!(5));
assert!(result["seconds"].is_number());
}
#[test]
fn test_render_json_type_preserving_array() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_last_task_outcome(
json!({"data": {"items": [0, 1, 2, 3, 4]}}),
TaskOutcome::Succeeded,
);
// Pure expression into result() — should preserve the array type
let input = json!({"list": "{{ result().data.items }}"});
let result = ctx.render_json(&input).unwrap();
assert_eq!(result["list"], json!([0, 1, 2, 3, 4]));
assert!(result["list"].is_array());
}
#[test]
fn test_render_json_mixed_template_stays_string() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_current_item(json!(5), 0);
// Mixed text + template — must remain a string
let input = json!({"msg": "Sleeping for {{ item }} seconds"});
let result = ctx.render_json(&input).unwrap();
assert_eq!(result["msg"], json!("Sleeping for 5 seconds"));
assert!(result["msg"].is_string());
}
#[test]
fn test_render_json_type_preserving_bool() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_last_task_outcome(json!({}), TaskOutcome::Succeeded);
let input = json!({"ok": "{{ succeeded() }}"});
let result = ctx.render_json(&input).unwrap();
assert_eq!(result["ok"], json!(true));
assert!(result["ok"].is_boolean());
}
#[test]
fn test_result_function() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_last_task_outcome(
json!({"data": {"items": [10, 20]}, "stdout": "hello"}),
TaskOutcome::Succeeded,
);
// result() returns the full last task result
let val = ctx.evaluate_expression("result()").unwrap();
assert_eq!(val["data"]["items"], json!([10, 20]));
// result().stdout returns nested field
let val = ctx.evaluate_expression("result().stdout").unwrap();
assert_eq!(val, json!("hello"));
// result().data.items returns deeper nested field
let val = ctx.evaluate_expression("result().data.items").unwrap();
assert_eq!(val, json!([10, 20]));
}
#[test]
fn test_succeeded_failed_functions() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_last_task_outcome(json!({}), TaskOutcome::Succeeded);
assert_eq!(ctx.evaluate_expression("succeeded()").unwrap(), json!(true));
assert_eq!(ctx.evaluate_expression("failed()").unwrap(), json!(false));
assert_eq!(
ctx.evaluate_expression("timed_out()").unwrap(),
json!(false)
);
ctx.set_last_task_outcome(json!({}), TaskOutcome::Failed);
assert_eq!(
ctx.evaluate_expression("succeeded()").unwrap(),
json!(false)
);
assert_eq!(ctx.evaluate_expression("failed()").unwrap(), json!(true));
ctx.set_last_task_outcome(json!({}), TaskOutcome::TimedOut);
assert_eq!(ctx.evaluate_expression("timed_out()").unwrap(), json!(true));
}
#[test]
fn test_publish_with_result_function() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_last_task_outcome(
json!({"data": {"items": [0, 1, 2]}}),
TaskOutcome::Succeeded,
);
let mut publish_map = HashMap::new();
publish_map.insert(
"number_list".to_string(),
"{{ result().data.items }}".to_string(),
);
ctx.publish_from_result(&json!({}), &[], Some(&publish_map))
.unwrap();
let val = ctx.get_var("number_list").unwrap();
assert_eq!(val, json!([0, 1, 2]));
assert!(val.is_array());
}
#[test]
fn test_publish_variables() {
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
@@ -524,6 +846,23 @@ mod tests {
assert_eq!(ctx.get_var("my_var").unwrap(), result);
}
#[test]
fn test_rebuild_context() {
let stored_vars = json!({"number_list": [0, 1, 2]});
let mut task_results = HashMap::new();
task_results.insert("task1".to_string(), json!({"data": {"items": [0, 1, 2]}}));
let ctx = WorkflowContext::rebuild(json!({"count": 5}), &stored_vars, task_results);
assert_eq!(ctx.get_var("number_list").unwrap(), json!([0, 1, 2]));
assert_eq!(
ctx.get_task_result("task1").unwrap(),
json!({"data": {"items": [0, 1, 2]}})
);
let rendered = ctx.render_template("{{ parameters.count }}").unwrap();
assert_eq!(rendered, "5");
}
#[test]
fn test_export_import() {
let mut ctx = WorkflowContext::new(json!({"key": "value"}), HashMap::new());
@@ -539,4 +878,28 @@ mod tests {
json!({"result": "ok"})
);
}
#[test]
fn test_with_items_integer_type_preservation() {
// Simulates the sleep_2 task from the hello_workflow:
// input: { seconds: "{{ item }}" }
// with_items: [0, 1, 2, 3, 4]
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
ctx.set_current_item(json!(3), 3);
let input = json!({
"message": "Sleeping for {{ item }} seconds ",
"seconds": "{{item}}"
});
let rendered = ctx.render_json(&input).unwrap();
// seconds should be integer 3, not string "3"
assert_eq!(rendered["seconds"], json!(3));
assert!(rendered["seconds"].is_number());
// message should be a string with the value interpolated
assert_eq!(rendered["message"], json!("Sleeping for 3 seconds "));
assert!(rendered["message"].is_string());
}
}