use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::{Extension, Json};
use dataflow_rs::datalogic_rs;
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::HashSet;
use crate::errors::OrionError;
use crate::server::admin_auth::AdminPrincipal;
use crate::server::extract::{OrionJson, OrionQuery};
use crate::server::routes::openapi::{
DataEnvelope, ImportResult, PaginatedEnvelope, WorkflowTestResult,
};
use crate::server::routes::response_helpers::{created_response, data_response, paginated_into};
use crate::server::state::AppState;
use crate::storage::models::WorkflowResponse;
use crate::storage::repositories::workflows::{
CreateWorkflowRequest, RolloutUpdateRequest, StatusChangeRequest, UpdateWorkflowRequest,
WorkflowFilter,
};
use super::StatusAction;
use super::audit_log;
use super::audit_log_draft_only;
use super::{ValidationEnvelope, ValidationIssue, issues_from_error};
use crate::storage::repositories::helpers::VersionFilter;
#[utoipa::path(
get,
path = "/api/v1/admin/workflows",
params(WorkflowFilter),
tag = "Workflows",
responses(
(status = 200, description = "Paginated list of workflows", body = PaginatedEnvelope<WorkflowResponse>),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_workflows(
State(state): State<AppState>,
OrionQuery(filter): OrionQuery<WorkflowFilter>,
) -> Result<Json<Value>, OrionError> {
let result = state.repos.workflows.list_paginated(&filter).await?;
paginated_into(result, |w| WorkflowResponse::try_from(w))
}
#[utoipa::path(
post,
path = "/api/v1/admin/workflows",
tag = "Workflows",
request_body = CreateWorkflowRequest,
responses(
(status = 201, description = "Workflow created as draft", body = DataEnvelope<WorkflowResponse>),
(status = 400, description = "Invalid input"),
(status = 409, description = "Workflow id already exists"),
)
)]
#[tracing::instrument(skip(state, req, principal))]
pub(crate) async fn create_workflow(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
OrionJson(req): OrionJson<CreateWorkflowRequest>,
) -> Result<(StatusCode, Json<Value>), OrionError> {
crate::validation::validate_create_workflow(&req, state.config.engine.max_loop_iterations)?;
let workflow = state.repos.workflows.create(&req).await?;
audit_log_draft_only(
&state.audit_queue,
&principal,
"create",
"workflow",
&workflow.workflow_id,
);
Ok(created_response(WorkflowResponse::try_from(&workflow)?))
}
#[utoipa::path(
get,
path = "/api/v1/admin/workflows/{id}",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID")),
responses(
(status = 200, description = "Workflow details", body = DataEnvelope<WorkflowResponse>),
(status = 404, description = "Workflow not found"),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn get_workflow(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, OrionError> {
let workflow = state.repos.workflows.get_by_id(&id).await?;
Ok(data_response(WorkflowResponse::try_from(&workflow)?))
}
#[utoipa::path(
put,
path = "/api/v1/admin/workflows/{id}",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID")),
request_body = UpdateWorkflowRequest,
responses(
(status = 200, description = "Draft workflow updated", body = DataEnvelope<WorkflowResponse>),
(status = 400, description = "Invalid input"),
(status = 404, description = "Workflow not found, or it has no draft version to update"),
)
)]
#[tracing::instrument(skip(state, req, principal))]
pub(crate) async fn update_workflow(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
Path(id): Path<String>,
OrionJson(req): OrionJson<UpdateWorkflowRequest>,
) -> Result<Json<Value>, OrionError> {
crate::validation::validate_update_workflow(&req, state.config.engine.max_loop_iterations)?;
let workflow = state.repos.workflows.update_draft(&id, &req).await?;
audit_log_draft_only(&state.audit_queue, &principal, "update", "workflow", &id);
Ok(data_response(WorkflowResponse::try_from(&workflow)?))
}
#[utoipa::path(
delete,
path = "/api/v1/admin/workflows/{id}",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID")),
responses(
(status = 204, description = "Workflow deleted"),
(status = 404, description = "Workflow not found"),
)
)]
#[tracing::instrument(skip(state, principal))]
pub(crate) async fn delete_workflow(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
Path(id): Path<String>,
) -> Result<StatusCode, OrionError> {
let mut write = super::audited_write(&state, &principal, "delete", "workflow", &id).await?;
state.repos.workflows.delete_tx(write.tx(), &id).await?;
write.commit().await?;
super::reload_after_commit(&state, super::ReloadMode::Now).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch,
path = "/api/v1/admin/workflows/{id}/status",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID"), super::StatusChangeQuery),
request_body = StatusChangeRequest,
responses(
(status = 200, description = "Status updated. With `?dry_run=true` nothing is \
written and the body is instead the `/validate` envelope \
(`{\"data\": {\"valid\", \"errors\", \"warnings\"}}`) reporting every gate \
the real transition would run: draft existence, connector existence and \
type match, MongoDB `database` presence, and rollout arithmetic (K3). \
With `?reload=defer` the row commits but the engine (and every cluster \
peer) keeps serving the previous active set until \
`POST /engine/reload` (K4).", body = DataEnvelope<WorkflowResponse>),
(status = 400, description = "Invalid status transition"),
(status = 404, description = "Workflow not found"),
)
)]
#[tracing::instrument(skip(state, req, principal))]
pub(crate) async fn change_workflow_status(
State(state): State<AppState>,
OrionQuery(query): OrionQuery<super::StatusChangeQuery>,
principal: Option<Extension<AdminPrincipal>>,
Path(id): Path<String>,
OrionJson(req): OrionJson<StatusChangeRequest>,
) -> Result<Json<Value>, OrionError> {
let action = StatusAction::parse(req.status)?;
let lifecycle = WorkflowLifecycle::new(&state);
if query.dry_run {
let envelope = dry_run_status_change(&lifecycle, &state, &id, &action, &req).await?;
return Ok(Json(serde_json::to_value(envelope)?));
}
if matches!(action, StatusAction::Activate) {
let draft = state.repos.workflows.get_by_id(&id).await?;
super::check_activation(&lifecycle, &draft).await?;
}
let mut write = super::audited_write(
&state,
&principal,
&format!("status_{}", req.status),
"workflow",
&id,
)
.await?;
let workflow = match action {
StatusAction::Activate => {
let rollout_pct = req.rollout_percentage.unwrap_or(100);
state
.repos
.workflows
.activate_tx(write.tx(), &id, rollout_pct)
.await?
}
StatusAction::Archive => state.repos.workflows.archive_tx(write.tx(), &id).await?,
};
write.commit().await?;
super::reload_after_commit(&state, query.reload).await?;
Ok(data_response(WorkflowResponse::try_from(&workflow)?))
}
struct WorkflowLifecycle<'a> {
workflows: &'a dyn crate::storage::repositories::workflows::WorkflowRepository,
connectors: &'a crate::connector::ConnectorRegistry,
}
impl<'a> WorkflowLifecycle<'a> {
fn new(state: &'a AppState) -> Self {
Self {
workflows: &*state.repos.workflows,
connectors: &state.connector_registry,
}
}
}
impl super::VersionedLifecycle for WorkflowLifecycle<'_> {
type Row = crate::storage::models::Workflow;
const NOUN: &'static str = "workflow";
fn row_status(row: &Self::Row) -> &str {
&row.status
}
async fn get_by_id(&self, id: &str) -> Result<Self::Row, OrionError> {
self.workflows.get_by_id(id).await
}
async fn has_active(&self, id: &str) -> Result<bool, OrionError> {
Ok(self
.workflows
.list_active()
.await?
.iter()
.any(|w| w.workflow_id == id))
}
async fn activation_gates(&self, draft: &Self::Row) -> Vec<OrionError> {
match super::services::workflows::ensure_connectors_exist(self.connectors, draft).await {
Ok(()) => Vec::new(),
Err(e) => vec![e],
}
}
}
async fn dry_run_status_change(
lifecycle: &WorkflowLifecycle<'_>,
state: &AppState,
id: &str,
action: &StatusAction,
req: &StatusChangeRequest,
) -> Result<ValidationEnvelope, OrionError> {
let mut errors = super::status_change_findings(lifecycle, id, action).await?;
let mut warnings = Vec::new();
if let StatusAction::Activate = action {
let rollout_pct = req.rollout_percentage.unwrap_or(100);
if !(0..=100).contains(&rollout_pct) {
errors.push(ValidationIssue {
field: "rollout_percentage".to_string(),
message: "rollout_percentage must be between 0 and 100".to_string(),
});
}
if (0..100).contains(&rollout_pct) {
let has_active = state
.repos
.workflows
.list_active()
.await?
.iter()
.any(|w| w.workflow_id == id);
if !has_active {
warnings.push(ValidationIssue {
field: "rollout_percentage".to_string(),
message: format!(
"partial rollout of {rollout_pct}% with no currently active \
version: the active set would sum to {rollout_pct}%, and the \
serving channel is quarantined until rollout percentages sum \
to 100"
),
});
}
}
}
Ok(ValidationEnvelope::new(errors, warnings))
}
pub(crate) use crate::engine::refs::{channel_call_targets, connector_refs};
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct WorkflowDependencies {
workflow_id: String,
version: i64,
connectors: Vec<ConnectorDependency>,
channels: Vec<String>,
has_dynamic_channel_calls: bool,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct ConnectorDependency {
connector: String,
function: String,
}
#[utoipa::path(
get,
path = "/api/v1/admin/workflows/{id}/dependencies",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID")),
responses(
(status = 200, description = "What the workflow's tasks reference (K9): connector \
names (with the referencing function) and statically-known `channel_call` \
targets. The API twin of the reference walk activation runs, for tooling \
that computes a package's dependency closure without re-implementing the \
task-walk.", body = DataEnvelope<WorkflowDependencies>),
(status = 404, description = "Workflow not found"),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn workflow_dependencies(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, OrionError> {
let workflow = state.repos.workflows.get_by_id(&id).await?;
let tasks: Value = serde_json::from_str(&workflow.tasks_json).map_err(|e| {
OrionError::internal_from(format!("Corrupt JSON in workflow {id} tasks_json"), e)
})?;
let mut connectors: Vec<ConnectorDependency> = Vec::new();
for r in connector_refs(&tasks) {
if !connectors
.iter()
.any(|c| c.connector == r.connector && c.function == r.function)
{
connectors.push(ConnectorDependency {
connector: r.connector.to_string(),
function: r.function.to_string(),
});
}
}
let (channels, has_dynamic_channel_calls) = channel_call_targets(&tasks);
Ok(data_response(WorkflowDependencies {
workflow_id: workflow.workflow_id,
version: workflow.version,
connectors,
channels: channels.into_iter().map(str::to_string).collect(),
has_dynamic_channel_calls,
}))
}
#[utoipa::path(
patch,
path = "/api/v1/admin/workflows/{id}/rollout",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID"), super::ReloadQuery),
request_body = RolloutUpdateRequest,
responses(
(status = 200, description = "Rollout percentage updated. With `?reload=defer` \
the row commits but the engine keeps serving the previous rollout until \
`POST /engine/reload` (K4).", body = DataEnvelope<WorkflowResponse>),
(status = 400, description = "Invalid rollout configuration"),
)
)]
#[tracing::instrument(skip(state, req, principal))]
pub(crate) async fn update_rollout(
State(state): State<AppState>,
OrionQuery(query): OrionQuery<super::ReloadQuery>,
principal: Option<Extension<AdminPrincipal>>,
Path(id): Path<String>,
OrionJson(req): OrionJson<RolloutUpdateRequest>,
) -> Result<Json<Value>, OrionError> {
let mut write =
super::audited_write(&state, &principal, "update_rollout", "workflow", &id).await?;
let workflow = state
.repos
.workflows
.update_rollout_tx(write.tx(), &id, req.rollout_percentage)
.await?;
write.commit().await?;
super::reload_after_commit(&state, query.reload).await?;
Ok(data_response(WorkflowResponse::try_from(&workflow)?))
}
#[utoipa::path(
get,
path = "/api/v1/admin/workflows/{id}/versions",
tag = "Workflows",
params(
("id" = String, Path, description = "Workflow ID"),
VersionFilter,
),
responses(
(status = 200, description = "Paginated version history", body = PaginatedEnvelope<WorkflowResponse>),
(status = 404, description = "Workflow not found"),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_workflow_versions(
State(state): State<AppState>,
Path(id): Path<String>,
OrionQuery(filter): OrionQuery<VersionFilter>,
) -> Result<Json<Value>, OrionError> {
let _ = state.repos.workflows.get_by_id(&id).await?;
let result = state.repos.workflows.list_versions(&id, &filter).await?;
paginated_into(result, |w| WorkflowResponse::try_from(w))
}
#[utoipa::path(
post,
path = "/api/v1/admin/workflows/{id}/versions",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID")),
responses(
(status = 201, description = "New draft version created", body = DataEnvelope<WorkflowResponse>),
(status = 409, description = "Draft already exists"),
)
)]
#[tracing::instrument(skip(state, principal))]
pub(crate) async fn create_new_workflow_version(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
Path(id): Path<String>,
) -> Result<(StatusCode, Json<Value>), OrionError> {
let workflow = state.repos.workflows.create_new_version(&id).await?;
audit_log_draft_only(
&state.audit_queue,
&principal,
"create_version",
"workflow",
&id,
);
Ok(created_response(WorkflowResponse::try_from(&workflow)?))
}
#[derive(Deserialize, utoipa::ToSchema)]
pub(crate) struct TestWorkflowRequest {
data: Value,
#[serde(default)]
metadata: Value,
}
#[utoipa::path(
post,
path = "/api/v1/admin/workflows/{id}/test",
tag = "Workflows",
params(("id" = String, Path, description = "Workflow ID")),
request_body = TestWorkflowRequest,
responses(
(status = 200, description = "Test result with trace", body = DataEnvelope<WorkflowTestResult>),
(status = 404, description = "Workflow not found"),
)
)]
#[tracing::instrument(skip(state, req, principal))]
pub(crate) async fn test_workflow(
State(state): State<AppState>,
Path(id): Path<String>,
principal: Option<Extension<AdminPrincipal>>,
OrionJson(req): OrionJson<TestWorkflowRequest>,
) -> Result<Json<Value>, OrionError> {
use crate::storage::repositories::workflows::workflow_to_dataflow;
let workflow = state.repos.workflows.get_by_id(&id).await?;
audit_log(&state.audit_queue, &principal, "test", "workflow", &id);
let df_workflow = workflow_to_dataflow(&workflow, "__test__").map_err(|e| {
OrionError::validation(format!(
"workflow '{id}' does not match the engine's workflow shape: {e}"
))
})?;
let custom_fns = crate::engine::build_custom_functions(crate::runtime::handler_deps(&state));
let test_engine = crate::engine::build_single(df_workflow, custom_fns, &state.secrets)?;
let mut payload = json!({});
if let Some(obj) = req.data.as_object() {
for (k, v) in obj {
payload[k] = v.clone();
}
} else {
payload = req.data;
}
let mut metadata = req.metadata;
crate::engine::stamp_vars(&mut metadata, state.vars.as_deref());
let mut message = dataflow_rs::Message::builder()
.payload_json(&payload)
.metadata_json(&metadata)
.build();
let mut trace = dataflow_rs::ExecutionTrace::new();
let run_error = test_engine
.process_message_tracing(&mut message, &mut trace)
.await
.err();
let matched = !trace.steps.is_empty()
&& trace.steps.iter().any(|s| {
matches!(
s.result,
dataflow_rs::StepResult::Executed | dataflow_rs::StepResult::Skipped
)
});
let trace_value = serde_json::to_value(&trace)?;
let mut body = json!({
"matched": matched,
"trace": trace_value,
"output": message.data(),
"errors": message.errors().iter().filter_map(|e| serde_json::to_value(e).ok()).collect::<Vec<_>>(),
});
if let Some(e) = run_error {
tracing::warn!(error = %e, "workflow dry-run execution failed");
body["error"] = json!(OrionError::Engine(e).client_message());
}
Ok(data_response(body))
}
#[utoipa::path(
post,
path = "/api/v1/admin/workflows/import",
tag = "Workflows",
request_body = Vec<CreateWorkflowRequest>,
params(super::ImportQuery),
responses(
(status = 200, description = "Import results with counts (or would-be results when ?dry_run=true). \
Each item is handled independently: a malformed or conflicting item becomes one entry in \
`errors` and the rest of the batch still applies. Dry-run additionally probes for name \
conflicts against stored rows and duplicates within the batch, without writing. \
`?on_conflict=new_version` upserts instead of refusing an existing id (K2): an existing \
draft is replaced, an active workflow whose content differs gets a new draft version, \
and identical content is reported `unchanged` — re-importing the same artifact is a \
no-op. Per-item outcomes are in `results`.", body = DataEnvelope<ImportResult>),
)
)]
#[tracing::instrument(skip(state, items, principal), fields(count = items.len()))]
pub(crate) async fn import_workflows(
State(state): State<AppState>,
OrionQuery(query): OrionQuery<super::ImportQuery>,
principal: Option<Extension<AdminPrincipal>>,
OrionJson(items): OrionJson<Vec<Value>>,
) -> Result<Json<Value>, OrionError> {
super::check_import_batch_size(items.len())?;
let repo = state.repos.workflows.clone();
let probe = state.repos.workflows.clone();
let upsert_repo = state.repos.workflows.clone();
let loop_cap = state.config.engine.max_loop_iterations;
let outcome =
super::import_items::<CreateWorkflowRequest, _, _, _, _, _, _, _, _>(
items,
query.dry_run,
query.on_conflict,
super::ImportOps {
validate: |w: &CreateWorkflowRequest| {
crate::validation::validate_create_workflow(w, loop_cap)
},
conflict_key: |w: &CreateWorkflowRequest| w.workflow_id.clone(),
exists: |id: String| {
let repo = probe.clone();
async move { exists_or_err(repo.get_by_id(&id).await) }
},
create: |w: CreateWorkflowRequest| {
let repo = repo.clone();
async move { repo.create(&w).await.map(|_| ()) }
},
upsert: |w: CreateWorkflowRequest, dry_run: bool| {
let repo = upsert_repo.clone();
async move {
super::versioned_upsert(&WorkflowUpsert(repo.as_ref()), w, dry_run).await
}
},
},
)
.await;
if query.dry_run {
return Ok(super::import_response(true, outcome));
}
for id in outcome.written() {
audit_log_draft_only(&state.audit_queue, &principal, "import", "workflow", id);
}
audit_log_draft_only(
&state.audit_queue,
&principal,
"import",
"workflow",
&format!("{} imported", outcome.imported),
);
Ok(super::import_response(false, outcome))
}
struct WorkflowUpsert<'a>(&'a dyn crate::storage::repositories::workflows::WorkflowRepository);
impl super::VersionedUpsert for WorkflowUpsert<'_> {
type Row = crate::storage::models::Workflow;
type Request = CreateWorkflowRequest;
fn request_id(req: &Self::Request) -> Option<String> {
req.workflow_id.clone()
}
fn row_status(row: &Self::Row) -> &str {
&row.status
}
fn content_matches(row: &Self::Row, req: &Self::Request) -> Result<bool, OrionError> {
Ok(crate::storage::content::workflow_content(row)?
== crate::storage::content::workflow_request_content(req))
}
async fn create(&self, req: &Self::Request) -> Result<(), OrionError> {
self.0.create(req).await.map(|_| ())
}
async fn get_by_id(&self, id: &str) -> Result<Self::Row, OrionError> {
self.0.get_by_id(id).await
}
async fn create_new_version(&self, id: &str) -> Result<(), OrionError> {
self.0.create_new_version(id).await.map(|_| ())
}
async fn replace_draft(&self, id: &str, req: &Self::Request) -> Result<(), OrionError> {
self.0.replace_draft(id, req).await.map(|_| ())
}
}
pub(crate) fn exists_or_err<T>(result: Result<T, OrionError>) -> Result<bool, OrionError> {
match result {
Ok(_) => Ok(true),
Err(OrionError::NotFound(_)) => Ok(false),
Err(e) => Err(e),
}
}
#[utoipa::path(
get,
path = "/api/v1/admin/workflows/export",
tag = "Workflows",
params(WorkflowFilter),
responses(
(status = 200, description = "Exported workflows", body = DataEnvelope<Vec<WorkflowResponse>>),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn export_workflows(
State(state): State<AppState>,
OrionQuery(filter): OrionQuery<WorkflowFilter>,
) -> Result<Json<Value>, OrionError> {
let rows = state.repos.workflows.snapshot(&filter).await?;
let data: Vec<WorkflowResponse> = rows
.iter()
.map(WorkflowResponse::try_from)
.collect::<Result<_, _>>()?;
Ok(data_response(data))
}
#[utoipa::path(
post,
path = "/api/v1/admin/workflows/validate",
tag = "Workflows",
request_body = CreateWorkflowRequest,
responses(
(status = 200, description = "Validation result", body = ValidationEnvelope),
)
)]
#[tracing::instrument(skip(state, req))]
pub(crate) async fn validate_workflow(
State(state): State<AppState>,
OrionJson(req): OrionJson<CreateWorkflowRequest>,
) -> Result<Json<ValidationEnvelope>, OrionError> {
Ok(Json(run_validation(&req, &state).await))
}
async fn run_validation(req: &CreateWorkflowRequest, state: &AppState) -> ValidationEnvelope {
let mut errors = Vec::new();
let mut warnings = Vec::new();
if let Err(e) =
crate::validation::validate_create_workflow(req, state.config.engine.max_loop_iterations)
{
errors.extend(issues_from_error(e));
}
validate_task_array_shape(req, &mut errors);
let dl = crate::engine::operators::add_to_datalogic(datalogic_rs::Engine::builder()).build();
validate_tasks(&req.tasks, &dl, state, &mut errors, &mut warnings).await;
validate_workflow_condition(&req.condition, &dl, &mut errors);
validate_dataflow_conversion(req, &mut errors);
warnings.extend(
crate::validation::unresolvable_logic_warnings(&req.tasks)
.into_iter()
.map(|(field, message)| ValidationIssue { field, message }),
);
ValidationEnvelope::new(errors, warnings)
}
fn validate_task_array_shape(req: &CreateWorkflowRequest, errors: &mut Vec<ValidationIssue>) {
let tasks = req.tasks.as_array();
if tasks.is_none() || tasks.is_some_and(|t| t.is_empty()) {
errors.push(ValidationIssue {
field: "tasks".to_string(),
message: "Tasks must be a non-empty array".to_string(),
});
}
}
async fn validate_tasks(
tasks: &Value,
dl: &datalogic_rs::Engine,
state: &AppState,
errors: &mut Vec<ValidationIssue>,
warnings: &mut Vec<ValidationIssue>,
) {
let mut seen_ids: HashSet<&str> = HashSet::new();
let mut written: Vec<String> = Vec::new();
for (path, task) in crate::engine::walk_steps(tasks).tasks {
let (task_errors, task_warnings) = errors_for_task(&path, task, dl, state).await;
errors.extend(task_errors);
warnings.extend(task_warnings);
warn_on_unwritten_reads(&path, task, &mut written, warnings);
let task_id = task.get("id").and_then(|v| v.as_str()).unwrap_or("");
if !task_id.is_empty() && !seen_ids.insert(task_id) {
errors.push(ValidationIssue {
field: "tasks".to_string(),
message: format!("Duplicate task id '{task_id}'"),
});
}
}
}
const MAX_UNWRITTEN_READ_WARNINGS: usize = 10;
fn warn_on_unwritten_reads(
path_prefix: &str,
task: &Value,
written: &mut Vec<String>,
warnings: &mut Vec<ValidationIssue>,
) {
let mut report = |path: &str, field: String| {
if warnings.len() >= MAX_UNWRITTEN_READ_WARNINGS
|| warnings
.iter()
.any(|w| w.field == field && w.message.contains(path))
{
return;
}
warnings.push(ValidationIssue {
field,
message: format!(
"reads '{path}', which no earlier task writes. If this is a typo the \
task will silently see null; if the value arrives another way \
(metadata, a connector response shape, continue_on_error), ignore this."
),
});
};
if let Some(condition) = task.get("condition") {
for path in data_reads(condition) {
if !is_written(&path, written) {
report(&path, format!("{path_prefix}.condition"));
}
}
}
let mappings = task
.get("function")
.and_then(|f| f.get("input"))
.and_then(|input| input.get("mappings"))
.and_then(|m| m.as_array());
if let Some(mappings) = mappings {
for (m, mapping) in mappings.iter().enumerate() {
if let Some(logic) = mapping.get("logic") {
for path in data_reads(logic) {
if !is_written(&path, written) {
report(
&path,
format!("{path_prefix}.function.input.mappings[{m}].logic"),
);
}
}
}
if let Some(path) = mapping.get("path").and_then(|p| p.as_str()) {
written.push(path.to_string());
}
}
return;
}
if let Some(input) = task.get("function").and_then(|f| f.get("input")) {
for path in data_reads(input) {
if !is_written(&path, written) {
report(&path, format!("{path_prefix}.function.input"));
}
}
}
written.extend(task_writes(task));
}
use crate::definitions::analysis::dataflow::{data_reads, is_written, task_writes};
async fn errors_for_task(
path_prefix: &str,
task: &Value,
dl: &datalogic_rs::Engine,
state: &AppState,
) -> (Vec<ValidationIssue>, Vec<ValidationIssue>) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let task_id = task.get("id").and_then(|v| v.as_str()).unwrap_or("");
if task_id.is_empty() {
errors.push(ValidationIssue {
field: format!("{path_prefix}.id"),
message: format!("Task at {path_prefix} is missing 'id'"),
});
}
if task
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.is_empty()
{
errors.push(ValidationIssue {
field: format!("{path_prefix}.name"),
message: format!("Task at {path_prefix} is missing 'name'"),
});
}
let function = task.get("function");
let fn_name = function
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("");
if fn_name.is_empty() {
errors.push(ValidationIssue {
field: format!("{path_prefix}.function.name"),
message: format!("Task at {path_prefix} is missing 'function.name'"),
});
}
if let Some(condition) = task.get("condition")
&& let Err(e) = dl.compile(condition)
{
errors.push(ValidationIssue {
field: format!("{path_prefix}.condition"),
message: format!("Invalid JSONLogic in task condition: {e}"),
});
}
if !fn_name.is_empty()
&& crate::engine::CONNECTOR_FUNCTIONS.contains(&fn_name)
&& let Some(connector_name) = function
.and_then(|f| f.get("input"))
.and_then(|input| input.get("connector"))
.and_then(|c| c.as_str())
&& state.connector_registry.get(connector_name).await.is_none()
{
warnings.push(ValidationIssue {
field: format!("{path_prefix}.function.input.connector"),
message: format!("Connector '{connector_name}' not found in registry"),
});
}
(errors, warnings)
}
fn validate_workflow_condition(
condition: &Value,
dl: &datalogic_rs::Engine,
errors: &mut Vec<ValidationIssue>,
) {
if let Err(e) = dl.compile(condition) {
errors.push(ValidationIssue {
field: "condition".to_string(),
message: format!("Invalid JSONLogic in workflow condition: {e}"),
});
}
}
fn validate_dataflow_conversion(req: &CreateWorkflowRequest, errors: &mut Vec<ValidationIssue>) {
use crate::storage::repositories::workflows::{synthetic_workflow, workflow_to_dataflow};
match synthetic_workflow(req, "temp-validate") {
Ok(w) => {
if let Err(e) = workflow_to_dataflow(&w, "__validate__") {
errors.push(ValidationIssue {
field: "(root)".to_string(),
message: format!("Failed to convert to dataflow workflow: {e}"),
});
}
}
Err(e) => errors.push(ValidationIssue {
field: "(root)".to_string(),
message: format!("Failed to serialize workflow fields: {e}"),
}),
}
}
#[cfg(test)]
mod tests {
use crate::storage::repositories::workflows::{SqlWorkflowRepository, WorkflowRepository};
#[tokio::test]
async fn export_snapshot_pages_until_exhausted() {
use crate::storage::schema::Workflows;
use sea_query::{Asterisk, Order, Query};
let pool = crate::storage::test_sqlite_pool().await;
let repo = SqlWorkflowRepository::new(pool.clone());
for i in 0..5 {
let req = serde_json::from_value(serde_json::json!({
"workflow_id": format!("wf-exp-{i}"),
"name": format!("Export {i}"),
"tasks": [{"id": "t1", "name": "Log",
"function": {"name": "log", "input": {"message": "x"}}}],
}))
.expect("request");
repo.create(&req).await.expect("create");
}
let exported: Vec<crate::storage::models::Workflow> = tokio::time::timeout(
std::time::Duration::from_secs(30),
crate::storage::repositories::helpers::snapshot_pages(&pool, 2, |limit, offset| {
Query::select()
.column(Asterisk)
.from(Workflows::Table)
.order_by(Workflows::WorkflowId, Order::Asc)
.limit(limit as u64)
.offset(offset as u64)
.to_owned()
}),
)
.await
.expect("export must terminate: an endless loop means paging is broken")
.expect("export");
assert_eq!(
exported.len(),
5,
"export must return exactly one row per workflow (no overlap, no gaps)"
);
let mut ids: Vec<&str> = exported.iter().map(|w| w.workflow_id.as_str()).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 5, "every workflow must be exported exactly once");
}
}