working on workflows
This commit is contained in:
@@ -42,27 +42,12 @@ use crate::workflow::graph::TaskGraph;
|
||||
|
||||
/// Extract workflow parameters from an execution's `config` field.
|
||||
///
|
||||
/// The config may be stored in two formats:
|
||||
/// 1. Wrapped: `{"parameters": {"n": 5, ...}}` — used by child task executions
|
||||
/// 2. Flat: `{"n": 5, ...}` — used by the API for manual executions
|
||||
///
|
||||
/// This helper checks for a `"parameters"` key first, and if absent treats
|
||||
/// the entire config object as the parameters (matching the worker's logic
|
||||
/// in `ActionExecutor::prepare_execution_context`).
|
||||
/// All executions store config in flat format: `{"n": 5, ...}`.
|
||||
/// The config object itself IS the parameters map.
|
||||
fn extract_workflow_params(config: &Option<JsonValue>) -> JsonValue {
|
||||
match config {
|
||||
Some(c) => {
|
||||
// Prefer the wrapped format if present
|
||||
if let Some(params) = c.get("parameters") {
|
||||
params.clone()
|
||||
} else if c.is_object() {
|
||||
// Flat format — the config itself is the parameters
|
||||
c.clone()
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
}
|
||||
}
|
||||
None => serde_json::json!({}),
|
||||
Some(c) if c.is_object() => c.clone(),
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +85,7 @@ fn apply_param_defaults(params: JsonValue, param_schema: &Option<JsonValue>) ->
|
||||
};
|
||||
if needs_default {
|
||||
if let Some(default_val) = prop.get("default") {
|
||||
debug!(
|
||||
"Applying default for parameter '{}': {}",
|
||||
key, default_val
|
||||
);
|
||||
debug!("Applying default for parameter '{}': {}", key, default_val);
|
||||
obj.insert(key.clone(), default_val.clone());
|
||||
}
|
||||
}
|
||||
@@ -234,8 +216,25 @@ impl ExecutionScheduler {
|
||||
worker.id, execution_id
|
||||
);
|
||||
|
||||
// Apply parameter defaults from the action's param_schema.
|
||||
// This mirrors what `process_workflow_execution` does for workflows
|
||||
// so that non-workflow executions also get missing parameters filled
|
||||
// in from the action's declared defaults.
|
||||
let execution_config = {
|
||||
let raw_config = execution.config.clone();
|
||||
let params = extract_workflow_params(&raw_config);
|
||||
let params_with_defaults = apply_param_defaults(params, &action.param_schema);
|
||||
// Config is already flat — just use the defaults-applied version
|
||||
if params_with_defaults.is_object()
|
||||
&& !params_with_defaults.as_object().unwrap().is_empty()
|
||||
{
|
||||
Some(params_with_defaults)
|
||||
} else {
|
||||
raw_config
|
||||
}
|
||||
};
|
||||
|
||||
// Update execution status to scheduled
|
||||
let execution_config = execution.config.clone();
|
||||
let mut execution_for_update = execution;
|
||||
execution_for_update.status = ExecutionStatus::Scheduled;
|
||||
ExecutionRepository::update(pool, execution_for_update.id, execution_for_update.into())
|
||||
@@ -391,6 +390,7 @@ impl ExecutionScheduler {
|
||||
&workflow_execution.id,
|
||||
task_node,
|
||||
&wf_ctx,
|
||||
None, // entry-point task — no predecessor
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
@@ -407,6 +407,10 @@ impl ExecutionScheduler {
|
||||
/// Create a child execution for a single workflow task and dispatch it to
|
||||
/// a worker. The child execution references the parent workflow execution
|
||||
/// via `workflow_task` metadata.
|
||||
///
|
||||
/// `triggered_by` is the name of the predecessor task whose completion
|
||||
/// caused this task to be scheduled. Pass `None` for entry-point tasks
|
||||
/// dispatched at workflow start.
|
||||
async fn dispatch_workflow_task(
|
||||
pool: &PgPool,
|
||||
publisher: &Publisher,
|
||||
@@ -415,6 +419,7 @@ impl ExecutionScheduler {
|
||||
workflow_execution_id: &i64,
|
||||
task_node: &crate::workflow::graph::TaskNode,
|
||||
wf_ctx: &WorkflowContext,
|
||||
triggered_by: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let action_ref: String = match &task_node.action {
|
||||
Some(a) => a.clone(),
|
||||
@@ -461,6 +466,7 @@ impl ExecutionScheduler {
|
||||
&action_ref,
|
||||
with_items_expr,
|
||||
wf_ctx,
|
||||
triggered_by,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -484,12 +490,12 @@ impl ExecutionScheduler {
|
||||
task_node.input.clone()
|
||||
};
|
||||
|
||||
// Build task config from the (rendered) input
|
||||
// Build task config from the (rendered) input.
|
||||
// Store as flat parameters (consistent with manual and rule-triggered
|
||||
// executions) — no {"parameters": ...} wrapper.
|
||||
let task_config: Option<JsonValue> =
|
||||
if rendered_input.is_object() && !rendered_input.as_object().unwrap().is_empty() {
|
||||
Some(serde_json::json!({
|
||||
"parameters": rendered_input
|
||||
}))
|
||||
Some(rendered_input.clone())
|
||||
} else if let Some(parent_config) = &parent_execution.config {
|
||||
Some(parent_config.clone())
|
||||
} else {
|
||||
@@ -500,6 +506,7 @@ impl ExecutionScheduler {
|
||||
let workflow_task = WorkflowTaskMetadata {
|
||||
workflow_execution: *workflow_execution_id,
|
||||
task_name: task_node.name.clone(),
|
||||
triggered_by: triggered_by.map(String::from),
|
||||
task_index: None,
|
||||
task_batch: None,
|
||||
retry_count: 0,
|
||||
@@ -587,6 +594,7 @@ impl ExecutionScheduler {
|
||||
action_ref: &str,
|
||||
with_items_expr: &str,
|
||||
wf_ctx: &WorkflowContext,
|
||||
triggered_by: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Resolve the with_items expression to a JSON array
|
||||
let items_value = wf_ctx
|
||||
@@ -647,9 +655,11 @@ impl ExecutionScheduler {
|
||||
task_node.input.clone()
|
||||
};
|
||||
|
||||
// Store as flat parameters (consistent with manual and rule-triggered
|
||||
// executions) — no {"parameters": ...} wrapper.
|
||||
let task_config: Option<JsonValue> =
|
||||
if rendered_input.is_object() && !rendered_input.as_object().unwrap().is_empty() {
|
||||
Some(serde_json::json!({ "parameters": rendered_input }))
|
||||
Some(rendered_input.clone())
|
||||
} else if let Some(parent_config) = &parent_execution.config {
|
||||
Some(parent_config.clone())
|
||||
} else {
|
||||
@@ -659,6 +669,7 @@ impl ExecutionScheduler {
|
||||
let workflow_task = WorkflowTaskMetadata {
|
||||
workflow_execution: *workflow_execution_id,
|
||||
task_name: task_node.name.clone(),
|
||||
triggered_by: triggered_by.map(String::from),
|
||||
task_index: Some(index as i32),
|
||||
task_batch: None,
|
||||
retry_count: 0,
|
||||
@@ -961,8 +972,7 @@ impl ExecutionScheduler {
|
||||
.and_then(|n| n.concurrency)
|
||||
.unwrap_or(1);
|
||||
|
||||
let free_slots =
|
||||
concurrency_limit.saturating_sub(in_flight_count.0 as usize);
|
||||
let free_slots = concurrency_limit.saturating_sub(in_flight_count.0 as usize);
|
||||
|
||||
if free_slots > 0 {
|
||||
if let Err(e) = Self::publish_pending_with_items_children(
|
||||
@@ -1009,6 +1019,39 @@ impl ExecutionScheduler {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Race-condition guard: when multiple with_items children
|
||||
// complete nearly simultaneously, the worker updates their
|
||||
// DB status to Completed *before* the completion MQ message
|
||||
// is processed. This means several advance_workflow calls
|
||||
// (processed sequentially by the completion listener) can
|
||||
// each see "0 siblings remaining" and fall through to
|
||||
// transition evaluation, dispatching successor tasks
|
||||
// multiple times.
|
||||
//
|
||||
// To prevent this we re-check the *persisted*
|
||||
// completed/failed task lists that were loaded from the
|
||||
// workflow_execution record at the top of this function.
|
||||
// If `task_name` is already present, a previous
|
||||
// advance_workflow invocation already handled the final
|
||||
// completion of this with_items task and dispatched its
|
||||
// successors — we can safely return.
|
||||
// ---------------------------------------------------------
|
||||
if workflow_execution
|
||||
.completed_tasks
|
||||
.contains(&task_name.to_string())
|
||||
|| workflow_execution
|
||||
.failed_tasks
|
||||
.contains(&task_name.to_string())
|
||||
{
|
||||
debug!(
|
||||
"with_items task '{}' already in persisted completed/failed list — \
|
||||
another advance_workflow call already handled final completion, skipping",
|
||||
task_name,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// All items done — check if any failed
|
||||
let any_failed: Vec<(i64,)> = sqlx::query_as(
|
||||
"SELECT id \
|
||||
@@ -1129,10 +1172,10 @@ impl ExecutionScheduler {
|
||||
if should_fire {
|
||||
// Process publish directives from this transition
|
||||
if !transition.publish.is_empty() {
|
||||
let publish_map: HashMap<String, String> = transition
|
||||
let publish_map: HashMap<String, JsonValue> = transition
|
||||
.publish
|
||||
.iter()
|
||||
.map(|p| (p.name.clone(), p.expression.clone()))
|
||||
.map(|p| (p.name.clone(), p.value.clone()))
|
||||
.collect();
|
||||
if let Err(e) = wf_ctx.publish_from_result(
|
||||
&serde_json::json!({}),
|
||||
@@ -1161,6 +1204,41 @@ impl ExecutionScheduler {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Guard against dispatching a task that has already
|
||||
// been dispatched (exists as a child execution in
|
||||
// this workflow). This catches edge cases where
|
||||
// the persisted completed/failed lists haven't been
|
||||
// updated yet but a child execution was already
|
||||
// created by a prior advance_workflow call.
|
||||
//
|
||||
// This is critical for with_items predecessors:
|
||||
// workers update DB status to Completed before the
|
||||
// completion MQ message is processed, so multiple
|
||||
// with_items items can each see "0 siblings
|
||||
// remaining" and attempt to dispatch the same
|
||||
// successor. The query covers both regular tasks
|
||||
// (task_index IS NULL) and with_items tasks
|
||||
// (task_index IS NOT NULL) so that neither case
|
||||
// can be double-dispatched.
|
||||
let already_dispatched: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) \
|
||||
FROM execution \
|
||||
WHERE workflow_task->>'workflow_execution' = $1::text \
|
||||
AND workflow_task->>'task_name' = $2",
|
||||
)
|
||||
.bind(workflow_execution_id.to_string())
|
||||
.bind(next_task_name.as_str())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if already_dispatched.0 > 0 {
|
||||
debug!(
|
||||
"Skipping task '{}' — already dispatched ({} existing execution(s))",
|
||||
next_task_name, already_dispatched.0
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check join barrier: if the task has a `join` count,
|
||||
// only schedule it when enough predecessors are done.
|
||||
if let Some(next_node) = graph.get_task(next_task_name) {
|
||||
@@ -1210,6 +1288,7 @@ impl ExecutionScheduler {
|
||||
&workflow_execution_id,
|
||||
task_node,
|
||||
&wf_ctx,
|
||||
Some(task_name), // predecessor that triggered this task
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1716,19 +1795,8 @@ mod tests {
|
||||
assert_eq!(free, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_workflow_params_wrapped_format() {
|
||||
// Child task executions store config as {"parameters": {...}}
|
||||
let config = Some(serde_json::json!({
|
||||
"parameters": {"n": 5, "name": "test"}
|
||||
}));
|
||||
let params = extract_workflow_params(&config);
|
||||
assert_eq!(params, serde_json::json!({"n": 5, "name": "test"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_workflow_params_flat_format() {
|
||||
// API manual executions store config as flat {"n": 5, ...}
|
||||
let config = Some(serde_json::json!({"n": 5, "name": "test"}));
|
||||
let params = extract_workflow_params(&config);
|
||||
assert_eq!(params, serde_json::json!({"n": 5, "name": "test"}));
|
||||
@@ -1742,7 +1810,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extract_workflow_params_non_object() {
|
||||
// Edge case: config is a non-object JSON value
|
||||
let config = Some(serde_json::json!("not an object"));
|
||||
let params = extract_workflow_params(&config);
|
||||
assert_eq!(params, serde_json::json!({}));
|
||||
@@ -1756,14 +1823,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_workflow_params_wrapped_takes_precedence() {
|
||||
// If config has a "parameters" key, that value is used even if
|
||||
// the config object also has other top-level keys
|
||||
fn test_extract_workflow_params_with_parameters_key() {
|
||||
// A "parameters" key is just a regular parameter — not unwrapped
|
||||
let config = Some(serde_json::json!({
|
||||
"parameters": {"n": 5},
|
||||
"context": {"rule": "test"}
|
||||
}));
|
||||
let params = extract_workflow_params(&config);
|
||||
assert_eq!(params, serde_json::json!({"n": 5}));
|
||||
// Returns the whole object as-is — "parameters" is treated as a normal key
|
||||
assert_eq!(
|
||||
params,
|
||||
serde_json::json!({"parameters": {"n": 5}, "context": {"rule": "test"}})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,24 +412,26 @@ impl WorkflowContext {
|
||||
|
||||
/// 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.
|
||||
/// Each publish directive is a `(name, value)` pair where the value is
|
||||
/// any JSON-compatible type. String values are treated as template
|
||||
/// expressions (e.g. `"{{ result().data.items }}"`) and rendered with
|
||||
/// type preservation. Non-string values (booleans, numbers, arrays,
|
||||
/// objects, null) pass through `render_json` unchanged, preserving
|
||||
/// their original type.
|
||||
pub fn publish_from_result(
|
||||
&mut self,
|
||||
result: &JsonValue,
|
||||
publish_vars: &[String],
|
||||
publish_map: Option<&HashMap<String, String>>,
|
||||
publish_map: Option<&HashMap<String, JsonValue>>,
|
||||
) -> ContextResult<()> {
|
||||
// If publish map is provided, use it
|
||||
if let Some(map) = publish_map {
|
||||
for (var_name, template) in map {
|
||||
// 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)?;
|
||||
for (var_name, json_value) in map {
|
||||
// render_json handles all types: strings are template-rendered
|
||||
// (with type preservation for pure `{{ }}` expressions), while
|
||||
// booleans, numbers, arrays, objects, and null pass through
|
||||
// unchanged.
|
||||
let value = self.render_json(json_value)?;
|
||||
self.set_var(var_name, value);
|
||||
}
|
||||
} else {
|
||||
@@ -1095,7 +1097,7 @@ mod tests {
|
||||
let mut publish_map = HashMap::new();
|
||||
publish_map.insert(
|
||||
"number_list".to_string(),
|
||||
"{{ result().data.items }}".to_string(),
|
||||
JsonValue::String("{{ result().data.items }}".to_string()),
|
||||
);
|
||||
|
||||
ctx.publish_from_result(&json!({}), &[], Some(&publish_map))
|
||||
@@ -1117,6 +1119,52 @@ mod tests {
|
||||
assert_eq!(ctx.get_var("my_var").unwrap(), result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_publish_typed_values() {
|
||||
// Non-string publish values (booleans, numbers, null) should pass
|
||||
// through render_json unchanged and be stored with their original type.
|
||||
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
|
||||
ctx.set_last_task_outcome(json!({"status": "ok"}), TaskOutcome::Succeeded);
|
||||
|
||||
let mut publish_map = HashMap::new();
|
||||
publish_map.insert("flag".to_string(), JsonValue::Bool(true));
|
||||
publish_map.insert("count".to_string(), json!(42));
|
||||
publish_map.insert("ratio".to_string(), json!(3.14));
|
||||
publish_map.insert("nothing".to_string(), JsonValue::Null);
|
||||
publish_map.insert(
|
||||
"template".to_string(),
|
||||
JsonValue::String("{{ result().status }}".to_string()),
|
||||
);
|
||||
publish_map.insert(
|
||||
"plain_str".to_string(),
|
||||
JsonValue::String("hello".to_string()),
|
||||
);
|
||||
|
||||
ctx.publish_from_result(&json!({}), &[], Some(&publish_map))
|
||||
.unwrap();
|
||||
|
||||
// Boolean preserved as boolean (not string "true")
|
||||
assert_eq!(ctx.get_var("flag").unwrap(), json!(true));
|
||||
assert!(ctx.get_var("flag").unwrap().is_boolean());
|
||||
|
||||
// Integer preserved
|
||||
assert_eq!(ctx.get_var("count").unwrap(), json!(42));
|
||||
assert!(ctx.get_var("count").unwrap().is_number());
|
||||
|
||||
// Float preserved
|
||||
assert_eq!(ctx.get_var("ratio").unwrap(), json!(3.14));
|
||||
|
||||
// Null preserved
|
||||
assert_eq!(ctx.get_var("nothing").unwrap(), json!(null));
|
||||
assert!(ctx.get_var("nothing").unwrap().is_null());
|
||||
|
||||
// Template expression rendered with type preservation
|
||||
assert_eq!(ctx.get_var("template").unwrap(), json!("ok"));
|
||||
|
||||
// Plain string stays as string
|
||||
assert_eq!(ctx.get_var("plain_str").unwrap(), json!("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_published_var_accessible_via_workflow_namespace() {
|
||||
let mut ctx = WorkflowContext::new(json!({}), HashMap::new());
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! - `do` — next tasks to invoke when the condition is met
|
||||
|
||||
use attune_common::workflow::{Task, TaskType, WorkflowDefinition};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Result type for graph operations
|
||||
@@ -101,11 +102,23 @@ pub struct GraphTransition {
|
||||
pub do_tasks: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single publish variable (key = expression)
|
||||
/// A single publish variable (key = value).
|
||||
///
|
||||
/// The `value` field holds either a template expression (as a `JsonValue::String`
|
||||
/// containing `{{ }}`), a literal string, or any other JSON-compatible type
|
||||
/// (boolean, number, array, object, null). The workflow context's `render_json`
|
||||
/// method handles all of these: strings are template-rendered (with type
|
||||
/// preservation for pure expressions), while non-string values pass through
|
||||
/// unchanged.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PublishVar {
|
||||
pub name: String,
|
||||
pub expression: String,
|
||||
/// The publish value — may be a template string, literal boolean, number,
|
||||
/// array, object, or null. Renamed from `expression` (which only supported
|
||||
/// strings); the serde alias ensures existing serialized task graphs that
|
||||
/// use the old field name still deserialize correctly.
|
||||
#[serde(alias = "expression")]
|
||||
pub value: JsonValue,
|
||||
}
|
||||
|
||||
/// Retry configuration
|
||||
@@ -463,14 +476,14 @@ fn extract_publish_vars(publish: &[attune_common::workflow::PublishDirective]) -
|
||||
for (key, value) in map {
|
||||
vars.push(PublishVar {
|
||||
name: key.clone(),
|
||||
expression: value.clone(),
|
||||
value: value.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
PublishDirective::Key(key) => {
|
||||
vars.push(PublishVar {
|
||||
name: key.clone(),
|
||||
expression: "{{ result() }}".to_string(),
|
||||
value: JsonValue::String("{{ result() }}".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -678,7 +691,7 @@ tasks:
|
||||
assert_eq!(transitions.len(), 1);
|
||||
assert_eq!(transitions[0].publish.len(), 1);
|
||||
assert_eq!(transitions[0].publish[0].name, "msg");
|
||||
assert_eq!(transitions[0].publish[0].expression, "task1 done");
|
||||
assert_eq!(transitions[0].publish[0].value, JsonValue::String("task1 done".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -932,4 +945,82 @@ tasks:
|
||||
assert!(next.contains(&"failure_task".to_string()));
|
||||
assert!(next.contains(&"always_task".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typed_publish_values() {
|
||||
// Verify that non-string publish values (booleans, numbers, null)
|
||||
// are preserved through parsing and graph construction.
|
||||
let yaml = r#"
|
||||
ref: test.typed_publish
|
||||
label: Typed Publish Test
|
||||
version: 1.0.0
|
||||
tasks:
|
||||
- name: task1
|
||||
action: core.echo
|
||||
next:
|
||||
- when: "{{ succeeded() }}"
|
||||
publish:
|
||||
- validation_passed: true
|
||||
- count: 42
|
||||
- ratio: 3.14
|
||||
- label: "hello"
|
||||
- template_val: "{{ result().data }}"
|
||||
- nothing: null
|
||||
do:
|
||||
- task2
|
||||
- when: "{{ failed() }}"
|
||||
publish:
|
||||
- validation_passed: false
|
||||
do:
|
||||
- task2
|
||||
- name: task2
|
||||
action: core.echo
|
||||
"#;
|
||||
|
||||
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
|
||||
let graph = TaskGraph::from_workflow(&workflow).unwrap();
|
||||
|
||||
let task1 = graph.get_task("task1").unwrap();
|
||||
assert_eq!(task1.transitions.len(), 2);
|
||||
|
||||
// Success transition should have 6 publish vars
|
||||
let success_publish = &task1.transitions[0].publish;
|
||||
assert_eq!(success_publish.len(), 6);
|
||||
|
||||
// Build a lookup map for easier assertions
|
||||
let publish_map: HashMap<&str, &JsonValue> = success_publish
|
||||
.iter()
|
||||
.map(|p| (p.name.as_str(), &p.value))
|
||||
.collect();
|
||||
|
||||
// Boolean true is preserved as a JSON boolean
|
||||
assert_eq!(publish_map["validation_passed"], &JsonValue::Bool(true));
|
||||
|
||||
// Integer is preserved as a JSON number
|
||||
assert_eq!(publish_map["count"], &serde_json::json!(42));
|
||||
|
||||
// Float is preserved as a JSON number
|
||||
assert_eq!(publish_map["ratio"], &serde_json::json!(3.14));
|
||||
|
||||
// Plain string stays as a string
|
||||
assert_eq!(
|
||||
publish_map["label"],
|
||||
&JsonValue::String("hello".to_string())
|
||||
);
|
||||
|
||||
// Template expression stays as a string (rendered later by context)
|
||||
assert_eq!(
|
||||
publish_map["template_val"],
|
||||
&JsonValue::String("{{ result().data }}".to_string())
|
||||
);
|
||||
|
||||
// Null is preserved
|
||||
assert_eq!(publish_map["nothing"], &JsonValue::Null);
|
||||
|
||||
// Failure transition should have boolean false
|
||||
let failure_publish = &task1.transitions[1].publish;
|
||||
assert_eq!(failure_publish.len(), 1);
|
||||
assert_eq!(failure_publish[0].name, "validation_passed");
|
||||
assert_eq!(failure_publish[0].value, JsonValue::Bool(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +162,16 @@ pub enum TaskType {
|
||||
}
|
||||
|
||||
/// Variable publishing directive
|
||||
///
|
||||
/// Values may be template expressions (strings containing `{{ }}`), literal
|
||||
/// strings, or any other JSON-compatible type (booleans, numbers, arrays,
|
||||
/// objects). Non-string literals are preserved through the rendering pipeline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PublishDirective {
|
||||
/// Simple key-value pair
|
||||
Simple(HashMap<String, String>),
|
||||
/// Key-value pair where the value can be any JSON-compatible type
|
||||
/// (string template, boolean, number, array, object, null).
|
||||
Simple(HashMap<String, serde_json::Value>),
|
||||
/// Just a key (publishes entire result under that key)
|
||||
Key(String),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! Workflows are stored in the `workflow_definition` table with their full YAML definition
|
||||
//! as JSON. A companion action record is also created so that workflows appear in
|
||||
//! action lists and the workflow builder's action palette.
|
||||
//!
|
||||
//! Standalone workflow files (in `workflows/`) carry their own `ref` and `label`.
|
||||
//! Action-linked workflow files (in `actions/workflows/`, referenced via
|
||||
//! `workflow_file`) may omit those fields — the registrar falls back to
|
||||
//! `WorkflowFile.ref_name` / `WorkflowFile.name` derived from the filename.
|
||||
|
||||
use attune_common::error::{Error, Result};
|
||||
use attune_common::repositories::action::{ActionRepository, CreateActionInput, UpdateActionInput};
|
||||
@@ -63,6 +68,32 @@ impl WorkflowRegistrar {
|
||||
Self { pool, options }
|
||||
}
|
||||
|
||||
/// Resolve the effective ref for a workflow.
|
||||
///
|
||||
/// Prefers the value declared in the YAML; falls back to the
|
||||
/// `WorkflowFile.ref_name` derived from the filename when the YAML
|
||||
/// omits it (action-linked workflow files).
|
||||
fn effective_ref(loaded: &LoadedWorkflow) -> String {
|
||||
if loaded.workflow.r#ref.is_empty() {
|
||||
loaded.file.ref_name.clone()
|
||||
} else {
|
||||
loaded.workflow.r#ref.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the effective label for a workflow.
|
||||
///
|
||||
/// Prefers the value declared in the YAML; falls back to the
|
||||
/// `WorkflowFile.name` (human-readable filename stem) when the YAML
|
||||
/// omits it.
|
||||
fn effective_label(loaded: &LoadedWorkflow) -> String {
|
||||
if loaded.workflow.label.is_empty() {
|
||||
loaded.file.name.clone()
|
||||
} else {
|
||||
loaded.workflow.label.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a single workflow
|
||||
pub async fn register_workflow(&self, loaded: &LoadedWorkflow) -> Result<RegistrationResult> {
|
||||
debug!("Registering workflow: {}", loaded.file.ref_name);
|
||||
@@ -93,6 +124,12 @@ impl WorkflowRegistrar {
|
||||
warnings.push(err.clone());
|
||||
}
|
||||
|
||||
// Resolve effective ref/label — prefer workflow YAML values, fall
|
||||
// back to filename-derived values for action-linked workflow files
|
||||
// that omit action-level metadata.
|
||||
let effective_ref = Self::effective_ref(loaded);
|
||||
let effective_label = Self::effective_label(loaded);
|
||||
|
||||
let (workflow_def_id, created) = if let Some(existing) = existing_workflow {
|
||||
if !self.options.update_existing {
|
||||
return Err(Error::already_exists(
|
||||
@@ -104,7 +141,13 @@ impl WorkflowRegistrar {
|
||||
|
||||
info!("Updating existing workflow: {}", loaded.file.ref_name);
|
||||
let workflow_def_id = self
|
||||
.update_workflow(&existing.id, &loaded.workflow, &pack.r#ref)
|
||||
.update_workflow(
|
||||
&existing.id,
|
||||
&loaded.workflow,
|
||||
&pack.r#ref,
|
||||
&effective_ref,
|
||||
&effective_label,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update or create the companion action record
|
||||
@@ -114,6 +157,8 @@ impl WorkflowRegistrar {
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&loaded.file.name,
|
||||
&effective_ref,
|
||||
&effective_label,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -121,7 +166,14 @@ impl WorkflowRegistrar {
|
||||
} else {
|
||||
info!("Creating new workflow: {}", loaded.file.ref_name);
|
||||
let workflow_def_id = self
|
||||
.create_workflow(&loaded.workflow, &loaded.file.pack, pack.id, &pack.r#ref)
|
||||
.create_workflow(
|
||||
&loaded.workflow,
|
||||
&loaded.file.pack,
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&effective_ref,
|
||||
&effective_label,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create a companion action record so the workflow appears in action lists
|
||||
@@ -131,6 +183,8 @@ impl WorkflowRegistrar {
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&loaded.file.name,
|
||||
&effective_ref,
|
||||
&effective_label,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -197,6 +251,9 @@ impl WorkflowRegistrar {
|
||||
/// This ensures the workflow appears in action lists and the action palette
|
||||
/// in the workflow builder. The action is linked to the workflow definition
|
||||
/// via the `workflow_def` FK.
|
||||
///
|
||||
/// `effective_ref` and `effective_label` are the resolved values (which may
|
||||
/// have been derived from the filename when the workflow YAML omits them).
|
||||
async fn create_companion_action(
|
||||
&self,
|
||||
workflow_def_id: i64,
|
||||
@@ -204,14 +261,16 @@ impl WorkflowRegistrar {
|
||||
pack_id: i64,
|
||||
pack_ref: &str,
|
||||
workflow_name: &str,
|
||||
effective_ref: &str,
|
||||
effective_label: &str,
|
||||
) -> Result<()> {
|
||||
let entrypoint = format!("workflows/{}.workflow.yaml", workflow_name);
|
||||
|
||||
let action_input = CreateActionInput {
|
||||
r#ref: workflow.r#ref.clone(),
|
||||
r#ref: effective_ref.to_string(),
|
||||
pack: pack_id,
|
||||
pack_ref: pack_ref.to_string(),
|
||||
label: workflow.label.clone(),
|
||||
label: effective_label.to_string(),
|
||||
description: workflow.description.clone().unwrap_or_default(),
|
||||
entrypoint,
|
||||
runtime: None,
|
||||
@@ -228,7 +287,7 @@ impl WorkflowRegistrar {
|
||||
|
||||
info!(
|
||||
"Created companion action '{}' (ID: {}) for workflow definition (ID: {})",
|
||||
workflow.r#ref, action.id, workflow_def_id
|
||||
effective_ref, action.id, workflow_def_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -238,6 +297,9 @@ impl WorkflowRegistrar {
|
||||
///
|
||||
/// If the action already exists, update it. If it doesn't exist (e.g., for
|
||||
/// workflows registered before the companion-action fix), create it.
|
||||
///
|
||||
/// `effective_ref` and `effective_label` are the resolved values (which may
|
||||
/// have been derived from the filename when the workflow YAML omits them).
|
||||
async fn ensure_companion_action(
|
||||
&self,
|
||||
workflow_def_id: i64,
|
||||
@@ -245,6 +307,8 @@ impl WorkflowRegistrar {
|
||||
pack_id: i64,
|
||||
pack_ref: &str,
|
||||
workflow_name: &str,
|
||||
effective_ref: &str,
|
||||
effective_label: &str,
|
||||
) -> Result<()> {
|
||||
let existing_action =
|
||||
ActionRepository::find_by_workflow_def(&self.pool, workflow_def_id).await?;
|
||||
@@ -252,7 +316,7 @@ impl WorkflowRegistrar {
|
||||
if let Some(action) = existing_action {
|
||||
// Update the existing companion action to stay in sync
|
||||
let update_input = UpdateActionInput {
|
||||
label: Some(workflow.label.clone()),
|
||||
label: Some(effective_label.to_string()),
|
||||
description: workflow.description.clone(),
|
||||
entrypoint: Some(format!("workflows/{}.workflow.yaml", workflow_name)),
|
||||
runtime: None,
|
||||
@@ -278,6 +342,8 @@ impl WorkflowRegistrar {
|
||||
pack_id,
|
||||
pack_ref,
|
||||
workflow_name,
|
||||
effective_ref,
|
||||
effective_label,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -286,27 +352,32 @@ impl WorkflowRegistrar {
|
||||
}
|
||||
|
||||
/// Create a new workflow definition
|
||||
///
|
||||
/// `effective_ref` and `effective_label` are the resolved values (which may
|
||||
/// have been derived from the filename when the workflow YAML omits them).
|
||||
async fn create_workflow(
|
||||
&self,
|
||||
workflow: &WorkflowYaml,
|
||||
_pack_name: &str,
|
||||
pack_id: i64,
|
||||
pack_ref: &str,
|
||||
effective_ref: &str,
|
||||
effective_label: &str,
|
||||
) -> Result<i64> {
|
||||
// Convert the parsed workflow back to JSON for storage
|
||||
let definition = serde_json::to_value(workflow)
|
||||
.map_err(|e| Error::validation(format!("Failed to serialize workflow: {}", e)))?;
|
||||
|
||||
let input = CreateWorkflowDefinitionInput {
|
||||
r#ref: workflow.r#ref.clone(),
|
||||
r#ref: effective_ref.to_string(),
|
||||
pack: pack_id,
|
||||
pack_ref: pack_ref.to_string(),
|
||||
label: workflow.label.clone(),
|
||||
label: effective_label.to_string(),
|
||||
description: workflow.description.clone(),
|
||||
version: workflow.version.clone(),
|
||||
param_schema: workflow.parameters.clone(),
|
||||
out_schema: workflow.output.clone(),
|
||||
definition: definition,
|
||||
definition,
|
||||
tags: workflow.tags.clone(),
|
||||
enabled: true,
|
||||
};
|
||||
@@ -317,18 +388,23 @@ impl WorkflowRegistrar {
|
||||
}
|
||||
|
||||
/// Update an existing workflow definition
|
||||
///
|
||||
/// `effective_ref` and `effective_label` are the resolved values (which may
|
||||
/// have been derived from the filename when the workflow YAML omits them).
|
||||
async fn update_workflow(
|
||||
&self,
|
||||
workflow_id: &i64,
|
||||
workflow: &WorkflowYaml,
|
||||
_pack_ref: &str,
|
||||
_effective_ref: &str,
|
||||
effective_label: &str,
|
||||
) -> Result<i64> {
|
||||
// Convert the parsed workflow back to JSON for storage
|
||||
let definition = serde_json::to_value(workflow)
|
||||
.map_err(|e| Error::validation(format!("Failed to serialize workflow: {}", e)))?;
|
||||
|
||||
let input = UpdateWorkflowDefinitionInput {
|
||||
label: Some(workflow.label.clone()),
|
||||
label: Some(effective_label.to_string()),
|
||||
description: workflow.description.clone(),
|
||||
version: Some(workflow.version.clone()),
|
||||
param_schema: workflow.parameters.clone(),
|
||||
|
||||
Reference in New Issue
Block a user