Files
attune/crates/executor/src/workflow/registrar.rs
2026-03-04 22:02:34 -06:00

449 lines
15 KiB
Rust

//! 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<String>,
}
/// 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<RegistrationResult> {
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<String, LoadedWorkflow>,
) -> Result<Vec<RegistrationResult>> {
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<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: 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<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(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);
}
}