//! Workflow Registrar //! //! This module handles registering workflows as workflow definitions in the database. //! 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}; use attune_common::repositories::workflow::{ CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, }; use attune_common::repositories::{ Create, Delete, FindByRef, PackRepository, Update, WorkflowDefinitionRepository, }; use sqlx::PgPool; use std::collections::HashMap; use tracing::{debug, info, warn}; use super::loader::LoadedWorkflow; use super::parser::WorkflowDefinition as WorkflowYaml; /// Options for workflow registration #[derive(Debug, Clone)] pub struct RegistrationOptions { /// Whether to update existing workflows pub update_existing: bool, /// Whether to skip workflows with validation errors pub skip_invalid: bool, } impl Default for RegistrationOptions { fn default() -> Self { Self { update_existing: true, skip_invalid: true, } } } /// Result of workflow registration #[derive(Debug, Clone)] pub struct RegistrationResult { /// Workflow reference name pub ref_name: String, /// Whether the workflow was created (false = updated) pub created: bool, /// Workflow definition ID pub workflow_def_id: i64, /// Any warnings during registration pub warnings: Vec, } /// Workflow registrar for registering workflows in the database pub struct WorkflowRegistrar { pool: PgPool, options: RegistrationOptions, } impl WorkflowRegistrar { /// Create a new workflow registrar pub fn new(pool: PgPool, options: RegistrationOptions) -> Self { 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 { debug!("Registering workflow: {}", loaded.file.ref_name); // Check for validation errors if loaded.validation_error.is_some() { if self.options.skip_invalid { return Err(Error::validation(format!( "Workflow has validation errors: {}", loaded.validation_error.as_ref().unwrap() ))); } } // Verify pack exists let pack = PackRepository::find_by_ref(&self.pool, &loaded.file.pack) .await? .ok_or_else(|| Error::not_found("pack", "ref", &loaded.file.pack))?; // Check if workflow already exists let existing_workflow = WorkflowDefinitionRepository::find_by_ref(&self.pool, &loaded.file.ref_name).await?; let mut warnings = Vec::new(); // Add validation warning if present if let Some(ref err) = loaded.validation_error { 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( "workflow", "ref", &loaded.file.ref_name, )); } info!("Updating existing workflow: {}", loaded.file.ref_name); let workflow_def_id = self .update_workflow( &existing.id, &loaded.workflow, &pack.r#ref, &effective_ref, &effective_label, ) .await?; // Update or create the companion action record self.ensure_companion_action( workflow_def_id, &loaded.workflow, pack.id, &pack.r#ref, &loaded.file.name, &effective_ref, &effective_label, ) .await?; (workflow_def_id, false) } 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, &effective_ref, &effective_label, ) .await?; // Create a companion action record so the workflow appears in action lists self.create_companion_action( workflow_def_id, &loaded.workflow, pack.id, &pack.r#ref, &loaded.file.name, &effective_ref, &effective_label, ) .await?; (workflow_def_id, true) }; Ok(RegistrationResult { ref_name: loaded.file.ref_name.clone(), created, workflow_def_id, warnings, }) } /// Register multiple workflows pub async fn register_workflows( &self, workflows: &HashMap, ) -> Result> { let mut results = Vec::new(); let mut errors = Vec::new(); for (ref_name, loaded) in workflows { match self.register_workflow(loaded).await { Ok(result) => { info!("Registered workflow: {}", ref_name); results.push(result); } Err(e) => { warn!("Failed to register workflow '{}': {}", ref_name, e); errors.push(format!("{}: {}", ref_name, e)); } } } if !errors.is_empty() && results.is_empty() { return Err(Error::validation(format!( "Failed to register any workflows: {}", errors.join("; ") ))); } Ok(results) } /// Unregister a workflow by reference pub async fn unregister_workflow(&self, ref_name: &str) -> Result<()> { debug!("Unregistering workflow: {}", ref_name); let workflow = WorkflowDefinitionRepository::find_by_ref(&self.pool, ref_name) .await? .ok_or_else(|| Error::not_found("workflow", "ref", ref_name))?; // Delete workflow definition (cascades to workflow_execution, and the companion // action is cascade-deleted via the FK on action.workflow_def) WorkflowDefinitionRepository::delete(&self.pool, workflow.id).await?; info!("Unregistered workflow: {}", ref_name); Ok(()) } /// Create a companion action record for a workflow definition. /// /// 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, workflow: &WorkflowYaml, 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: effective_ref.to_string(), pack: pack_id, pack_ref: pack_ref.to_string(), label: effective_label.to_string(), description: workflow.description.clone().unwrap_or_default(), entrypoint, runtime: None, runtime_version_constraint: None, param_schema: workflow.parameters.clone(), out_schema: workflow.output.clone(), is_adhoc: false, }; let action = ActionRepository::create(&self.pool, action_input).await?; // Link the action to the workflow definition (sets workflow_def FK) ActionRepository::link_workflow_def(&self.pool, action.id, workflow_def_id).await?; info!( "Created companion action '{}' (ID: {}) for workflow definition (ID: {})", effective_ref, action.id, workflow_def_id ); Ok(()) } /// Ensure a companion action record exists for a workflow definition. /// /// 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, workflow: &WorkflowYaml, 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?; if let Some(action) = existing_action { // Update the existing companion action to stay in sync let update_input = UpdateActionInput { label: Some(effective_label.to_string()), description: workflow.description.clone(), entrypoint: Some(format!("workflows/{}.workflow.yaml", workflow_name)), runtime: None, runtime_version_constraint: None, param_schema: workflow.parameters.clone(), out_schema: workflow.output.clone(), parameter_delivery: None, parameter_format: None, output_format: None, }; ActionRepository::update(&self.pool, action.id, update_input).await?; debug!( "Updated companion action '{}' (ID: {}) for workflow definition (ID: {})", action.r#ref, action.id, workflow_def_id ); } else { // Backfill: create companion action for pre-fix workflows self.create_companion_action( workflow_def_id, workflow, pack_id, pack_ref, workflow_name, effective_ref, effective_label, ) .await?; } Ok(()) } /// 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 { // 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: effective_ref.to_string(), pack: pack_id, pack_ref: pack_ref.to_string(), label: effective_label.to_string(), description: workflow.description.clone(), version: workflow.version.clone(), param_schema: workflow.parameters.clone(), out_schema: workflow.output.clone(), definition, tags: workflow.tags.clone(), enabled: true, }; let created = WorkflowDefinitionRepository::create(&self.pool, input).await?; Ok(created.id) } /// 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 { // 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(effective_label.to_string()), description: workflow.description.clone(), version: Some(workflow.version.clone()), param_schema: workflow.parameters.clone(), out_schema: workflow.output.clone(), definition: Some(definition), tags: Some(workflow.tags.clone()), enabled: Some(true), }; let updated = WorkflowDefinitionRepository::update(&self.pool, *workflow_id, input).await?; Ok(updated.id) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_registration_options_default() { let options = RegistrationOptions::default(); assert_eq!(options.update_existing, true); assert_eq!(options.skip_invalid, true); } #[test] fn test_registration_result_creation() { let result = RegistrationResult { ref_name: "test.workflow".to_string(), created: true, workflow_def_id: 123, warnings: vec![], }; assert_eq!(result.ref_name, "test.workflow"); assert_eq!(result.created, true); assert_eq!(result.workflow_def_id, 123); assert_eq!(result.warnings.len(), 0); } }