[WIP] Workflows

This commit is contained in:
2026-02-27 16:34:17 -06:00
parent 570c52e623
commit daeff10f18
96 changed files with 5889 additions and 2098 deletions

View File

@@ -137,6 +137,11 @@ pub struct ActionResponse {
#[schema(value_type = Object, nullable = true)]
pub out_schema: Option<JsonValue>,
/// Workflow definition ID (non-null if this action is a workflow)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(example = 42, nullable = true)]
pub workflow_def: Option<i64>,
/// Whether this is an ad-hoc action (not from pack installation)
#[schema(example = false)]
pub is_adhoc: bool,
@@ -186,6 +191,11 @@ pub struct ActionSummary {
#[schema(example = ">=3.12", nullable = true)]
pub runtime_version_constraint: Option<String>,
/// Workflow definition ID (non-null if this action is a workflow)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(example = 42, nullable = true)]
pub workflow_def: Option<i64>,
/// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>,
@@ -210,6 +220,7 @@ impl From<attune_common::models::action::Action> for ActionResponse {
runtime_version_constraint: action.runtime_version_constraint,
param_schema: action.param_schema,
out_schema: action.out_schema,
workflow_def: action.workflow_def,
is_adhoc: action.is_adhoc,
created: action.created,
updated: action.updated,
@@ -229,6 +240,7 @@ impl From<attune_common::models::action::Action> for ActionSummary {
entrypoint: action.entrypoint,
runtime: action.runtime,
runtime_version_constraint: action.runtime_version_constraint,
workflow_def: action.workflow_def,
created: action.created,
updated: action.updated,
}

View File

@@ -53,10 +53,6 @@ pub struct EventResponse {
/// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>,
/// Last update timestamp
#[schema(example = "2024-01-13T10:30:00Z")]
pub updated: DateTime<Utc>,
}
impl From<Event> for EventResponse {
@@ -72,7 +68,6 @@ impl From<Event> for EventResponse {
rule: event.rule,
rule_ref: event.rule_ref,
created: event.created,
updated: event.updated,
}
}
}
@@ -230,9 +225,9 @@ pub struct EnforcementResponse {
#[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>,
/// Last update timestamp
#[schema(example = "2024-01-13T10:30:00Z")]
pub updated: DateTime<Utc>,
/// Timestamp when the enforcement was resolved (status changed from created to processed/disabled)
#[schema(example = "2024-01-13T10:30:01Z", nullable = true)]
pub resolved_at: Option<DateTime<Utc>>,
}
impl From<Enforcement> for EnforcementResponse {
@@ -249,7 +244,7 @@ impl From<Enforcement> for EnforcementResponse {
condition: enforcement.condition,
conditions: enforcement.conditions,
created: enforcement.created,
updated: enforcement.updated,
resolved_at: enforcement.resolved_at,
}
}
}

View File

@@ -6,6 +6,7 @@ use serde_json::Value as JsonValue;
use utoipa::{IntoParams, ToSchema};
use attune_common::models::enums::ExecutionStatus;
use attune_common::models::execution::WorkflowTaskMetadata;
/// Request DTO for creating a manual execution
#[derive(Debug, Clone, Deserialize, ToSchema)]
@@ -62,6 +63,11 @@ pub struct ExecutionResponse {
#[schema(value_type = Object, example = json!({"message_id": "1234567890.123456"}))]
pub result: Option<JsonValue>,
/// Workflow task metadata (only populated for workflow task executions)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>, nullable = true)]
pub workflow_task: Option<WorkflowTaskMetadata>,
/// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>,
@@ -102,6 +108,11 @@ pub struct ExecutionSummary {
#[schema(example = "core.timer")]
pub trigger_ref: Option<String>,
/// Workflow task metadata (only populated for workflow task executions)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>, nullable = true)]
pub workflow_task: Option<WorkflowTaskMetadata>,
/// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>,
@@ -150,6 +161,12 @@ pub struct ExecutionQueryParams {
#[param(example = 1)]
pub parent: Option<i64>,
/// If true, only return top-level executions (those without a parent).
/// Useful for the "By Workflow" view where child tasks are loaded separately.
#[serde(default)]
#[param(example = false)]
pub top_level_only: Option<bool>,
/// Page number (for pagination)
#[serde(default = "default_page")]
#[param(example = 1, minimum = 1)]
@@ -190,6 +207,7 @@ impl From<attune_common::models::execution::Execution> for ExecutionResponse {
result: execution
.result
.map(|r| serde_json::to_value(r).unwrap_or(JsonValue::Null)),
workflow_task: execution.workflow_task,
created: execution.created,
updated: execution.updated,
}
@@ -207,6 +225,7 @@ impl From<attune_common::models::execution::Execution> for ExecutionSummary {
enforcement: execution.enforcement,
rule_ref: None, // Populated separately via enforcement lookup
trigger_ref: None, // Populated separately via enforcement lookup
workflow_task: execution.workflow_task,
created: execution.created,
updated: execution.updated,
}
@@ -256,6 +275,7 @@ mod tests {
action_ref: None,
enforcement: None,
parent: None,
top_level_only: None,
pack_name: None,
rule_ref: None,
trigger_ref: None,
@@ -274,6 +294,7 @@ mod tests {
action_ref: None,
enforcement: None,
parent: None,
top_level_only: None,
pack_name: None,
rule_ref: None,
trigger_ref: None,

View File

@@ -126,7 +126,7 @@ impl HistoryQueryParams {
/// Path parameter for the entity type segment.
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct HistoryEntityTypePath {
/// Entity type: `execution`, `worker`, `enforcement`, or `event`
/// Entity type: `execution` or `worker`
pub entity_type: String,
}

View File

@@ -168,6 +168,10 @@ pub async fn list_executions(
filtered_executions.retain(|e| e.parent == Some(parent_id));
}
if query.top_level_only == Some(true) {
filtered_executions.retain(|e| e.parent.is_none());
}
if let Some(executor_id) = query.executor {
filtered_executions.retain(|e| e.executor == Some(executor_id));
}

View File

@@ -27,14 +27,14 @@ use crate::{
/// List history records for a given entity type.
///
/// Supported entity types: `execution`, `worker`, `enforcement`, `event`.
/// Supported entity types: `execution`, `worker`.
/// Returns a paginated list of change records ordered by time descending.
#[utoipa::path(
get,
path = "/api/v1/history/{entity_type}",
tag = "history",
params(
("entity_type" = String, Path, description = "Entity type: execution, worker, enforcement, or event"),
("entity_type" = String, Path, description = "Entity type: execution or worker"),
HistoryQueryParams,
),
responses(
@@ -127,56 +127,6 @@ pub async fn get_worker_history(
get_entity_history_by_id(&state, HistoryEntityType::Worker, id, query).await
}
/// Get history for a specific enforcement by ID.
///
/// Returns all change records for the given enforcement, ordered by time descending.
#[utoipa::path(
get,
path = "/api/v1/enforcements/{id}/history",
tag = "history",
params(
("id" = i64, Path, description = "Enforcement ID"),
HistoryQueryParams,
),
responses(
(status = 200, description = "History records for the enforcement", body = PaginatedResponse<HistoryRecordResponse>),
),
security(("bearer_auth" = []))
)]
pub async fn get_enforcement_history(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(id): Path<i64>,
Query(query): Query<HistoryQueryParams>,
) -> ApiResult<impl IntoResponse> {
get_entity_history_by_id(&state, HistoryEntityType::Enforcement, id, query).await
}
/// Get history for a specific event by ID.
///
/// Returns all change records for the given event, ordered by time descending.
#[utoipa::path(
get,
path = "/api/v1/events/{id}/history",
tag = "history",
params(
("id" = i64, Path, description = "Event ID"),
HistoryQueryParams,
),
responses(
(status = 200, description = "History records for the event", body = PaginatedResponse<HistoryRecordResponse>),
),
security(("bearer_auth" = []))
)]
pub async fn get_event_history(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(id): Path<i64>,
Query(query): Query<HistoryQueryParams>,
) -> ApiResult<impl IntoResponse> {
get_entity_history_by_id(&state, HistoryEntityType::Event, id, query).await
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
@@ -231,8 +181,6 @@ async fn get_entity_history_by_id(
/// - `GET /history/:entity_type` — generic history query
/// - `GET /executions/:id/history` — execution-specific history
/// - `GET /workers/:id/history` — worker-specific history (note: currently no /workers base route exists)
/// - `GET /enforcements/:id/history` — enforcement-specific history
/// - `GET /events/:id/history` — event-specific history
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
// Generic history endpoint
@@ -240,6 +188,4 @@ pub fn routes() -> Router<Arc<AppState>> {
// Entity-specific convenience endpoints
.route("/executions/{id}/history", get(get_execution_history))
.route("/workers/{id}/history", get(get_worker_history))
.route("/enforcements/{id}/history", get(get_enforcement_history))
.route("/events/{id}/history", get(get_event_history))
}

View File

@@ -601,8 +601,8 @@ async fn write_workflow_yaml(
/// 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 created with `is_workflow = true` and linked to
/// the workflow definition via the `workflow_def` FK.
/// workflow builder. The action is linked to the workflow definition via the
/// `workflow_def` FK.
async fn create_companion_action(
db: &sqlx::PgPool,
workflow_ref: &str,
@@ -643,7 +643,7 @@ async fn create_companion_action(
))
})?;
// Link the action to the workflow definition (sets is_workflow = true and workflow_def)
// Link the action to the workflow definition (sets workflow_def FK)
ActionRepository::link_workflow_def(db, action.id, workflow_def_id)
.await
.map_err(|e| {

View File

@@ -368,7 +368,6 @@ mod tests {
runtime_version_constraint: None,
param_schema: schema,
out_schema: None,
is_workflow: false,
workflow_def: None,
is_adhoc: false,
parameter_delivery: attune_common::models::ParameterDelivery::default(),

View File

@@ -120,23 +120,21 @@ async fn test_sse_stream_receives_execution_updates() -> Result<()> {
println!("Updating execution {} to 'running' status", execution_id);
// Update execution status - this should trigger PostgreSQL NOTIFY
let _ = sqlx::query(
"UPDATE execution SET status = 'running', start_time = NOW() WHERE id = $1",
)
.bind(execution_id)
.execute(&pool_clone)
.await;
let _ =
sqlx::query("UPDATE execution SET status = 'running', updated = NOW() WHERE id = $1")
.bind(execution_id)
.execute(&pool_clone)
.await;
println!("Update executed, waiting before setting to succeeded");
tokio::time::sleep(Duration::from_millis(500)).await;
// Update to succeeded
let _ = sqlx::query(
"UPDATE execution SET status = 'succeeded', end_time = NOW() WHERE id = $1",
)
.bind(execution_id)
.execute(&pool_clone)
.await;
let _ =
sqlx::query("UPDATE execution SET status = 'succeeded', updated = NOW() WHERE id = $1")
.bind(execution_id)
.execute(&pool_clone)
.await;
println!("Execution {} updated to 'succeeded'", execution_id);
});