working on workflows

This commit is contained in:
2026-03-04 22:02:34 -06:00
parent b54aa3ec26
commit 7438f92502
63 changed files with 10231 additions and 731 deletions

View File

@@ -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());

View File

@@ -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));
}
}

View File

@@ -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),
}

View File

@@ -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(),