working on workflows
This commit is contained in:
@@ -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>,
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user