[WIP] workflow builder

This commit is contained in:
2026-02-23 20:45:10 -06:00
parent d629da32fa
commit 53a3fbb6b1
66 changed files with 7887 additions and 1608 deletions

View File

@@ -38,14 +38,14 @@ pub struct CreateActionRequest {
#[schema(example = 1)]
pub runtime: Option<i64>,
/// Parameter schema (JSON Schema) defining expected inputs
/// Parameter schema (StackStorm-style) defining expected inputs with inline required/secret
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object, nullable = true, example = json!({"type": "object", "properties": {"channel": {"type": "string"}, "message": {"type": "string"}}}))]
#[schema(value_type = Object, nullable = true, example = json!({"channel": {"type": "string", "description": "Slack channel", "required": true}, "message": {"type": "string", "description": "Message text", "required": true}}))]
pub param_schema: Option<JsonValue>,
/// Output schema (JSON Schema) defining expected outputs
/// Output schema (flat format) defining expected outputs with inline required/secret
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object, nullable = true, example = json!({"type": "object", "properties": {"message_id": {"type": "string"}}}))]
#[schema(value_type = Object, nullable = true, example = json!({"message_id": {"type": "string", "description": "ID of the sent message", "required": true}}))]
pub out_schema: Option<JsonValue>,
}
@@ -71,7 +71,7 @@ pub struct UpdateActionRequest {
#[schema(example = 1)]
pub runtime: Option<i64>,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
@@ -115,7 +115,7 @@ pub struct ActionResponse {
#[schema(example = 1)]
pub runtime: Option<i64>,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,

View File

@@ -137,8 +137,8 @@ pub struct CreateInquiryRequest {
#[schema(example = "Approve deployment to production?")]
pub prompt: String,
/// Optional JSON schema for the expected response format
#[schema(value_type = Object, example = json!({"type": "object", "properties": {"approved": {"type": "boolean"}}}))]
/// Optional schema for the expected response format (flat format with inline required/secret)
#[schema(value_type = Object, example = json!({"approved": {"type": "boolean", "description": "Whether the deployment is approved", "required": true}}))]
pub response_schema: Option<JsonSchema>,
/// Optional identity ID to assign this inquiry to

View File

@@ -28,9 +28,9 @@ pub struct CreatePackRequest {
#[schema(example = "1.0.0")]
pub version: String,
/// Configuration schema (JSON Schema)
/// Configuration schema (flat format with inline required/secret per parameter)
#[serde(default = "default_empty_object")]
#[schema(value_type = Object, example = json!({"type": "object", "properties": {"api_token": {"type": "string"}}}))]
#[schema(value_type = Object, example = json!({"api_token": {"type": "string", "description": "API authentication key", "required": true, "secret": true}}))]
pub conf_schema: JsonValue,
/// Pack configuration values
@@ -95,11 +95,6 @@ pub struct InstallPackRequest {
#[schema(example = "main")]
pub ref_spec: Option<String>,
/// Force reinstall if pack already exists
#[serde(default)]
#[schema(example = false)]
pub force: bool,
/// Skip running pack tests during installation
#[serde(default)]
#[schema(example = false)]

View File

@@ -28,14 +28,14 @@ pub struct CreateTriggerRequest {
#[schema(example = "Triggers when a webhook is received")]
pub description: Option<String>,
/// Parameter schema (JSON Schema) defining event payload structure
/// Parameter schema (StackStorm-style) defining trigger configuration with inline required/secret
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object, nullable = true, example = json!({"type": "object", "properties": {"url": {"type": "string"}}}))]
#[schema(value_type = Object, nullable = true, example = json!({"url": {"type": "string", "description": "Webhook URL", "required": true}}))]
pub param_schema: Option<JsonValue>,
/// Output schema (JSON Schema) defining event data structure
/// Output schema (flat format) defining event data structure with inline required/secret
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object, nullable = true, example = json!({"type": "object", "properties": {"payload": {"type": "object"}}}))]
#[schema(value_type = Object, nullable = true, example = json!({"payload": {"type": "object", "description": "Event payload data", "required": true}}))]
pub out_schema: Option<JsonValue>,
/// Whether the trigger is enabled
@@ -56,7 +56,7 @@ pub struct UpdateTriggerRequest {
#[schema(example = "Updated webhook trigger description")]
pub description: Option<String>,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
@@ -100,7 +100,7 @@ pub struct TriggerResponse {
#[schema(example = true)]
pub enabled: bool,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
@@ -208,9 +208,9 @@ pub struct CreateSensorRequest {
#[schema(example = "monitoring.cpu_threshold")]
pub trigger_ref: String,
/// Parameter schema (JSON Schema) for sensor configuration
/// Parameter schema (flat format) for sensor configuration
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object, nullable = true, example = json!({"type": "object", "properties": {"threshold": {"type": "number"}}}))]
#[schema(value_type = Object, nullable = true, example = json!({"threshold": {"type": "number", "description": "Alert threshold", "required": true}}))]
pub param_schema: Option<JsonValue>,
/// Configuration values for this sensor instance (conforms to param_schema)
@@ -242,7 +242,7 @@ pub struct UpdateSensorRequest {
#[schema(example = "/sensors/monitoring/cpu_monitor_v2.py")]
pub entrypoint: Option<String>,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
@@ -302,7 +302,7 @@ pub struct SensorResponse {
#[schema(example = true)]
pub enabled: bool,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,

View File

@@ -6,6 +6,54 @@ use serde_json::Value as JsonValue;
use utoipa::{IntoParams, ToSchema};
use validator::Validate;
/// Request DTO for saving a workflow file to disk and syncing to DB
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct SaveWorkflowFileRequest {
/// Workflow name (becomes filename: {name}.workflow.yaml)
#[validate(length(min = 1, max = 255))]
#[schema(example = "deploy_app")]
pub name: String,
/// Human-readable label
#[validate(length(min = 1, max = 255))]
#[schema(example = "Deploy Application")]
pub label: String,
/// Workflow description
#[schema(example = "Deploys an application to the target environment")]
pub description: Option<String>,
/// Workflow version (semantic versioning recommended)
#[validate(length(min = 1, max = 50))]
#[schema(example = "1.0.0")]
pub version: String,
/// Pack reference this workflow belongs to
#[validate(length(min = 1, max = 255))]
#[schema(example = "core")]
pub pack_ref: String,
/// The full workflow definition as JSON (will be serialized to YAML on disk)
#[schema(value_type = Object)]
pub definition: JsonValue,
/// Parameter schema (flat format with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
/// Output schema (flat format)
#[schema(value_type = Object, nullable = true)]
pub out_schema: Option<JsonValue>,
/// Tags for categorization
#[schema(example = json!(["deployment", "automation"]))]
pub tags: Option<Vec<String>>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: Option<bool>,
}
/// Request DTO for creating a new workflow
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreateWorkflowRequest {
@@ -33,12 +81,12 @@ pub struct CreateWorkflowRequest {
#[schema(example = "1.0.0")]
pub version: String,
/// Parameter schema (JSON Schema) defining expected inputs
#[schema(value_type = Object, example = json!({"type": "object", "properties": {"severity": {"type": "string"}, "channel": {"type": "string"}}}))]
/// Parameter schema (StackStorm-style) defining expected inputs with inline required/secret
#[schema(value_type = Object, example = json!({"severity": {"type": "string", "description": "Incident severity", "required": true}, "channel": {"type": "string", "description": "Notification channel"}}))]
pub param_schema: Option<JsonValue>,
/// Output schema (JSON Schema) defining expected outputs
#[schema(value_type = Object, example = json!({"type": "object", "properties": {"incident_id": {"type": "string"}}}))]
/// Output schema (flat format) defining expected outputs with inline required/secret
#[schema(value_type = Object, example = json!({"incident_id": {"type": "string", "description": "Unique incident identifier", "required": true}}))]
pub out_schema: Option<JsonValue>,
/// Workflow definition (complete workflow YAML structure as JSON)
@@ -71,7 +119,7 @@ pub struct UpdateWorkflowRequest {
#[schema(example = "1.1.0")]
pub version: Option<String>,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
@@ -123,7 +171,7 @@ pub struct WorkflowResponse {
#[schema(example = "1.0.0")]
pub version: String,
/// Parameter schema
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,

View File

@@ -40,7 +40,9 @@ use crate::{
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct CreateEventRequest {
/// Trigger reference (e.g., "core.timer", "core.webhook")
/// Also accepts "trigger_type" for compatibility with the sensor interface spec.
#[validate(length(min = 1))]
#[serde(alias = "trigger_type")]
#[schema(example = "core.timer")]
pub trigger_ref: String,

View File

@@ -10,9 +10,13 @@ use axum::{
use std::sync::Arc;
use validator::Validate;
use attune_common::models::OwnerType;
use attune_common::repositories::{
action::ActionRepository,
key::{CreateKeyInput, KeyRepository, UpdateKeyInput},
Create, Delete, List, Update,
pack::PackRepository,
trigger::SensorRepository,
Create, Delete, FindByRef, List, Update,
};
use crate::auth::RequireAuth;
@@ -157,6 +161,78 @@ pub async fn create_key(
)));
}
// Auto-resolve owner IDs from refs when only the ref is provided.
// This makes the API more ergonomic for sensors and other clients that
// know the owner ref but not the numeric database ID.
let mut owner_sensor = request.owner_sensor;
let mut owner_action = request.owner_action;
let mut owner_pack = request.owner_pack;
match request.owner_type {
OwnerType::Sensor => {
if owner_sensor.is_none() {
if let Some(ref sensor_ref) = request.owner_sensor_ref {
if let Some(sensor) =
SensorRepository::find_by_ref(&state.db, sensor_ref).await?
{
tracing::debug!(
"Auto-resolved owner_sensor from ref '{}' to id {}",
sensor_ref,
sensor.id
);
owner_sensor = Some(sensor.id);
} else {
return Err(ApiError::BadRequest(format!(
"Sensor with ref '{}' not found",
sensor_ref
)));
}
}
}
}
OwnerType::Action => {
if owner_action.is_none() {
if let Some(ref action_ref) = request.owner_action_ref {
if let Some(action) =
ActionRepository::find_by_ref(&state.db, action_ref).await?
{
tracing::debug!(
"Auto-resolved owner_action from ref '{}' to id {}",
action_ref,
action.id
);
owner_action = Some(action.id);
} else {
return Err(ApiError::BadRequest(format!(
"Action with ref '{}' not found",
action_ref
)));
}
}
}
}
OwnerType::Pack => {
if owner_pack.is_none() {
if let Some(ref pack_ref) = request.owner_pack_ref {
if let Some(pack) = PackRepository::find_by_ref(&state.db, pack_ref).await? {
tracing::debug!(
"Auto-resolved owner_pack from ref '{}' to id {}",
pack_ref,
pack.id
);
owner_pack = Some(pack.id);
} else {
return Err(ApiError::BadRequest(format!(
"Pack with ref '{}' not found",
pack_ref
)));
}
}
}
}
_ => {}
}
// Encrypt value if requested
let (value, encryption_key_hash) = if request.encrypted {
let encryption_key = state
@@ -190,11 +266,11 @@ pub async fn create_key(
owner_type: request.owner_type,
owner: request.owner,
owner_identity: request.owner_identity,
owner_pack: request.owner_pack,
owner_pack,
owner_pack_ref: request.owner_pack_ref,
owner_action: request.owner_action,
owner_action,
owner_action_ref: request.owner_action_ref,
owner_sensor: request.owner_sensor,
owner_sensor,
owner_sensor_ref: request.owner_sensor_ref,
name: request.name,
encrypted: request.encrypted,

View File

@@ -14,7 +14,10 @@ use validator::Validate;
use attune_common::models::pack_test::PackTestResult;
use attune_common::mq::{MessageEnvelope, MessageType, PackRegisteredPayload};
use attune_common::repositories::{
action::ActionRepository,
pack::{CreatePackInput, UpdatePackInput},
rule::{RestoreRuleInput, RuleRepository},
trigger::TriggerRepository,
Create, Delete, FindById, FindByRef, PackRepository, PackTestRepository, Pagination, Update,
};
use attune_common::workflow::{PackWorkflowService, PackWorkflowServiceConfig};
@@ -545,6 +548,9 @@ async fn register_pack_internal(
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Ad-hoc rules to restore after pack reinstallation
let mut saved_adhoc_rules: Vec<attune_common::models::rule::Rule> = Vec::new();
// Check if pack already exists
if !force {
if PackRepository::exists_by_ref(&state.db, &pack_ref).await? {
@@ -554,8 +560,20 @@ async fn register_pack_internal(
)));
}
} else {
// Delete existing pack if force is true
// Delete existing pack if force is true, preserving ad-hoc (user-created) rules
if let Some(existing_pack) = PackRepository::find_by_ref(&state.db, &pack_ref).await? {
// Save ad-hoc rules before deletion — CASCADE on pack FK would destroy them
saved_adhoc_rules = RuleRepository::find_adhoc_by_pack(&state.db, existing_pack.id)
.await
.unwrap_or_default();
if !saved_adhoc_rules.is_empty() {
tracing::info!(
"Preserving {} ad-hoc rule(s) during reinstall of pack '{}'",
saved_adhoc_rules.len(),
pack_ref
);
}
PackRepository::delete(&state.db, existing_pack.id).await?;
tracing::info!("Deleted existing pack '{}' for forced reinstall", pack_ref);
}
@@ -671,6 +689,123 @@ async fn register_pack_internal(
}
}
// Restore ad-hoc rules that were saved before pack deletion, and
// re-link any rules from other packs whose action/trigger FKs were
// set to NULL when the old pack's entities were cascade-deleted.
{
// Phase 1: Restore saved ad-hoc rules
if !saved_adhoc_rules.is_empty() {
let mut restored = 0u32;
for saved_rule in &saved_adhoc_rules {
// Resolve action and trigger IDs by ref (they may have been recreated)
let action_id = ActionRepository::find_by_ref(&state.db, &saved_rule.action_ref)
.await
.ok()
.flatten()
.map(|a| a.id);
let trigger_id = TriggerRepository::find_by_ref(&state.db, &saved_rule.trigger_ref)
.await
.ok()
.flatten()
.map(|t| t.id);
let input = RestoreRuleInput {
r#ref: saved_rule.r#ref.clone(),
pack: pack.id,
pack_ref: pack.r#ref.clone(),
label: saved_rule.label.clone(),
description: saved_rule.description.clone(),
action: action_id,
action_ref: saved_rule.action_ref.clone(),
trigger: trigger_id,
trigger_ref: saved_rule.trigger_ref.clone(),
conditions: saved_rule.conditions.clone(),
action_params: saved_rule.action_params.clone(),
trigger_params: saved_rule.trigger_params.clone(),
enabled: saved_rule.enabled,
};
match RuleRepository::restore_rule(&state.db, input).await {
Ok(rule) => {
restored += 1;
if rule.action.is_none() || rule.trigger.is_none() {
tracing::warn!(
"Restored ad-hoc rule '{}' with unresolved references \
(action: {}, trigger: {})",
rule.r#ref,
if rule.action.is_some() {
"linked"
} else {
"NULL"
},
if rule.trigger.is_some() {
"linked"
} else {
"NULL"
},
);
}
}
Err(e) => {
tracing::warn!(
"Failed to restore ad-hoc rule '{}': {}",
saved_rule.r#ref,
e
);
}
}
}
tracing::info!(
"Restored {}/{} ad-hoc rule(s) for pack '{}'",
restored,
saved_adhoc_rules.len(),
pack.r#ref
);
}
// Phase 2: Re-link rules from other packs whose action/trigger FKs
// were set to NULL when the old pack's entities were cascade-deleted
let new_actions = ActionRepository::find_by_pack(&state.db, pack.id)
.await
.unwrap_or_default();
let new_triggers = TriggerRepository::find_by_pack(&state.db, pack.id)
.await
.unwrap_or_default();
for action in &new_actions {
match RuleRepository::relink_action_by_ref(&state.db, &action.r#ref, action.id).await {
Ok(count) if count > 0 => {
tracing::info!("Re-linked {} rule(s) to action '{}'", count, action.r#ref);
}
Err(e) => {
tracing::warn!(
"Failed to re-link rules to action '{}': {}",
action.r#ref,
e
);
}
_ => {}
}
}
for trigger in &new_triggers {
match RuleRepository::relink_trigger_by_ref(&state.db, &trigger.r#ref, trigger.id).await
{
Ok(count) if count > 0 => {
tracing::info!("Re-linked {} rule(s) to trigger '{}'", count, trigger.r#ref);
}
Err(e) => {
tracing::warn!(
"Failed to re-link rules to trigger '{}': {}",
trigger.r#ref,
e
);
}
_ => {}
}
}
}
// Set up runtime environments for the pack's actions.
// This creates virtualenvs, installs dependencies, etc. based on each
// runtime's execution_config from the database.
@@ -964,7 +1099,6 @@ async fn register_pack_internal(
responses(
(status = 201, description = "Pack installed successfully", body = ApiResponse<PackInstallResponse>),
(status = 400, description = "Invalid request or tests failed", body = ApiResponse<String>),
(status = 409, description = "Pack already exists", body = ApiResponse<String>),
(status = 501, description = "Not implemented yet", body = ApiResponse<String>),
),
security(("bearer_auth" = []))
@@ -1122,12 +1256,14 @@ pub async fn install_pack(
tracing::info!("Pack moved to permanent storage: {:?}", final_path);
// Register the pack in database (from permanent storage location)
// Register the pack in database (from permanent storage location).
// Remote installs always force-overwrite: if you're pulling from a remote,
// the intent is to get that pack installed regardless of local state.
let pack_id = register_pack_internal(
state.clone(),
user_sub,
final_path.to_string_lossy().to_string(),
request.force,
true, // always force for remote installs
request.skip_tests,
)
.await

View File

@@ -4,9 +4,10 @@ use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
routing::{get, post, put},
Json, Router,
};
use std::path::PathBuf;
use std::sync::Arc;
use validator::Validate;
@@ -23,8 +24,8 @@ use crate::{
dto::{
common::{PaginatedResponse, PaginationParams},
workflow::{
CreateWorkflowRequest, UpdateWorkflowRequest, WorkflowResponse, WorkflowSearchParams,
WorkflowSummary,
CreateWorkflowRequest, SaveWorkflowFileRequest, UpdateWorkflowRequest,
WorkflowResponse, WorkflowSearchParams, WorkflowSummary,
},
ApiResponse, SuccessResponse,
},
@@ -340,6 +341,202 @@ pub async fn delete_workflow(
Ok((StatusCode::OK, Json(response)))
}
/// Save a workflow file to disk and sync it to the database
///
/// Writes a `{name}.workflow.yaml` file to `{packs_base_dir}/{pack_ref}/actions/workflows/`
/// and creates or updates the corresponding workflow_definition record in the database.
#[utoipa::path(
post,
path = "/api/v1/packs/{pack_ref}/workflow-files",
tag = "workflows",
params(
("pack_ref" = String, Path, description = "Pack reference identifier")
),
request_body = SaveWorkflowFileRequest,
responses(
(status = 201, description = "Workflow file saved and synced", body = inline(ApiResponse<WorkflowResponse>)),
(status = 400, description = "Validation error"),
(status = 404, description = "Pack not found"),
(status = 409, description = "Workflow with same ref already exists"),
(status = 500, description = "Failed to write workflow file")
),
security(("bearer_auth" = []))
)]
pub async fn save_workflow_file(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(pack_ref): Path<String>,
Json(request): Json<SaveWorkflowFileRequest>,
) -> ApiResult<impl IntoResponse> {
request.validate()?;
// Verify pack exists
let pack = PackRepository::find_by_ref(&state.db, &pack_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
let workflow_ref = format!("{}.{}", pack_ref, request.name);
// Check if workflow already exists
if WorkflowDefinitionRepository::find_by_ref(&state.db, &workflow_ref)
.await?
.is_some()
{
return Err(ApiError::Conflict(format!(
"Workflow with ref '{}' already exists",
workflow_ref
)));
}
// Write YAML file to disk
let packs_base_dir = PathBuf::from(&state.config.packs_base_dir);
write_workflow_yaml(&packs_base_dir, &pack_ref, &request).await?;
// Create workflow in database
let definition_json = serde_json::to_value(&request.definition).map_err(|e| {
ApiError::BadRequest(format!("Failed to serialize workflow definition: {}", e))
})?;
let workflow_input = CreateWorkflowDefinitionInput {
r#ref: workflow_ref,
pack: pack.id,
pack_ref: pack.r#ref.clone(),
label: request.label,
description: request.description,
version: request.version,
param_schema: request.param_schema,
out_schema: request.out_schema,
definition: definition_json,
tags: request.tags.unwrap_or_default(),
enabled: request.enabled.unwrap_or(true),
};
let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?;
let response = ApiResponse::with_message(
WorkflowResponse::from(workflow),
"Workflow file saved and synced successfully",
);
Ok((StatusCode::CREATED, Json(response)))
}
/// Update a workflow file on disk and sync changes to the database
#[utoipa::path(
put,
path = "/api/v1/workflows/{ref}/file",
tag = "workflows",
params(
("ref" = String, Path, description = "Workflow reference identifier")
),
request_body = SaveWorkflowFileRequest,
responses(
(status = 200, description = "Workflow file updated and synced", body = inline(ApiResponse<WorkflowResponse>)),
(status = 400, description = "Validation error"),
(status = 404, description = "Workflow not found"),
(status = 500, description = "Failed to write workflow file")
),
security(("bearer_auth" = []))
)]
pub async fn update_workflow_file(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(workflow_ref): Path<String>,
Json(request): Json<SaveWorkflowFileRequest>,
) -> ApiResult<impl IntoResponse> {
request.validate()?;
// Check if workflow exists
let existing_workflow = WorkflowDefinitionRepository::find_by_ref(&state.db, &workflow_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Workflow '{}' not found", workflow_ref)))?;
// Verify pack exists
let _pack = PackRepository::find_by_ref(&state.db, &request.pack_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", request.pack_ref)))?;
// Write updated YAML file to disk
let packs_base_dir = PathBuf::from(&state.config.packs_base_dir);
write_workflow_yaml(&packs_base_dir, &request.pack_ref, &request).await?;
// Update workflow in database
let definition_json = serde_json::to_value(&request.definition).map_err(|e| {
ApiError::BadRequest(format!("Failed to serialize workflow definition: {}", e))
})?;
let update_input = UpdateWorkflowDefinitionInput {
label: Some(request.label),
description: request.description,
version: Some(request.version),
param_schema: request.param_schema,
out_schema: request.out_schema,
definition: Some(definition_json),
tags: request.tags,
enabled: request.enabled,
};
let workflow =
WorkflowDefinitionRepository::update(&state.db, existing_workflow.id, update_input).await?;
let response = ApiResponse::with_message(
WorkflowResponse::from(workflow),
"Workflow file updated and synced successfully",
);
Ok((StatusCode::OK, Json(response)))
}
/// Write a workflow definition to disk as YAML
async fn write_workflow_yaml(
packs_base_dir: &PathBuf,
pack_ref: &str,
request: &SaveWorkflowFileRequest,
) -> Result<(), ApiError> {
let workflows_dir = packs_base_dir
.join(pack_ref)
.join("actions")
.join("workflows");
// Ensure the directory exists
tokio::fs::create_dir_all(&workflows_dir)
.await
.map_err(|e| {
ApiError::InternalServerError(format!(
"Failed to create workflows directory '{}': {}",
workflows_dir.display(),
e
))
})?;
let filename = format!("{}.workflow.yaml", request.name);
let filepath = workflows_dir.join(&filename);
// Serialize definition to YAML
let yaml_content = serde_yaml_ng::to_string(&request.definition).map_err(|e| {
ApiError::BadRequest(format!("Failed to serialize workflow to YAML: {}", e))
})?;
// Write file
tokio::fs::write(&filepath, yaml_content)
.await
.map_err(|e| {
ApiError::InternalServerError(format!(
"Failed to write workflow file '{}': {}",
filepath.display(),
e
))
})?;
tracing::info!(
"Wrote workflow file: {} ({} bytes)",
filepath.display(),
filepath.metadata().map(|m| m.len()).unwrap_or(0)
);
Ok(())
}
/// Create workflow routes
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
@@ -350,7 +547,9 @@ pub fn routes() -> Router<Arc<AppState>> {
.put(update_workflow)
.delete(delete_workflow),
)
.route("/workflows/{ref}/file", put(update_workflow_file))
.route("/packs/{pack_ref}/workflows", get(list_workflows_by_pack))
.route("/packs/{pack_ref}/workflow-files", post(save_workflow_file))
}
#[cfg(test)]
@@ -362,4 +561,43 @@ mod tests {
// Just verify the router can be constructed
let _router = routes();
}
#[test]
fn test_save_request_validation() {
let req = SaveWorkflowFileRequest {
name: "test_workflow".to_string(),
label: "Test Workflow".to_string(),
description: Some("A test workflow".to_string()),
version: "1.0.0".to_string(),
pack_ref: "core".to_string(),
definition: serde_json::json!({
"ref": "core.test_workflow",
"label": "Test Workflow",
"version": "1.0.0",
"tasks": [{"name": "task1", "action": "core.echo"}]
}),
param_schema: None,
out_schema: None,
tags: None,
enabled: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn test_save_request_validation_empty_name() {
let req = SaveWorkflowFileRequest {
name: "".to_string(), // Invalid: empty
label: "Test".to_string(),
description: None,
version: "1.0.0".to_string(),
pack_ref: "core".to_string(),
definition: serde_json::json!({}),
param_schema: None,
out_schema: None,
tags: None,
enabled: None,
};
assert!(req.validate().is_err());
}
}

View File

@@ -1,9 +1,14 @@
//! Parameter validation module
//!
//! Validates trigger and action parameters against their declared JSON schemas.
//! Template-aware: values containing `{{ }}` template expressions are replaced
//! with schema-appropriate placeholders before validation, so template expressions
//! pass type checks while literal values are still validated normally.
//! Validates trigger and action parameters against their declared schemas.
//! Schemas use the flat StackStorm-style format:
//! { "param_name": { "type": "string", "required": true, "secret": true, ... }, ... }
//!
//! Before validation, flat schemas are converted to standard JSON Schema so we
//! can reuse the `jsonschema` crate. Template-aware: values containing `{{ }}`
//! template expressions are replaced with schema-appropriate placeholders before
//! validation, so template expressions pass type checks while literal values are
//! still validated normally.
use attune_common::models::{action::Action, trigger::Trigger};
use jsonschema::Validator;
@@ -11,6 +16,68 @@ use serde_json::Value;
use crate::middleware::ApiError;
/// Convert a flat StackStorm-style parameter schema into a standard JSON Schema
/// object suitable for `jsonschema::Validator`.
///
/// Input (flat):
/// ```json
/// { "url": { "type": "string", "required": true }, "timeout": { "type": "integer", "default": 30 } }
/// ```
///
/// Output (JSON Schema):
/// ```json
/// { "type": "object", "properties": { "url": { "type": "string" }, "timeout": { "type": "integer", "default": 30 } }, "required": ["url"] }
/// ```
fn flat_to_json_schema(flat: &Value) -> Value {
let Some(map) = flat.as_object() else {
// Not an object — return a permissive schema
return serde_json::json!({});
};
// If it already looks like a JSON Schema (has "type": "object" + "properties"),
// pass it through unchanged for backward tolerance.
if map.get("type").and_then(|v| v.as_str()) == Some("object") && map.contains_key("properties")
{
return flat.clone();
}
let mut properties = serde_json::Map::new();
let mut required: Vec<Value> = Vec::new();
for (key, prop_def) in map {
let Some(prop_obj) = prop_def.as_object() else {
// Skip non-object entries (shouldn't happen in valid schemas)
continue;
};
// Clone the property definition, stripping `required` and `secret`
// (they are not valid JSON Schema keywords).
let mut clean = prop_obj.clone();
let is_required = clean
.remove("required")
.and_then(|v| v.as_bool())
.unwrap_or(false);
clean.remove("secret");
// `position` is also an Attune extension, not JSON Schema
clean.remove("position");
if is_required {
required.push(Value::String(key.clone()));
}
properties.insert(key.clone(), Value::Object(clean));
}
let mut schema = serde_json::Map::new();
schema.insert("type".to_string(), Value::String("object".to_string()));
schema.insert("properties".to_string(), Value::Object(properties));
if !required.is_empty() {
schema.insert("required".to_string(), Value::Array(required));
}
Value::Object(schema)
}
/// Check if a JSON value is (or contains) a template expression.
fn is_template_expression(value: &Value) -> bool {
match value {
@@ -100,7 +167,8 @@ fn placeholder_for_schema(property_schema: &Value) -> Value {
/// schema-appropriate placeholders. Only replaces leaf values that match
/// `{{ ... }}`; non-template values are left untouched for normal validation.
///
/// `schema` should be the full JSON Schema object (with `properties`, `type`, etc).
/// `schema` must be a standard JSON Schema object (with `properties`, `type`, etc).
/// Call `flat_to_json_schema` first if starting from flat format.
fn replace_templates_with_placeholders(params: &Value, schema: &Value) -> Value {
match params {
Value::Object(map) => {
@@ -164,17 +232,23 @@ fn replace_templates_with_placeholders(params: &Value, schema: &Value) -> Value
/// Validate trigger parameters against the trigger's parameter schema.
/// Template expressions (`{{ ... }}`) are accepted for any field type.
///
/// The schema is expected in flat StackStorm format and is converted to
/// JSON Schema internally for validation.
pub fn validate_trigger_params(trigger: &Trigger, params: &Value) -> Result<(), ApiError> {
// If no schema is defined, accept any parameters
let Some(schema) = &trigger.param_schema else {
let Some(flat_schema) = &trigger.param_schema else {
return Ok(());
};
// Convert flat format to JSON Schema for validation
let schema = flat_to_json_schema(flat_schema);
// Replace template expressions with schema-appropriate placeholders
let sanitized = replace_templates_with_placeholders(params, schema);
let sanitized = replace_templates_with_placeholders(params, &schema);
// Compile the JSON schema
let compiled_schema = Validator::new(schema).map_err(|e| {
let compiled_schema = Validator::new(&schema).map_err(|e| {
ApiError::InternalServerError(format!(
"Invalid parameter schema for trigger '{}': {}",
trigger.r#ref, e
@@ -207,17 +281,23 @@ pub fn validate_trigger_params(trigger: &Trigger, params: &Value) -> Result<(),
/// Validate action parameters against the action's parameter schema.
/// Template expressions (`{{ ... }}`) are accepted for any field type.
///
/// The schema is expected in flat StackStorm format and is converted to
/// JSON Schema internally for validation.
pub fn validate_action_params(action: &Action, params: &Value) -> Result<(), ApiError> {
// If no schema is defined, accept any parameters
let Some(schema) = &action.param_schema else {
let Some(flat_schema) = &action.param_schema else {
return Ok(());
};
// Convert flat format to JSON Schema for validation
let schema = flat_to_json_schema(flat_schema);
// Replace template expressions with schema-appropriate placeholders
let sanitized = replace_templates_with_placeholders(params, schema);
let sanitized = replace_templates_with_placeholders(params, &schema);
// Compile the JSON schema
let compiled_schema = Validator::new(schema).map_err(|e| {
let compiled_schema = Validator::new(&schema).map_err(|e| {
ApiError::InternalServerError(format!(
"Invalid parameter schema for action '{}': {}",
action.r#ref, e
@@ -309,15 +389,65 @@ mod tests {
// ── Basic trigger validation (no templates) ──────────────────────
// ── flat_to_json_schema unit tests ───────────────────────────────
#[test]
fn test_flat_to_json_schema_basic() {
let flat = json!({
"url": { "type": "string", "required": true },
"timeout": { "type": "integer", "default": 30 }
});
let result = flat_to_json_schema(&flat);
assert_eq!(result["type"], "object");
assert_eq!(result["properties"]["url"]["type"], "string");
// `required` should be stripped from individual properties
assert!(result["properties"]["url"].get("required").is_none());
assert_eq!(result["properties"]["timeout"]["default"], 30);
// Top-level required array should contain "url"
let req = result["required"].as_array().unwrap();
assert!(req.contains(&json!("url")));
assert!(!req.contains(&json!("timeout")));
}
#[test]
fn test_flat_to_json_schema_strips_secret_and_position() {
let flat = json!({
"token": { "type": "string", "secret": true, "position": 0, "required": true }
});
let result = flat_to_json_schema(&flat);
let token = &result["properties"]["token"];
assert!(token.get("secret").is_none());
assert!(token.get("position").is_none());
assert!(token.get("required").is_none());
}
#[test]
fn test_flat_to_json_schema_empty() {
let flat = json!({});
let result = flat_to_json_schema(&flat);
assert_eq!(result["type"], "object");
assert!(result.get("required").is_none());
}
#[test]
fn test_flat_to_json_schema_passthrough_json_schema() {
// If already JSON Schema format, pass through unchanged
let js = json!({
"type": "object",
"properties": { "x": { "type": "string" } },
"required": ["x"]
});
let result = flat_to_json_schema(&js);
assert_eq!(result, js);
}
// ── Basic trigger validation (flat format) ──────────────────────
#[test]
fn test_validate_trigger_params_with_valid_params() {
let schema = json!({
"type": "object",
"properties": {
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"] },
"delta": { "type": "integer", "minimum": 1 }
},
"required": ["unit", "delta"]
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"], "required": true },
"delta": { "type": "integer", "minimum": 1, "required": true }
});
let trigger = make_trigger(Some(schema));
@@ -328,12 +458,8 @@ mod tests {
#[test]
fn test_validate_trigger_params_with_invalid_params() {
let schema = json!({
"type": "object",
"properties": {
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"] },
"delta": { "type": "integer", "minimum": 1 }
},
"required": ["unit", "delta"]
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"], "required": true },
"delta": { "type": "integer", "minimum": 1, "required": true }
});
let trigger = make_trigger(Some(schema));
@@ -351,16 +477,12 @@ mod tests {
assert!(validate_trigger_params(&trigger, &params).is_err());
}
// ── Basic action validation (no templates) ───────────────────────
// ── Basic action validation (flat format) ───────────────────────
#[test]
fn test_validate_action_params_with_valid_params() {
let schema = json!({
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
"message": { "type": "string", "required": true }
});
let action = make_action(Some(schema));
@@ -371,11 +493,7 @@ mod tests {
#[test]
fn test_validate_action_params_with_empty_params_but_required_fields() {
let schema = json!({
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
"message": { "type": "string", "required": true }
});
let action = make_action(Some(schema));
@@ -383,16 +501,12 @@ mod tests {
assert!(validate_action_params(&action, &params).is_err());
}
// ── Template-aware validation ────────────────────────────────────
// ── Template-aware validation (flat format) ──────────────────────
#[test]
fn test_template_in_integer_field_passes() {
let schema = json!({
"type": "object",
"properties": {
"counter": { "type": "integer" }
},
"required": ["counter"]
"counter": { "type": "integer", "required": true }
});
let action = make_action(Some(schema));
@@ -403,11 +517,7 @@ mod tests {
#[test]
fn test_template_in_boolean_field_passes() {
let schema = json!({
"type": "object",
"properties": {
"verbose": { "type": "boolean" }
},
"required": ["verbose"]
"verbose": { "type": "boolean", "required": true }
});
let action = make_action(Some(schema));
@@ -418,11 +528,7 @@ mod tests {
#[test]
fn test_template_in_number_field_passes() {
let schema = json!({
"type": "object",
"properties": {
"threshold": { "type": "number", "minimum": 0.0 }
},
"required": ["threshold"]
"threshold": { "type": "number", "minimum": 0.0, "required": true }
});
let action = make_action(Some(schema));
@@ -433,11 +539,7 @@ mod tests {
#[test]
fn test_template_in_enum_field_passes() {
let schema = json!({
"type": "object",
"properties": {
"level": { "type": "string", "enum": ["info", "warn", "error"] }
},
"required": ["level"]
"level": { "type": "string", "enum": ["info", "warn", "error"], "required": true }
});
let action = make_action(Some(schema));
@@ -448,11 +550,7 @@ mod tests {
#[test]
fn test_template_in_array_field_passes() {
let schema = json!({
"type": "object",
"properties": {
"recipients": { "type": "array", "items": { "type": "string" } }
},
"required": ["recipients"]
"recipients": { "type": "array", "items": { "type": "string" }, "required": true }
});
let action = make_action(Some(schema));
@@ -463,11 +561,7 @@ mod tests {
#[test]
fn test_template_in_object_field_passes() {
let schema = json!({
"type": "object",
"properties": {
"metadata": { "type": "object" }
},
"required": ["metadata"]
"metadata": { "type": "object", "required": true }
});
let action = make_action(Some(schema));
@@ -478,13 +572,9 @@ mod tests {
#[test]
fn test_mixed_template_and_literal_values() {
let schema = json!({
"type": "object",
"properties": {
"message": { "type": "string" },
"count": { "type": "integer" },
"verbose": { "type": "boolean" }
},
"required": ["message", "count", "verbose"]
"message": { "type": "string", "required": true },
"count": { "type": "integer", "required": true },
"verbose": { "type": "boolean", "required": true }
});
let action = make_action(Some(schema));
@@ -498,6 +588,26 @@ mod tests {
assert!(validate_action_params(&action, &params).is_ok());
}
// ── Secret fields are ignored during validation ──────────────────
#[test]
fn test_secret_field_validated_normally() {
let schema = json!({
"api_key": { "type": "string", "required": true, "secret": true },
"endpoint": { "type": "string" }
});
let action = make_action(Some(schema));
// Valid: secret field provided
let params = json!({ "api_key": "sk-1234", "endpoint": "https://api.example.com" });
assert!(validate_action_params(&action, &params).is_ok());
// Invalid: secret field missing but required
let params = json!({ "endpoint": "https://api.example.com" });
assert!(validate_action_params(&action, &params).is_err());
}
#[test]
fn test_literal_values_still_validated() {
let schema = json!({