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

@@ -1052,6 +1052,14 @@ pub mod execution {
/// Task name within the workflow
pub task_name: String,
/// Name of the predecessor task whose completion triggered this task's
/// dispatch. `None` for entry-point tasks (dispatched at workflow
/// start). Used by the timeline UI to draw only the transitions that
/// actually fired rather than every possible transition from the
/// workflow definition.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub triggered_by: Option<String>,
/// Index for with-items iteration (0-based)
pub task_index: Option<i32>,

View File

@@ -7,19 +7,33 @@
//! Components are loaded in dependency order:
//! 1. Runtimes (no dependencies)
//! 2. Triggers (no dependencies)
//! 3. Actions (depend on runtime)
//! 3. Actions (depend on runtime; workflow actions also create workflow_definition records)
//! 4. Sensors (depend on triggers and runtime)
//!
//! All loaders use **upsert** semantics: if an entity with the same ref already
//! exists it is updated in place (preserving its database ID); otherwise a new
//! row is created. After loading, entities that belong to the pack but whose
//! refs are no longer present in the YAML files are deleted.
//!
//! ## Workflow Actions
//!
//! An action YAML may include a `workflow_file` field pointing to a workflow
//! definition file relative to the `actions/` directory (e.g.,
//! `workflow_file: workflows/deploy.workflow.yaml`). When present the loader:
//!
//! 1. Reads and parses the referenced workflow YAML file.
//! 2. Creates or updates a `workflow_definition` record in the database.
//! 3. Creates the action record with `workflow_def` linked to the definition.
//!
//! This allows the action YAML to control action-level metadata (ref, label,
//! parameters, policies) independently of the workflow graph. Multiple actions
//! can reference the same workflow file with different configurations.
use std::collections::HashMap;
use std::path::Path;
use sqlx::PgPool;
use tracing::{info, warn};
use tracing::{debug, info, warn};
use crate::error::{Error, Result};
use crate::models::Id;
@@ -32,8 +46,12 @@ use crate::repositories::trigger::{
CreateSensorInput, CreateTriggerInput, SensorRepository, TriggerRepository, UpdateSensorInput,
UpdateTriggerInput,
};
use crate::repositories::workflow::{
CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository,
};
use crate::repositories::{Create, Delete, FindById, FindByRef, Update};
use crate::version_matching::extract_version_components;
use crate::workflow::parser::parse_workflow_yaml;
/// Result of loading pack components into the database.
#[derive(Debug, Default)]
@@ -588,6 +606,13 @@ impl<'a> PackComponentLoader<'a> {
/// Load action definitions from `pack_dir/actions/*.yaml`.
///
/// Returns the list of loaded action refs for cleanup.
///
/// When an action YAML contains a `workflow_file` field, the loader reads
/// the referenced workflow definition, creates/updates a
/// `workflow_definition` record, and links the action to it via the
/// `action.workflow_def` FK. This enables the action YAML to control
/// action-level metadata independently of the workflow graph, and allows
/// multiple actions to share the same workflow file.
async fn load_actions(
&self,
pack_dir: &Path,
@@ -636,19 +661,64 @@ impl<'a> PackComponentLoader<'a> {
.unwrap_or("")
.to_string();
let entrypoint = data
.get("entry_point")
// ── Workflow file handling ──────────────────────────────────
// If the action declares `workflow_file`, load the referenced
// workflow definition and link the action to it.
let workflow_file_field = data
.get("workflow_file")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
.map(|s| s.to_string());
// Resolve runtime ID from runner_type
let runner_type = data
.get("runner_type")
.and_then(|v| v.as_str())
.unwrap_or("shell");
let workflow_def_id: Option<Id> = if let Some(ref wf_path) = workflow_file_field {
match self
.load_workflow_for_action(
&actions_dir,
wf_path,
&action_ref,
&label,
&description,
&data,
)
.await
{
Ok(id) => Some(id),
Err(e) => {
let msg = format!(
"Failed to load workflow file '{}' for action '{}': {}",
wf_path, action_ref, e
);
warn!("{}", msg);
result.warnings.push(msg);
// Continue creating the action without workflow link
None
}
}
} else {
None
};
let runtime_id = self.resolve_runtime_id(runner_type).await?;
// For workflow actions the entrypoint is the workflow file path;
// for regular actions it comes from entry_point in the YAML.
let entrypoint = if let Some(ref wf_path) = workflow_file_field {
wf_path.clone()
} else {
data.get("entry_point")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
};
// Resolve runtime ID from runner_type (workflow actions have no
// runner_type and get runtime = None).
let runtime_id = if workflow_file_field.is_some() {
None
} else {
let runner_type = data
.get("runner_type")
.and_then(|v| v.as_str())
.unwrap_or("shell");
self.resolve_runtime_id(runner_type).await?
};
let param_schema = data
.get("parameters")
@@ -701,6 +771,19 @@ impl<'a> PackComponentLoader<'a> {
Ok(_) => {
info!("Updated action '{}' (ID: {})", action_ref, existing.id);
result.actions_updated += 1;
// Re-link workflow definition if present
if let Some(wf_id) = workflow_def_id {
if let Err(e) =
ActionRepository::link_workflow_def(self.pool, existing.id, wf_id)
.await
{
warn!(
"Failed to link workflow def {} to action '{}': {}",
wf_id, action_ref, e
);
}
}
}
Err(e) => {
let msg = format!("Failed to update action '{}': {}", action_ref, e);
@@ -745,8 +828,25 @@ impl<'a> PackComponentLoader<'a> {
match create_result {
Ok(id) => {
info!("Created action '{}' (ID: {})", action_ref, id);
loaded_refs.push(action_ref);
loaded_refs.push(action_ref.clone());
result.actions_loaded += 1;
// Link workflow definition if present
if let Some(wf_id) = workflow_def_id {
if let Err(e) =
ActionRepository::link_workflow_def(self.pool, id, wf_id).await
{
warn!(
"Failed to link workflow def {} to new action '{}': {}",
wf_id, action_ref, e
);
} else {
info!(
"Linked action '{}' (ID: {}) to workflow definition (ID: {})",
action_ref, id, wf_id
);
}
}
}
Err(e) => {
// Check for unique constraint violation (already exists race condition)
@@ -771,6 +871,146 @@ impl<'a> PackComponentLoader<'a> {
Ok(loaded_refs)
}
/// Load a workflow definition file referenced by an action's `workflow_file`
/// field and create/update the corresponding `workflow_definition` record.
///
/// Returns the database ID of the workflow definition.
async fn load_workflow_for_action(
&self,
actions_dir: &Path,
workflow_file_path: &str,
action_ref: &str,
action_label: &str,
action_description: &str,
action_data: &serde_yaml_ng::Value,
) -> Result<Id> {
let full_path = actions_dir.join(workflow_file_path);
if !full_path.exists() {
return Err(Error::validation(format!(
"Workflow file '{}' not found at '{}'",
workflow_file_path,
full_path.display()
)));
}
let content = std::fs::read_to_string(&full_path).map_err(|e| {
Error::io(format!(
"Failed to read workflow file '{}': {}",
full_path.display(),
e
))
})?;
let mut workflow_yaml = parse_workflow_yaml(&content)?;
// The action YAML is authoritative for action-level metadata.
// Fill in ref/label/description/tags from the action when the
// workflow file omits them (action-linked workflow files should
// contain only the execution graph).
if workflow_yaml.r#ref.is_empty() {
workflow_yaml.r#ref = action_ref.to_string();
}
if workflow_yaml.label.is_empty() {
workflow_yaml.label = action_label.to_string();
}
if workflow_yaml.description.is_none() {
workflow_yaml.description = Some(action_description.to_string());
}
if workflow_yaml.tags.is_empty() {
if let Some(tags_val) = action_data.get("tags") {
if let Some(tags_seq) = tags_val.as_sequence() {
workflow_yaml.tags = tags_seq
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
}
}
let workflow_ref = workflow_yaml.r#ref.clone();
// The action YAML is authoritative for param_schema / out_schema.
// Fall back to the workflow file's own schemas only if the action
// YAML doesn't define them.
let param_schema = action_data
.get("parameters")
.and_then(|v| serde_json::to_value(v).ok())
.or_else(|| workflow_yaml.parameters.clone());
let out_schema = action_data
.get("output")
.and_then(|v| serde_json::to_value(v).ok())
.or_else(|| workflow_yaml.output.clone());
let definition_json = serde_json::to_value(&workflow_yaml)
.map_err(|e| Error::validation(format!("Failed to serialize workflow: {}", e)))?;
// Derive label/description for the DB record from the action YAML,
// since it is authoritative. The workflow file values were already
// used as fallback above when populating workflow_yaml.
let label = workflow_yaml.label.clone();
let description = workflow_yaml.description.clone();
let tags = workflow_yaml.tags.clone();
// Check if this workflow definition already exists
if let Some(existing) =
WorkflowDefinitionRepository::find_by_ref(self.pool, &workflow_ref).await?
{
debug!(
"Updating existing workflow definition '{}' (ID: {})",
workflow_ref, existing.id
);
let update_input = UpdateWorkflowDefinitionInput {
label: Some(label),
description,
version: Some(workflow_yaml.version.clone()),
param_schema,
out_schema,
definition: Some(definition_json),
tags: Some(tags),
enabled: Some(true),
};
WorkflowDefinitionRepository::update(self.pool, existing.id, update_input).await?;
info!(
"Updated workflow definition '{}' (ID: {}) for action '{}'",
workflow_ref, existing.id, action_ref
);
Ok(existing.id)
} else {
debug!(
"Creating new workflow definition '{}' for action '{}'",
workflow_ref, action_ref
);
let create_input = CreateWorkflowDefinitionInput {
r#ref: workflow_ref.clone(),
pack: self.pack_id,
pack_ref: self.pack_ref.clone(),
label,
description,
version: workflow_yaml.version.clone(),
param_schema,
out_schema,
definition: definition_json,
tags,
enabled: true,
};
let created = WorkflowDefinitionRepository::create(self.pool, create_input).await?;
info!(
"Created workflow definition '{}' (ID: {}) for action '{}'",
workflow_ref, created.id, action_ref
);
Ok(created.id)
}
}
/// Load sensor definitions from `pack_dir/sensors/*.yaml`.
///
/// Returns the list of loaded sensor refs for cleanup.

View File

@@ -115,12 +115,17 @@ pub fn validate_workflow_expressions(
match directive {
PublishDirective::Simple(map) => {
for (pk, pv) in map {
validate_template(
pv,
&format!("{task_loc} next[{ti}].publish.{pk}"),
&known_names,
&mut warnings,
);
// Only validate string values as templates;
// non-string literals (booleans, numbers, etc.)
// pass through unchanged and have no expressions.
if let Some(s) = pv.as_str() {
validate_template(
s,
&format!("{task_loc} next[{ti}].publish.{pk}"),
&known_names,
&mut warnings,
);
}
}
}
PublishDirective::Key(_) => { /* nothing to validate */ }
@@ -132,12 +137,16 @@ pub fn validate_workflow_expressions(
for directive in &task.publish {
if let PublishDirective::Simple(map) = directive {
for (pk, pv) in map {
validate_template(
pv,
&format!("{task_loc} publish.{pk}"),
&known_names,
&mut warnings,
);
// Only validate string values as templates;
// non-string literals pass through unchanged.
if let Some(s) = pv.as_str() {
validate_template(
s,
&format!("{task_loc} publish.{pk}"),
&known_names,
&mut warnings,
);
}
}
}
}
@@ -567,7 +576,7 @@ mod tests {
fn test_transition_publish_validated() {
let mut task = action_task("step1");
let mut publish_map = HashMap::new();
publish_map.insert("out".to_string(), "{{ unknown_thing }}".to_string());
publish_map.insert("out".to_string(), serde_json::Value::String("{{ unknown_thing }}".to_string()));
task.next = vec![super::super::parser::TaskTransition {
when: Some("{{ succeeded() }}".to_string()),
publish: vec![PublishDirective::Simple(publish_map)],

View File

@@ -109,32 +109,49 @@ impl WorkflowLoader {
}
/// Load all workflows from a specific pack
///
/// Scans two directories in order:
/// 1. `{pack_dir}/workflows/` — legacy/standalone workflow files
/// 2. `{pack_dir}/actions/workflows/` — visual-builder and action-linked workflow files
///
/// If the same workflow ref appears in both directories, the version from
/// `actions/workflows/` wins (it is scanned second and overwrites the map entry).
pub async fn load_pack_workflows(
&self,
pack_name: &str,
pack_dir: &Path,
) -> Result<HashMap<String, LoadedWorkflow>> {
let workflows_dir = pack_dir.join("workflows");
if !workflows_dir.exists() {
debug!("No workflows directory in pack '{}'", pack_name);
return Ok(HashMap::new());
}
let workflow_files = self.scan_workflow_files(&workflows_dir, pack_name).await?;
let mut workflows = HashMap::new();
for file in workflow_files {
match self.load_workflow_file(&file).await {
Ok(loaded) => {
workflows.insert(loaded.file.ref_name.clone(), loaded);
}
Err(e) => {
warn!("Failed to load workflow '{}': {}", file.path.display(), e);
// Scan both workflow directories
let scan_dirs: Vec<std::path::PathBuf> = vec![
pack_dir.join("workflows"),
pack_dir.join("actions").join("workflows"),
];
for workflows_dir in &scan_dirs {
if !workflows_dir.exists() {
continue;
}
let workflow_files = self.scan_workflow_files(workflows_dir, pack_name).await?;
for file in workflow_files {
match self.load_workflow_file(&file).await {
Ok(loaded) => {
workflows.insert(loaded.file.ref_name.clone(), loaded);
}
Err(e) => {
warn!("Failed to load workflow '{}': {}", file.path.display(), e);
}
}
}
}
if workflows.is_empty() {
debug!("No workflows found in pack '{}'", pack_name);
}
Ok(workflows)
}
@@ -185,6 +202,10 @@ impl WorkflowLoader {
}
/// Reload a specific workflow by reference
///
/// Searches for the workflow file in both `workflows/` and
/// `actions/workflows/` directories, trying `.yaml`, `.yml`, and
/// `.workflow.yaml` extensions.
pub async fn reload_workflow(&self, ref_name: &str) -> Result<LoadedWorkflow> {
let parts: Vec<&str> = ref_name.split('.').collect();
if parts.len() != 2 {
@@ -198,36 +219,35 @@ impl WorkflowLoader {
let workflow_name = parts[1];
let pack_dir = self.config.packs_base_dir.join(pack_name);
let workflow_path = pack_dir
.join("workflows")
.join(format!("{}.yaml", workflow_name));
if !workflow_path.exists() {
// Try .yml extension
let workflow_path_yml = pack_dir
.join("workflows")
.join(format!("{}.yml", workflow_name));
if workflow_path_yml.exists() {
let file = WorkflowFile {
path: workflow_path_yml,
pack: pack_name.to_string(),
name: workflow_name.to_string(),
ref_name: ref_name.to_string(),
};
return self.load_workflow_file(&file).await;
// Candidate directories and filename patterns to search
let dirs = [
pack_dir.join("actions").join("workflows"),
pack_dir.join("workflows"),
];
let extensions = [
format!("{}.workflow.yaml", workflow_name),
format!("{}.yaml", workflow_name),
format!("{}.workflow.yml", workflow_name),
format!("{}.yml", workflow_name),
];
for dir in &dirs {
for filename in &extensions {
let candidate = dir.join(filename);
if candidate.exists() {
let file = WorkflowFile {
path: candidate,
pack: pack_name.to_string(),
name: workflow_name.to_string(),
ref_name: ref_name.to_string(),
};
return self.load_workflow_file(&file).await;
}
}
return Err(Error::not_found("workflow", "ref", ref_name));
}
let file = WorkflowFile {
path: workflow_path,
pack: pack_name.to_string(),
name: workflow_name.to_string(),
ref_name: ref_name.to_string(),
};
self.load_workflow_file(&file).await
Err(Error::not_found("workflow", "ref", ref_name))
}
/// Scan pack directories
@@ -259,6 +279,11 @@ impl WorkflowLoader {
}
/// Scan workflow files in a directory
///
/// Handles both `{name}.yaml` and `{name}.workflow.yaml` naming
/// conventions. For files with a `.workflow.yaml` suffix (produced by
/// the visual workflow builder), the `.workflow` portion is stripped
/// when deriving the workflow name and ref.
async fn scan_workflow_files(
&self,
workflows_dir: &Path,
@@ -278,7 +303,14 @@ impl WorkflowLoader {
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "yaml" || ext == "yml" {
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
if let Some(raw_stem) = path.file_stem().and_then(|n| n.to_str()) {
// Strip `.workflow` suffix if present:
// "deploy.workflow.yaml" -> stem "deploy.workflow" -> name "deploy"
// "deploy.yaml" -> stem "deploy" -> name "deploy"
let name = raw_stem
.strip_suffix(".workflow")
.unwrap_or(raw_stem);
let ref_name = format!("{}.{}", pack_name, name);
workflow_files.push(WorkflowFile {
path: path.clone(),
@@ -475,4 +507,161 @@ tasks:
.to_string()
.contains("exceeds maximum size"));
}
/// Verify that `scan_workflow_files` strips the `.workflow` suffix from
/// filenames like `deploy.workflow.yaml`, yielding name `deploy` and
/// ref `pack.deploy` instead of `pack.deploy.workflow`.
#[tokio::test]
async fn test_scan_workflow_files_strips_workflow_suffix() {
let temp_dir = TempDir::new().unwrap();
let packs_dir = temp_dir.path().to_path_buf();
let pack_dir = packs_dir.join("my_pack");
let workflows_dir = pack_dir.join("actions").join("workflows");
fs::create_dir_all(&workflows_dir).await.unwrap();
let workflow_yaml = r#"
ref: my_pack.deploy
label: Deploy
version: "1.0.0"
tasks:
- name: step1
action: core.noop
"#;
fs::write(workflows_dir.join("deploy.workflow.yaml"), workflow_yaml)
.await
.unwrap();
let config = LoaderConfig {
packs_base_dir: packs_dir,
skip_validation: true,
max_file_size: 1024 * 1024,
};
let loader = WorkflowLoader::new(config);
let files = loader
.scan_workflow_files(&workflows_dir, "my_pack")
.await
.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "deploy");
assert_eq!(files[0].ref_name, "my_pack.deploy");
}
/// Verify that `load_pack_workflows` discovers workflow files in both
/// `workflows/` (legacy) and `actions/workflows/` (visual builder)
/// directories, and that `actions/workflows/` wins on ref collision.
#[tokio::test]
async fn test_load_pack_workflows_scans_both_directories() {
let temp_dir = TempDir::new().unwrap();
let packs_dir = temp_dir.path().to_path_buf();
let pack_dir = packs_dir.join("dual_pack");
// Legacy directory: workflows/
let legacy_dir = pack_dir.join("workflows");
fs::create_dir_all(&legacy_dir).await.unwrap();
let legacy_yaml = r#"
ref: dual_pack.alpha
label: Alpha (legacy)
version: "1.0.0"
tasks:
- name: t1
action: core.noop
"#;
fs::write(legacy_dir.join("alpha.yaml"), legacy_yaml)
.await
.unwrap();
// Also put a workflow that only exists in the legacy dir
let beta_yaml = r#"
ref: dual_pack.beta
label: Beta
version: "1.0.0"
tasks:
- name: t1
action: core.noop
"#;
fs::write(legacy_dir.join("beta.yaml"), beta_yaml)
.await
.unwrap();
// Visual builder directory: actions/workflows/
let builder_dir = pack_dir.join("actions").join("workflows");
fs::create_dir_all(&builder_dir).await.unwrap();
let builder_yaml = r#"
ref: dual_pack.alpha
label: Alpha (builder)
version: "2.0.0"
tasks:
- name: t1
action: core.noop
"#;
fs::write(builder_dir.join("alpha.workflow.yaml"), builder_yaml)
.await
.unwrap();
let config = LoaderConfig {
packs_base_dir: packs_dir,
skip_validation: true,
max_file_size: 1024 * 1024,
};
let loader = WorkflowLoader::new(config);
let workflows = loader
.load_pack_workflows("dual_pack", &pack_dir)
.await
.unwrap();
// Both alpha and beta should be present
assert_eq!(workflows.len(), 2);
assert!(workflows.contains_key("dual_pack.alpha"));
assert!(workflows.contains_key("dual_pack.beta"));
// Alpha should come from actions/workflows/ (scanned second, overwrites)
let alpha = &workflows["dual_pack.alpha"];
assert_eq!(alpha.workflow.label, "Alpha (builder)");
assert_eq!(alpha.workflow.version, "2.0.0");
// Beta only exists in legacy dir
let beta = &workflows["dual_pack.beta"];
assert_eq!(beta.workflow.label, "Beta");
}
/// Verify that `reload_workflow` finds files in `actions/workflows/`
/// with the `.workflow.yaml` extension.
#[tokio::test]
async fn test_reload_workflow_finds_actions_workflows_dir() {
let temp_dir = TempDir::new().unwrap();
let packs_dir = temp_dir.path().to_path_buf();
let pack_dir = packs_dir.join("rp");
let builder_dir = pack_dir.join("actions").join("workflows");
fs::create_dir_all(&builder_dir).await.unwrap();
let yaml = r#"
ref: rp.deploy
label: Deploy
version: "1.0.0"
tasks:
- name: step1
action: core.noop
"#;
fs::write(builder_dir.join("deploy.workflow.yaml"), yaml)
.await
.unwrap();
let config = LoaderConfig {
packs_base_dir: packs_dir,
skip_validation: true,
max_file_size: 1024 * 1024,
};
let loader = WorkflowLoader::new(config);
let loaded = loader.reload_workflow("rp.deploy").await.unwrap();
assert_eq!(loaded.workflow.r#ref, "rp.deploy");
assert_eq!(loaded.file.name, "deploy");
assert_eq!(loaded.file.ref_name, "rp.deploy");
}
}

View File

@@ -78,14 +78,26 @@ impl From<ParseError> for crate::error::Error {
}
/// Complete workflow definition parsed from YAML
///
/// When loaded via an action's `workflow_file` field, the `ref` and `label`
/// fields are optional — the action YAML is authoritative for those values.
/// For standalone workflow files (in `workflows/`), they should be present.
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct WorkflowDefinition {
/// Unique reference (e.g., "my_pack.deploy_app")
#[validate(length(min = 1, max = 255))]
/// Unique reference (e.g., "my_pack.deploy_app").
///
/// Optional for action-linked workflow files (supplied by the action YAML).
/// Required for standalone workflow files.
#[serde(default)]
#[validate(length(max = 255))]
pub r#ref: String,
/// Human-readable label
#[validate(length(min = 1, max = 255))]
/// Human-readable label.
///
/// Optional for action-linked workflow files (supplied by the action YAML).
/// Required for standalone workflow files.
#[serde(default)]
#[validate(length(max = 255))]
pub label: String,
/// Optional description
@@ -412,11 +424,19 @@ pub enum TaskType {
}
/// Variable publishing directive
///
/// Publish directives map variable names to values. 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 so that, for example,
/// `validation_passed: true` publishes the boolean `true`, not the string
/// `"true"`.
#[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),
}
@@ -1315,4 +1335,175 @@ tasks:
assert!(workflow.tasks[0].next[0].chart_meta.is_none());
assert!(workflow.tasks[0].next[1].chart_meta.is_none());
}
// -----------------------------------------------------------------------
// Action-linked workflow file (no ref/label)
// -----------------------------------------------------------------------
#[test]
fn test_parse_action_linked_workflow_without_ref_and_label() {
// Action-linked workflow files (in actions/workflows/) omit ref and
// label — those are supplied by the companion action YAML. The
// parser must accept such files and default the fields to empty
// strings.
let yaml = r#"
version: 1.0.0
vars:
counter: 0
tasks:
- name: step1
action: core.echo
input:
message: "hello"
next:
- when: "{{ succeeded() }}"
do:
- step2
- name: step2
action: core.echo
input:
message: "world"
output_map:
result: "{{ task.step2.result }}"
"#;
let result = parse_workflow_yaml(yaml);
assert!(result.is_ok(), "Parse failed: {:?}", result.err());
let workflow = result.unwrap();
// ref and label default to empty strings
assert_eq!(workflow.r#ref, "");
assert_eq!(workflow.label, "");
// Graph fields are parsed normally
assert_eq!(workflow.version, "1.0.0");
assert_eq!(workflow.tasks.len(), 2);
assert_eq!(workflow.tasks[0].name, "step1");
assert!(workflow.vars.contains_key("counter"));
assert!(workflow.output_map.is_some());
// No parameters or output schema (those come from the action YAML)
assert!(workflow.parameters.is_none());
assert!(workflow.output.is_none());
assert!(workflow.tags.is_empty());
}
#[test]
fn test_parse_standalone_workflow_still_works_with_ref_and_label() {
// Standalone workflow files (in workflows/) still carry ref and label.
// Verify they continue to parse correctly.
let yaml = r#"
ref: mypack.deploy
label: Deploy Workflow
description: Deploys the application
version: 2.0.0
parameters:
target:
type: string
required: true
tags:
- deploy
- production
tasks:
- name: deploy
action: core.run
input:
target: "{{ parameters.target }}"
"#;
let result = parse_workflow_yaml(yaml);
assert!(result.is_ok(), "Parse failed: {:?}", result.err());
let workflow = result.unwrap();
assert_eq!(workflow.r#ref, "mypack.deploy");
assert_eq!(workflow.label, "Deploy Workflow");
assert_eq!(
workflow.description.as_deref(),
Some("Deploys the application")
);
assert_eq!(workflow.version, "2.0.0");
assert!(workflow.parameters.is_some());
assert_eq!(workflow.tags, vec!["deploy", "production"]);
}
#[test]
fn test_typed_publish_values_in_transitions() {
// Regression test: publish directive values that are booleans, numbers,
// or null must parse successfully (not just strings). Previously
// `PublishDirective::Simple(HashMap<String, String>)` rejected them.
let yaml = r#"
ref: test.typed_publish
label: Typed Publish
version: 1.0.0
tasks:
- name: validate
action: core.echo
next:
- when: "{{ succeeded() }}"
publish:
- validation_passed: true
- count: 42
- ratio: 3.14
- label: "hello"
- template_val: "{{ result().data }}"
- nothing: null
do:
- finalize
- when: "{{ failed() }}"
publish:
- validation_passed: false
do:
- handle_error
- name: finalize
action: core.echo
- name: handle_error
action: core.echo
"#;
let result = parse_workflow_yaml(yaml);
assert!(result.is_ok(), "Parse failed: {:?}", result.err());
let workflow = result.unwrap();
let task = &workflow.tasks[0];
assert_eq!(task.name, "validate");
assert_eq!(task.next.len(), 2);
// Success transition: 6 publish directives with mixed types
let success_transition = &task.next[0];
assert_eq!(success_transition.publish.len(), 6);
// Verify each typed value survived parsing
for directive in &success_transition.publish {
if let PublishDirective::Simple(map) = directive {
if let Some(val) = map.get("validation_passed") {
assert_eq!(val, &serde_json::Value::Bool(true), "boolean true");
} else if let Some(val) = map.get("count") {
assert_eq!(val, &serde_json::json!(42), "integer");
} else if let Some(val) = map.get("ratio") {
assert_eq!(val, &serde_json::json!(3.14), "float");
} else if let Some(val) = map.get("label") {
assert_eq!(val, &serde_json::json!("hello"), "string");
} else if let Some(val) = map.get("template_val") {
assert_eq!(val, &serde_json::json!("{{ result().data }}"), "template");
} else if let Some(val) = map.get("nothing") {
assert!(val.is_null(), "null");
}
}
}
// Failure transition: boolean false
let failure_transition = &task.next[1];
assert_eq!(failure_transition.publish.len(), 1);
if let PublishDirective::Simple(map) = &failure_transition.publish[0] {
assert_eq!(map.get("validation_passed"), Some(&serde_json::Value::Bool(false)));
} else {
panic!("Expected Simple publish directive");
}
}
}

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 crate::error::{Error, Result};
use crate::repositories::action::{ActionRepository, CreateActionInput, UpdateActionInput};
@@ -61,6 +66,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);
@@ -91,6 +122,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(
@@ -102,7 +139,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
@@ -112,6 +155,8 @@ impl WorkflowRegistrar {
pack.id,
&pack.r#ref,
&loaded.file.name,
&effective_ref,
&effective_label,
)
.await?;
@@ -119,7 +164,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
@@ -129,6 +181,8 @@ impl WorkflowRegistrar {
pack.id,
&pack.r#ref,
&loaded.file.name,
&effective_ref,
&effective_label,
)
.await?;
@@ -195,6 +249,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,
@@ -202,14 +259,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,
@@ -226,7 +285,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(())
@@ -236,6 +295,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,
@@ -243,6 +305,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?;
@@ -250,7 +314,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,
@@ -276,6 +340,8 @@ impl WorkflowRegistrar {
pack_id,
pack_ref,
workflow_name,
effective_ref,
effective_label,
)
.await?;
}
@@ -284,27 +350,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,
};
@@ -315,18 +386,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(),