more polish on workflows
Some checks failed
CI / Rustfmt (push) Failing after 25s
CI / Clippy (push) Failing after 2m3s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Web Blocking Checks (push) Failing after 26s
CI / Security Blocking Checks (push) Successful in 8s
CI / Security Advisory Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled

This commit is contained in:
2026-03-11 11:21:28 -05:00
parent a7ed135af2
commit b5d6bb2243
25 changed files with 366 additions and 322 deletions

View File

@@ -49,9 +49,6 @@ pub struct SaveWorkflowFileRequest {
#[schema(example = json!(["deployment", "automation"]))] #[schema(example = json!(["deployment", "automation"]))]
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: Option<bool>,
} }
/// Request DTO for creating a new workflow /// Request DTO for creating a new workflow
@@ -97,9 +94,6 @@ pub struct CreateWorkflowRequest {
#[schema(example = json!(["incident", "slack", "approval"]))] #[schema(example = json!(["incident", "slack", "approval"]))]
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: Option<bool>,
} }
/// Request DTO for updating a workflow /// Request DTO for updating a workflow
@@ -135,9 +129,6 @@ pub struct UpdateWorkflowRequest {
#[schema(example = json!(["incident", "slack", "approval", "automation"]))] #[schema(example = json!(["incident", "slack", "approval", "automation"]))]
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: Option<bool>,
} }
/// Response DTO for workflow information /// Response DTO for workflow information
@@ -187,10 +178,6 @@ pub struct WorkflowResponse {
#[schema(example = json!(["incident", "slack", "approval"]))] #[schema(example = json!(["incident", "slack", "approval"]))]
pub tags: Vec<String>, pub tags: Vec<String>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: bool,
/// Creation timestamp /// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")] #[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
@@ -231,10 +218,6 @@ pub struct WorkflowSummary {
#[schema(example = json!(["incident", "slack", "approval"]))] #[schema(example = json!(["incident", "slack", "approval"]))]
pub tags: Vec<String>, pub tags: Vec<String>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: bool,
/// Creation timestamp /// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")] #[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
@@ -259,7 +242,6 @@ impl From<attune_common::models::workflow::WorkflowDefinition> for WorkflowRespo
out_schema: workflow.out_schema, out_schema: workflow.out_schema,
definition: workflow.definition, definition: workflow.definition,
tags: workflow.tags, tags: workflow.tags,
enabled: workflow.enabled,
created: workflow.created, created: workflow.created,
updated: workflow.updated, updated: workflow.updated,
} }
@@ -277,7 +259,6 @@ impl From<attune_common::models::workflow::WorkflowDefinition> for WorkflowSumma
description: workflow.description, description: workflow.description,
version: workflow.version, version: workflow.version,
tags: workflow.tags, tags: workflow.tags,
enabled: workflow.enabled,
created: workflow.created, created: workflow.created,
updated: workflow.updated, updated: workflow.updated,
} }
@@ -291,10 +272,6 @@ pub struct WorkflowSearchParams {
#[param(example = "incident,approval")] #[param(example = "incident,approval")]
pub tags: Option<String>, pub tags: Option<String>,
/// Filter by enabled status
#[param(example = true)]
pub enabled: Option<bool>,
/// Search term for label/description (case-insensitive) /// Search term for label/description (case-insensitive)
#[param(example = "incident")] #[param(example = "incident")]
pub search: Option<String>, pub search: Option<String>,
@@ -320,7 +297,6 @@ mod tests {
out_schema: None, out_schema: None,
definition: serde_json::json!({"tasks": []}), definition: serde_json::json!({"tasks": []}),
tags: None, tags: None,
enabled: None,
}; };
assert!(req.validate().is_err()); assert!(req.validate().is_err());
@@ -338,7 +314,6 @@ mod tests {
out_schema: None, out_schema: None,
definition: serde_json::json!({"tasks": []}), definition: serde_json::json!({"tasks": []}),
tags: Some(vec!["test".to_string()]), tags: Some(vec!["test".to_string()]),
enabled: Some(true),
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
@@ -354,7 +329,6 @@ mod tests {
out_schema: None, out_schema: None,
definition: None, definition: None,
tags: None, tags: None,
enabled: None,
}; };
// Should be valid even with all None values // Should be valid even with all None values
@@ -365,7 +339,6 @@ mod tests {
fn test_workflow_search_params() { fn test_workflow_search_params() {
let params = WorkflowSearchParams { let params = WorkflowSearchParams {
tags: Some("incident,approval".to_string()), tags: Some("incident,approval".to_string()),
enabled: Some(true),
search: Some("response".to_string()), search: Some("response".to_string()),
pack_ref: Some("core".to_string()), pack_ref: Some("core".to_string()),
}; };

View File

@@ -66,7 +66,6 @@ pub async fn list_workflows(
let filters = WorkflowSearchFilters { let filters = WorkflowSearchFilters {
pack: None, pack: None,
pack_ref: search_params.pack_ref.clone(), pack_ref: search_params.pack_ref.clone(),
enabled: search_params.enabled,
tags, tags,
search: search_params.search.clone(), search: search_params.search.clone(),
limit: pagination.limit(), limit: pagination.limit(),
@@ -113,7 +112,6 @@ pub async fn list_workflows_by_pack(
let filters = WorkflowSearchFilters { let filters = WorkflowSearchFilters {
pack: None, pack: None,
pack_ref: Some(pack_ref), pack_ref: Some(pack_ref),
enabled: None,
tags: None, tags: None,
search: None, search: None,
limit: pagination.limit(), limit: pagination.limit(),
@@ -208,7 +206,6 @@ pub async fn create_workflow(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: request.definition, definition: request.definition,
tags: request.tags.clone().unwrap_or_default(), tags: request.tags.clone().unwrap_or_default(),
enabled: request.enabled.unwrap_or(true),
}; };
let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?; let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?;
@@ -275,7 +272,6 @@ pub async fn update_workflow(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: request.definition, definition: request.definition,
tags: request.tags, tags: request.tags,
enabled: request.enabled,
}; };
let workflow = let workflow =
@@ -408,7 +404,6 @@ pub async fn save_workflow_file(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: definition_json, definition: definition_json,
tags: request.tags.clone().unwrap_or_default(), tags: request.tags.clone().unwrap_or_default(),
enabled: request.enabled.unwrap_or(true),
}; };
let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?; let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?;
@@ -489,7 +484,6 @@ pub async fn update_workflow_file(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: Some(definition_json), definition: Some(definition_json),
tags: request.tags, tags: request.tags,
enabled: request.enabled,
}; };
let workflow = let workflow =
@@ -647,7 +641,6 @@ fn build_action_yaml(pack_ref: &str, request: &SaveWorkflowFileRequest) -> Strin
lines.push(format!("description: \"{}\"", desc.replace('"', "\\\""))); lines.push(format!("description: \"{}\"", desc.replace('"', "\\\"")));
} }
} }
lines.push("enabled: true".to_string());
lines.push(format!( lines.push(format!(
"workflow_file: workflows/{}.workflow.yaml", "workflow_file: workflows/{}.workflow.yaml",
request.name request.name

View File

@@ -551,7 +551,6 @@ pub async fn create_test_workflow(
] ]
}), }),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: true,
}; };
Ok(WorkflowDefinitionRepository::create(pool, input).await?) Ok(WorkflowDefinitionRepository::create(pool, input).await?)

View File

@@ -22,7 +22,6 @@ ref: {}.example_workflow
label: Example Workflow label: Example Workflow
description: A test workflow for integration testing description: A test workflow for integration testing
version: "1.0.0" version: "1.0.0"
enabled: true
parameters: parameters:
message: message:
type: string type: string
@@ -46,7 +45,6 @@ ref: {}.another_workflow
label: Another Workflow label: Another Workflow
description: Second test workflow description: Second test workflow
version: "1.0.0" version: "1.0.0"
enabled: false
tasks: tasks:
- name: task1 - name: task1
action: core.noop action: core.noop

View File

@@ -46,8 +46,7 @@ async fn test_create_workflow_success() {
} }
] ]
}, },
"tags": ["test", "automation"], "tags": ["test", "automation"]
"enabled": true
}), }),
ctx.token(), ctx.token(),
) )
@@ -60,7 +59,6 @@ async fn test_create_workflow_success() {
assert_eq!(body["data"]["ref"], "test-pack.test_workflow"); assert_eq!(body["data"]["ref"], "test-pack.test_workflow");
assert_eq!(body["data"]["label"], "Test Workflow"); assert_eq!(body["data"]["label"], "Test Workflow");
assert_eq!(body["data"]["version"], "1.0.0"); assert_eq!(body["data"]["version"], "1.0.0");
assert_eq!(body["data"]["enabled"], true);
assert!(body["data"]["tags"].as_array().unwrap().len() == 2); assert!(body["data"]["tags"].as_array().unwrap().len() == 2);
} }
@@ -85,7 +83,6 @@ async fn test_create_workflow_duplicate_ref() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -152,7 +149,6 @@ async fn test_get_workflow_by_ref() {
out_schema: None, out_schema: None,
definition: json!({"tasks": [{"name": "task1"}]}), definition: json!({"tasks": [{"name": "task1"}]}),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -206,7 +202,6 @@ async fn test_list_workflows() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: i % 2 == 1, // Odd ones enabled
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -256,7 +251,6 @@ async fn test_list_workflows_by_pack() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -275,7 +269,6 @@ async fn test_list_workflows_by_pack() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -308,14 +301,14 @@ async fn test_list_workflows_with_filters() {
let pack_name = unique_pack_name(); let pack_name = unique_pack_name();
let pack = create_test_pack(&ctx.pool, &pack_name).await.unwrap(); let pack = create_test_pack(&ctx.pool, &pack_name).await.unwrap();
// Create workflows with different tags and enabled status // Create workflows with different tags
let workflows = vec![ let workflows = vec![
("workflow1", vec!["incident", "approval"], true), ("workflow1", vec!["incident", "approval"]),
("workflow2", vec!["incident"], false), ("workflow2", vec!["incident"]),
("workflow3", vec!["automation"], true), ("workflow3", vec!["automation"]),
]; ];
for (ref_name, tags, enabled) in workflows { for (ref_name, tags) in workflows {
let input = CreateWorkflowDefinitionInput { let input = CreateWorkflowDefinitionInput {
r#ref: format!("test-pack.{}", ref_name), r#ref: format!("test-pack.{}", ref_name),
pack: pack.id, pack: pack.id,
@@ -327,24 +320,12 @@ async fn test_list_workflows_with_filters() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: tags.iter().map(|s| s.to_string()).collect(), tags: tags.iter().map(|s| s.to_string()).collect(),
enabled,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
.unwrap(); .unwrap();
} }
// Filter by enabled (and pack_ref for isolation)
let response = ctx
.get(
&format!("/api/v1/workflows?enabled=true&pack_ref={}", pack_name),
ctx.token(),
)
.await
.unwrap();
let body: Value = response.json().await.unwrap();
assert_eq!(body["data"].as_array().unwrap().len(), 2);
// Filter by tag (and pack_ref for isolation) // Filter by tag (and pack_ref for isolation)
let response = ctx let response = ctx
.get( .get(
@@ -387,7 +368,6 @@ async fn test_update_workflow() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -400,8 +380,7 @@ async fn test_update_workflow() {
json!({ json!({
"label": "Updated Label", "label": "Updated Label",
"description": "Updated description", "description": "Updated description",
"version": "1.1.0", "version": "1.1.0"
"enabled": false
}), }),
ctx.token(), ctx.token(),
) )
@@ -414,7 +393,6 @@ async fn test_update_workflow() {
assert_eq!(body["data"]["label"], "Updated Label"); assert_eq!(body["data"]["label"], "Updated Label");
assert_eq!(body["data"]["description"], "Updated description"); assert_eq!(body["data"]["description"], "Updated description");
assert_eq!(body["data"]["version"], "1.1.0"); assert_eq!(body["data"]["version"], "1.1.0");
assert_eq!(body["data"]["enabled"], false);
} }
#[tokio::test] #[tokio::test]
@@ -455,7 +433,6 @@ async fn test_delete_workflow() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await

View File

@@ -86,9 +86,6 @@ struct ActionYaml {
#[serde(default)] #[serde(default)]
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
/// Whether the action is enabled
#[serde(default)]
enabled: Option<bool>,
} }
// ── API DTOs ──────────────────────────────────────────────────────────── // ── API DTOs ────────────────────────────────────────────────────────────
@@ -109,8 +106,6 @@ struct SaveWorkflowFileRequest {
out_schema: Option<serde_json::Value>, out_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -127,7 +122,6 @@ struct WorkflowResponse {
out_schema: Option<serde_json::Value>, out_schema: Option<serde_json::Value>,
definition: serde_json::Value, definition: serde_json::Value,
tags: Vec<String>, tags: Vec<String>,
enabled: bool,
created: String, created: String,
updated: String, updated: String,
} }
@@ -142,7 +136,6 @@ struct WorkflowSummary {
description: Option<String>, description: Option<String>,
version: String, version: String,
tags: Vec<String>, tags: Vec<String>,
enabled: bool,
created: String, created: String,
updated: String, updated: String,
} }
@@ -281,7 +274,6 @@ async fn handle_upload(
param_schema: action.parameters.clone(), param_schema: action.parameters.clone(),
out_schema: action.output.clone(), out_schema: action.output.clone(),
tags: action.tags.clone(), tags: action.tags.clone(),
enabled: action.enabled,
}; };
// ── 6. Print progress ─────────────────────────────────────────────── // ── 6. Print progress ───────────────────────────────────────────────
@@ -357,7 +349,6 @@ async fn handle_upload(
response.tags.join(", ") response.tags.join(", ")
}, },
), ),
("Enabled", output::format_bool(response.enabled)),
]); ]);
} }
} }
@@ -414,7 +405,6 @@ async fn handle_list(
"Pack", "Pack",
"Label", "Label",
"Version", "Version",
"Enabled",
"Tags", "Tags",
], ],
); );
@@ -426,7 +416,6 @@ async fn handle_list(
wf.pack_ref.clone(), wf.pack_ref.clone(),
output::truncate(&wf.label, 30), output::truncate(&wf.label, 30),
wf.version.clone(), wf.version.clone(),
output::format_bool(wf.enabled),
if wf.tags.is_empty() { if wf.tags.is_empty() {
"-".to_string() "-".to_string()
} else { } else {
@@ -478,7 +467,6 @@ async fn handle_show(
.unwrap_or_else(|| "-".to_string()), .unwrap_or_else(|| "-".to_string()),
), ),
("Version", workflow.version.clone()), ("Version", workflow.version.clone()),
("Enabled", output::format_bool(workflow.enabled)),
( (
"Tags", "Tags",
if workflow.tags.is_empty() { if workflow.tags.is_empty() {

View File

@@ -1385,7 +1385,6 @@ pub mod workflow {
pub out_schema: Option<JsonSchema>, pub out_schema: Option<JsonSchema>,
pub definition: JsonDict, pub definition: JsonDict,
pub tags: Vec<String>, pub tags: Vec<String>,
pub enabled: bool,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
pub updated: DateTime<Utc>, pub updated: DateTime<Utc>,
} }

View File

@@ -1131,7 +1131,6 @@ impl<'a> PackComponentLoader<'a> {
out_schema, out_schema,
definition: Some(definition_json), definition: Some(definition_json),
tags: Some(tags), tags: Some(tags),
enabled: Some(true),
}; };
WorkflowDefinitionRepository::update(self.pool, existing.id, update_input).await?; WorkflowDefinitionRepository::update(self.pool, existing.id, update_input).await?;
@@ -1159,7 +1158,6 @@ impl<'a> PackComponentLoader<'a> {
out_schema, out_schema,
definition: definition_json, definition: definition_json,
tags, tags,
enabled: true,
}; };
let created = WorkflowDefinitionRepository::create(self.pool, create_input).await?; let created = WorkflowDefinitionRepository::create(self.pool, create_input).await?;

View File

@@ -20,8 +20,6 @@ pub struct WorkflowSearchFilters {
pub pack: Option<Id>, pub pack: Option<Id>,
/// Filter by pack reference /// Filter by pack reference
pub pack_ref: Option<String>, pub pack_ref: Option<String>,
/// Filter by enabled status
pub enabled: Option<bool>,
/// Filter by tags (OR across tags — matches if any tag is present) /// Filter by tags (OR across tags — matches if any tag is present)
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Text search across label and description (case-insensitive substring) /// Text search across label and description (case-insensitive substring)
@@ -62,7 +60,6 @@ pub struct CreateWorkflowDefinitionInput {
pub out_schema: Option<JsonSchema>, pub out_schema: Option<JsonSchema>,
pub definition: JsonDict, pub definition: JsonDict,
pub tags: Vec<String>, pub tags: Vec<String>,
pub enabled: bool,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -74,7 +71,6 @@ pub struct UpdateWorkflowDefinitionInput {
pub out_schema: Option<JsonSchema>, pub out_schema: Option<JsonSchema>,
pub definition: Option<JsonDict>, pub definition: Option<JsonDict>,
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
pub enabled: Option<bool>,
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -84,7 +80,7 @@ impl FindById for WorkflowDefinitionRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, WorkflowDefinition>( sqlx::query_as::<_, WorkflowDefinition>(
"SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated "SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated
FROM workflow_definition FROM workflow_definition
WHERE id = $1" WHERE id = $1"
) )
@@ -102,7 +98,7 @@ impl FindByRef for WorkflowDefinitionRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, WorkflowDefinition>( sqlx::query_as::<_, WorkflowDefinition>(
"SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated "SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated
FROM workflow_definition FROM workflow_definition
WHERE ref = $1" WHERE ref = $1"
) )
@@ -120,7 +116,7 @@ impl List for WorkflowDefinitionRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, WorkflowDefinition>( sqlx::query_as::<_, WorkflowDefinition>(
"SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated "SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated
FROM workflow_definition FROM workflow_definition
ORDER BY created DESC ORDER BY created DESC
LIMIT 1000" LIMIT 1000"
@@ -141,9 +137,9 @@ impl Create for WorkflowDefinitionRepository {
{ {
sqlx::query_as::<_, WorkflowDefinition>( sqlx::query_as::<_, WorkflowDefinition>(
"INSERT INTO workflow_definition "INSERT INTO workflow_definition
(ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled) (ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated" RETURNING id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated"
) )
.bind(&input.r#ref) .bind(&input.r#ref)
.bind(input.pack) .bind(input.pack)
@@ -155,7 +151,6 @@ impl Create for WorkflowDefinitionRepository {
.bind(&input.out_schema) .bind(&input.out_schema)
.bind(&input.definition) .bind(&input.definition)
.bind(&input.tags) .bind(&input.tags)
.bind(input.enabled)
.fetch_one(executor) .fetch_one(executor)
.await .await
.map_err(Into::into) .map_err(Into::into)
@@ -219,20 +214,12 @@ impl Update for WorkflowDefinitionRepository {
query.push("tags = ").push_bind(tags); query.push("tags = ").push_bind(tags);
has_updates = true; has_updates = true;
} }
if let Some(enabled) = input.enabled {
if has_updates {
query.push(", ");
}
query.push("enabled = ").push_bind(enabled);
has_updates = true;
}
if !has_updates { if !has_updates {
return Self::get_by_id(executor, id).await; return Self::get_by_id(executor, id).await;
} }
query.push(", updated = NOW() WHERE id = ").push_bind(id); query.push(", updated = NOW() WHERE id = ").push_bind(id);
query.push(" RETURNING id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated"); query.push(" RETURNING id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated");
query query
.build_query_as::<WorkflowDefinition>() .build_query_as::<WorkflowDefinition>()
@@ -269,7 +256,7 @@ impl WorkflowDefinitionRepository {
where where
E: Executor<'e, Database = Postgres> + Copy + 'e, E: Executor<'e, Database = Postgres> + Copy + 'e,
{ {
let select_cols = "id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated"; let select_cols = "id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated";
let mut qb: QueryBuilder<'_, Postgres> = let mut qb: QueryBuilder<'_, Postgres> =
QueryBuilder::new(format!("SELECT {select_cols} FROM workflow_definition")); QueryBuilder::new(format!("SELECT {select_cols} FROM workflow_definition"));
@@ -301,9 +288,6 @@ impl WorkflowDefinitionRepository {
if let Some(ref pack_ref) = filters.pack_ref { if let Some(ref pack_ref) = filters.pack_ref {
push_condition!("pack_ref = ", pack_ref.clone()); push_condition!("pack_ref = ", pack_ref.clone());
} }
if let Some(enabled) = filters.enabled {
push_condition!("enabled = ", enabled);
}
if let Some(ref tags) = filters.tags { if let Some(ref tags) = filters.tags {
if !tags.is_empty() { if !tags.is_empty() {
// Use PostgreSQL array overlap operator: tags && ARRAY[...] // Use PostgreSQL array overlap operator: tags && ARRAY[...]
@@ -359,7 +343,7 @@ impl WorkflowDefinitionRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, WorkflowDefinition>( sqlx::query_as::<_, WorkflowDefinition>(
"SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated "SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated
FROM workflow_definition FROM workflow_definition
WHERE pack = $1 WHERE pack = $1
ORDER BY label" ORDER BY label"
@@ -379,7 +363,7 @@ impl WorkflowDefinitionRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, WorkflowDefinition>( sqlx::query_as::<_, WorkflowDefinition>(
"SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated "SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated
FROM workflow_definition FROM workflow_definition
WHERE pack_ref = $1 WHERE pack_ref = $1
ORDER BY label" ORDER BY label"
@@ -403,29 +387,13 @@ impl WorkflowDefinitionRepository {
Ok(result.0) Ok(result.0)
} }
/// Find all enabled workflows
pub async fn find_enabled<'e, E>(executor: E) -> Result<Vec<WorkflowDefinition>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, WorkflowDefinition>(
"SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated
FROM workflow_definition
WHERE enabled = true
ORDER BY label"
)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Find workflows by tag /// Find workflows by tag
pub async fn find_by_tag<'e, E>(executor: E, tag: &str) -> Result<Vec<WorkflowDefinition>> pub async fn find_by_tag<'e, E>(executor: E, tag: &str) -> Result<Vec<WorkflowDefinition>>
where where
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, WorkflowDefinition>( sqlx::query_as::<_, WorkflowDefinition>(
"SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, enabled, created, updated "SELECT id, ref, pack, pack_ref, label, description, version, param_schema, out_schema, definition, tags, created, updated
FROM workflow_definition FROM workflow_definition
WHERE $1 = ANY(tags) WHERE $1 = ANY(tags)
ORDER BY label" ORDER BY label"

View File

@@ -379,7 +379,6 @@ impl WorkflowRegistrar {
out_schema: workflow.output.clone(), out_schema: workflow.output.clone(),
definition, definition,
tags: workflow.tags.clone(), tags: workflow.tags.clone(),
enabled: true,
}; };
let created = WorkflowDefinitionRepository::create(&self.pool, input).await?; let created = WorkflowDefinitionRepository::create(&self.pool, input).await?;
@@ -411,7 +410,6 @@ impl WorkflowRegistrar {
out_schema: workflow.output.clone(), out_schema: workflow.output.clone(),
definition: Some(definition), definition: Some(definition),
tags: Some(workflow.tags.clone()), tags: Some(workflow.tags.clone()),
enabled: Some(true),
}; };
let updated = WorkflowDefinitionRepository::update(&self.pool, *workflow_id, input).await?; let updated = WorkflowDefinitionRepository::update(&self.pool, *workflow_id, input).await?;

View File

@@ -286,21 +286,6 @@ impl ExecutionScheduler {
) )
})?; })?;
if !workflow_def.enabled {
warn!(
"Workflow '{}' is disabled, failing execution {}",
workflow_def.r#ref, execution.id
);
let mut fail = execution.clone();
fail.status = ExecutionStatus::Failed;
fail.result = Some(serde_json::json!({
"error": format!("Workflow '{}' is disabled", workflow_def.r#ref),
"succeeded": false,
}));
ExecutionRepository::update(pool, fail.id, fail.into()).await?;
return Ok(());
}
// Parse workflow definition JSON into the strongly-typed struct // Parse workflow definition JSON into the strongly-typed struct
let definition: WorkflowDefinition = let definition: WorkflowDefinition =
serde_json::from_value(workflow_def.definition.clone()).map_err(|e| { serde_json::from_value(workflow_def.definition.clone()).map_err(|e| {
@@ -900,9 +885,25 @@ impl ExecutionScheduler {
return Ok(()); return Ok(());
} }
// Cancelled workflow: don't dispatch new tasks, but check whether all let parent_execution = ExecutionRepository::find_by_id(pool, workflow_execution.execution)
// running children have now finished. When none remain, finalize the .await?
// parent execution as Cancelled so it doesn't stay stuck in "Canceling". .ok_or_else(|| {
anyhow::anyhow!(
"Parent execution {} not found for workflow_execution {}",
workflow_execution.execution,
workflow_execution_id
)
})?;
// Cancellation must be a hard stop for workflow orchestration. Once
// either the workflow record, the parent execution, or the completed
// child itself is in a cancellation state, do not evaluate transitions,
// release more with_items siblings, or dispatch any successor tasks.
if Self::should_halt_workflow_advancement(
workflow_execution.status,
parent_execution.status,
execution.status,
) {
if workflow_execution.status == ExecutionStatus::Cancelled { if workflow_execution.status == ExecutionStatus::Cancelled {
let running = Self::count_running_workflow_children( let running = Self::count_running_workflow_children(
pool, pool,
@@ -926,11 +927,21 @@ impl ExecutionScheduler {
.await?; .await?;
} else { } else {
debug!( debug!(
"Cancelled workflow_execution {} still has {} running children, \ "Workflow_execution {} is cancelling/cancelled with {} running children, \
waiting for them to finish", skipping advancement",
workflow_execution_id, running workflow_execution_id, running
); );
} }
} else {
debug!(
"Workflow_execution {} advancement halted due to cancellation state \
(workflow: {:?}, parent: {:?}, child: {:?})",
workflow_execution_id,
workflow_execution.status,
parent_execution.status,
execution.status
);
}
return Ok(()); return Ok(());
} }
@@ -1116,17 +1127,6 @@ impl ExecutionScheduler {
} }
} }
// Load the parent execution for context
let parent_execution = ExecutionRepository::find_by_id(pool, workflow_execution.execution)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"Parent execution {} not found for workflow_execution {}",
workflow_execution.execution,
workflow_execution_id
)
})?;
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Rebuild the WorkflowContext from persisted state + completed task // Rebuild the WorkflowContext from persisted state + completed task
// results so that successor task inputs can be rendered. // results so that successor task inputs can be rendered.
@@ -1414,6 +1414,23 @@ impl ExecutionScheduler {
Ok(count) Ok(count)
} }
fn should_halt_workflow_advancement(
workflow_status: ExecutionStatus,
parent_status: ExecutionStatus,
child_status: ExecutionStatus,
) -> bool {
matches!(
workflow_status,
ExecutionStatus::Canceling | ExecutionStatus::Cancelled
) || matches!(
parent_status,
ExecutionStatus::Canceling | ExecutionStatus::Cancelled
) || matches!(
child_status,
ExecutionStatus::Canceling | ExecutionStatus::Cancelled
)
}
/// Finalize a cancelled workflow by updating the parent `execution` record /// Finalize a cancelled workflow by updating the parent `execution` record
/// to `Cancelled`. The `workflow_execution` record is already `Cancelled` /// to `Cancelled`. The `workflow_execution` record is already `Cancelled`
/// (set by `cancel_workflow_children`); this only touches the parent. /// (set by `cancel_workflow_children`); this only touches the parent.
@@ -1918,4 +1935,28 @@ mod tests {
assert_eq!(update.status, Some(ExecutionStatus::Scheduled)); assert_eq!(update.status, Some(ExecutionStatus::Scheduled));
assert_eq!(update.worker, Some(99)); assert_eq!(update.worker, Some(99));
} }
#[test]
fn test_workflow_advancement_halts_for_any_cancellation_state() {
assert!(ExecutionScheduler::should_halt_workflow_advancement(
ExecutionStatus::Running,
ExecutionStatus::Canceling,
ExecutionStatus::Completed
));
assert!(ExecutionScheduler::should_halt_workflow_advancement(
ExecutionStatus::Cancelled,
ExecutionStatus::Running,
ExecutionStatus::Failed
));
assert!(ExecutionScheduler::should_halt_workflow_advancement(
ExecutionStatus::Running,
ExecutionStatus::Running,
ExecutionStatus::Cancelled
));
assert!(!ExecutionScheduler::should_halt_workflow_advancement(
ExecutionStatus::Running,
ExecutionStatus::Running,
ExecutionStatus::Failed
));
}
} }

View File

@@ -379,7 +379,6 @@ impl WorkflowRegistrar {
out_schema: workflow.output.clone(), out_schema: workflow.output.clone(),
definition, definition,
tags: workflow.tags.clone(), tags: workflow.tags.clone(),
enabled: true,
}; };
let created = WorkflowDefinitionRepository::create(&self.pool, input).await?; let created = WorkflowDefinitionRepository::create(&self.pool, input).await?;
@@ -411,7 +410,6 @@ impl WorkflowRegistrar {
out_schema: workflow.output.clone(), out_schema: workflow.output.clone(),
definition: Some(definition), definition: Some(definition),
tags: Some(workflow.tags.clone()), tags: Some(workflow.tags.clone()),
enabled: Some(true),
}; };
let updated = WorkflowDefinitionRepository::update(&self.pool, *workflow_id, input).await?; let updated = WorkflowDefinitionRepository::update(&self.pool, *workflow_id, input).await?;

View File

@@ -26,14 +26,12 @@ CREATE TABLE workflow_definition (
out_schema JSONB, out_schema JSONB,
definition JSONB NOT NULL, definition JSONB NOT NULL,
tags TEXT[] DEFAULT '{}', tags TEXT[] DEFAULT '{}',
enabled BOOLEAN DEFAULT true NOT NULL,
created TIMESTAMPTZ DEFAULT NOW() NOT NULL, created TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated TIMESTAMPTZ DEFAULT NOW() NOT NULL updated TIMESTAMPTZ DEFAULT NOW() NOT NULL
); );
-- Indexes -- Indexes
CREATE INDEX idx_workflow_def_pack ON workflow_definition(pack); CREATE INDEX idx_workflow_def_pack ON workflow_definition(pack);
CREATE INDEX idx_workflow_def_enabled ON workflow_definition(enabled);
CREATE INDEX idx_workflow_def_ref ON workflow_definition(ref); CREATE INDEX idx_workflow_def_ref ON workflow_definition(ref);
CREATE INDEX idx_workflow_def_tags ON workflow_definition USING gin(tags); CREATE INDEX idx_workflow_def_tags ON workflow_definition USING gin(tags);
@@ -137,7 +135,6 @@ SELECT
wd.ref as workflow_ref, wd.ref as workflow_ref,
wd.label, wd.label,
wd.version, wd.version,
wd.enabled,
a.id as action_id, a.id as action_id,
a.ref as action_ref, a.ref as action_ref,
a.pack as pack_id, a.pack as pack_id,

View File

@@ -22,10 +22,6 @@ export type ApiResponse_WorkflowResponse = {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/** /**
* Workflow ID * Workflow ID
*/ */
@@ -72,4 +68,3 @@ export type ApiResponse_WorkflowResponse = {
*/ */
message?: string | null; message?: string | null;
}; };

View File

@@ -14,10 +14,6 @@ export type CreateWorkflowRequest = {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled?: boolean | null;
/** /**
* Human-readable label * Human-readable label
*/ */
@@ -47,4 +43,3 @@ export type CreateWorkflowRequest = {
*/ */
version: string; version: string;
}; };

View File

@@ -19,10 +19,6 @@ export type PaginatedResponse_WorkflowSummary = {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/** /**
* Workflow ID * Workflow ID
*/ */
@@ -57,4 +53,3 @@ export type PaginatedResponse_WorkflowSummary = {
*/ */
pagination: PaginationMeta; pagination: PaginationMeta;
}; };

View File

@@ -14,10 +14,6 @@ export type UpdateWorkflowRequest = {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled?: boolean | null;
/** /**
* Human-readable label * Human-readable label
*/ */
@@ -39,4 +35,3 @@ export type UpdateWorkflowRequest = {
*/ */
version?: string | null; version?: string | null;
}; };

View File

@@ -18,10 +18,6 @@ export type WorkflowResponse = {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/** /**
* Workflow ID * Workflow ID
*/ */
@@ -63,4 +59,3 @@ export type WorkflowResponse = {
*/ */
version: string; version: string;
}; };

View File

@@ -14,10 +14,6 @@ export type WorkflowSummary = {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/** /**
* Workflow ID * Workflow ID
*/ */
@@ -47,4 +43,3 @@ export type WorkflowSummary = {
*/ */
version: string; version: string;
}; };

View File

@@ -57,7 +57,6 @@ export class WorkflowsService {
page, page,
pageSize, pageSize,
tags, tags,
enabled,
search, search,
packRef, packRef,
}: { }: {
@@ -73,10 +72,6 @@ export class WorkflowsService {
* Filter by tag(s) - comma-separated list * Filter by tag(s) - comma-separated list
*/ */
tags?: string | null, tags?: string | null,
/**
* Filter by enabled status
*/
enabled?: boolean | null,
/** /**
* Search term for label/description (case-insensitive) * Search term for label/description (case-insensitive)
*/ */
@@ -93,7 +88,6 @@ export class WorkflowsService {
'page': page, 'page': page,
'page_size': pageSize, 'page_size': pageSize,
'tags': tags, 'tags': tags,
'enabled': enabled,
'search': search, 'search': search,
'pack_ref': packRef, 'pack_ref': packRef,
}, },
@@ -125,10 +119,6 @@ export class WorkflowsService {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/** /**
* Workflow ID * Workflow ID
*/ */
@@ -216,10 +206,6 @@ export class WorkflowsService {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/** /**
* Workflow ID * Workflow ID
*/ */
@@ -308,10 +294,6 @@ export class WorkflowsService {
* Workflow description * Workflow description
*/ */
description?: string | null; description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/** /**
* Workflow ID * Workflow ID
*/ */

View File

@@ -63,7 +63,9 @@ const ARROW_LENGTH = 12;
const ARROW_HALF_WIDTH = 5; const ARROW_HALF_WIDTH = 5;
const ARROW_DIRECTION_LOOKBACK_PX = 10; const ARROW_DIRECTION_LOOKBACK_PX = 10;
const ARROW_DIRECTION_SAMPLES = 48; const ARROW_DIRECTION_SAMPLES = 48;
const ARROW_SHAFT_OVERLAP_PX = 2; // Keep a small amount of shaft under the arrowhead so sample-based trimming
// does not leave a visible gap on simple bezier edges without waypoints.
const ARROW_SHAFT_OVERLAP_PX = 4;
/** Color for each edge type (alias for shared constant) */ /** Color for each edge type (alias for shared constant) */
const EDGE_COLORS = EDGE_TYPE_COLORS; const EDGE_COLORS = EDGE_TYPE_COLORS;

View File

@@ -1,11 +1,29 @@
import { useState } from "react"; import { useState } from "react";
import { Pencil, Plus, X, LogIn, LogOut } from "lucide-react"; import {
Pencil,
Plus,
X,
LogIn,
LogOut,
SlidersHorizontal,
} from "lucide-react";
import SchemaBuilder from "@/components/common/SchemaBuilder"; import SchemaBuilder from "@/components/common/SchemaBuilder";
import type { ParamDefinition } from "@/types/workflow"; import type { CancellationPolicy, ParamDefinition } from "@/types/workflow";
import { CANCELLATION_POLICY_LABELS } from "@/types/workflow";
interface WorkflowInputsPanelProps { interface WorkflowInputsPanelProps {
label: string;
version: string;
description: string;
tags: string[];
cancellationPolicy: CancellationPolicy;
parameters: Record<string, ParamDefinition>; parameters: Record<string, ParamDefinition>;
output: Record<string, ParamDefinition>; output: Record<string, ParamDefinition>;
onLabelChange: (label: string) => void;
onVersionChange: (version: string) => void;
onDescriptionChange: (description: string) => void;
onTagsChange: (tags: string[]) => void;
onCancellationPolicyChange: (policy: CancellationPolicy) => void;
onParametersChange: (parameters: Record<string, ParamDefinition>) => void; onParametersChange: (parameters: Record<string, ParamDefinition>) => void;
onOutputChange: (output: Record<string, ParamDefinition>) => void; onOutputChange: (output: Record<string, ParamDefinition>) => void;
} }
@@ -82,8 +100,18 @@ function ParamSummaryList({
} }
export default function WorkflowInputsPanel({ export default function WorkflowInputsPanel({
label,
version,
description,
tags,
cancellationPolicy,
parameters, parameters,
output, output,
onLabelChange,
onVersionChange,
onDescriptionChange,
onTagsChange,
onCancellationPolicyChange,
onParametersChange, onParametersChange,
onOutputChange, onOutputChange,
}: WorkflowInputsPanelProps) { }: WorkflowInputsPanelProps) {
@@ -123,8 +151,103 @@ export default function WorkflowInputsPanel({
<> <>
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
<div className="flex-1 overflow-y-auto p-3 space-y-4"> <div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Input Parameters */}
<div> <div>
<div className="flex items-center gap-1.5 mb-2">
<SlidersHorizontal className="w-3.5 h-3.5 text-blue-500" />
<h4 className="text-xs font-semibold text-gray-600 uppercase tracking-wider">
Workflow
</h4>
</div>
<div className="space-y-2.5 rounded-lg border border-gray-200 bg-white p-3">
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Label
</label>
<input
type="text"
value={label}
onChange={(e) => onLabelChange(e.target.value)}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Workflow Label"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Description
</label>
<input
type="text"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Workflow description"
/>
</div>
<div className="grid grid-cols-1 gap-2">
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Version
</label>
<input
type="text"
value={version}
onChange={(e) => onVersionChange(e.target.value)}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="1.0.0"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Tags
</label>
<input
type="text"
value={tags.join(", ")}
onChange={(e) =>
onTagsChange(
e.target.value
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
)
}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="tag-one, tag-two"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Cancellation Policy
</label>
<select
value={cancellationPolicy}
onChange={(e) =>
onCancellationPolicyChange(
e.target.value as CancellationPolicy,
)
}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
title="Controls how running tasks behave when the workflow is cancelled"
>
{Object.entries(CANCELLATION_POLICY_LABELS).map(
([value, optionLabel]) => (
<option key={value} value={value}>
{optionLabel}
</option>
),
)}
</select>
</div>
</div>
</div>
{/* Input Parameters */}
<div className="border-t border-gray-200 pt-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<LogIn className="w-3.5 h-3.5 text-green-500" /> <LogIn className="w-3.5 h-3.5 text-green-500" />

View File

@@ -10,7 +10,6 @@ interface WorkflowsQueryParams {
pageSize?: number; pageSize?: number;
packRef?: string; packRef?: string;
tags?: string; tags?: string;
enabled?: boolean;
search?: string; search?: string;
} }
@@ -23,7 +22,6 @@ export function useWorkflows(params?: WorkflowsQueryParams) {
page: params?.page || 1, page: params?.page || 1,
pageSize: params?.pageSize || 50, pageSize: params?.pageSize || 50,
tags: params?.tags, tags: params?.tags,
enabled: params?.enabled,
search: params?.search, search: params?.search,
packRef: params?.packRef, packRef: params?.packRef,
}); });

View File

@@ -15,6 +15,7 @@ import {
ExternalLink, ExternalLink,
Copy, Copy,
Check, Check,
PanelLeftClose,
} from "lucide-react"; } from "lucide-react";
import SearchableSelect from "@/components/common/SearchableSelect"; import SearchableSelect from "@/components/common/SearchableSelect";
import yaml from "js-yaml"; import yaml from "js-yaml";
@@ -40,7 +41,6 @@ import type {
WorkflowBuilderState, WorkflowBuilderState,
PaletteAction, PaletteAction,
TransitionPreset, TransitionPreset,
CancellationPolicy,
} from "@/types/workflow"; } from "@/types/workflow";
import { import {
generateUniqueTaskName, generateUniqueTaskName,
@@ -54,7 +54,6 @@ import {
removeTaskFromTransitions, removeTaskFromTransitions,
renameTaskInTransitions, renameTaskInTransitions,
findStartingTaskIds, findStartingTaskIds,
CANCELLATION_POLICY_LABELS,
} from "@/types/workflow"; } from "@/types/workflow";
const INITIAL_STATE: WorkflowBuilderState = { const INITIAL_STATE: WorkflowBuilderState = {
@@ -68,10 +67,13 @@ const INITIAL_STATE: WorkflowBuilderState = {
vars: {}, vars: {},
tasks: [], tasks: [],
tags: [], tags: [],
enabled: true,
cancellationPolicy: "allow_finish", cancellationPolicy: "allow_finish",
}; };
const ACTIONS_SIDEBAR_WIDTH = 256;
const WORKFLOW_OPTIONS_DEFAULT_WIDTH = 360;
const WORKFLOW_OPTIONS_STORAGE_KEY = "workflow-builder-options-width";
export default function WorkflowBuilderPage() { export default function WorkflowBuilderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { ref: editRef } = useParams<{ ref?: string }>(); const { ref: editRef } = useParams<{ ref?: string }>();
@@ -104,10 +106,19 @@ export default function WorkflowBuilderPage() {
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [showYamlPreview, setShowYamlPreview] = useState(false); const [showYamlPreview, setShowYamlPreview] = useState(false);
const [sidebarTab, setSidebarTab] = useState<"actions" | "inputs">("actions"); const [sidebarTab, setSidebarTab] = useState<"actions" | "inputs">("actions");
const [workflowOptionsWidth, setWorkflowOptionsWidth] = useState<number>(() => {
if (typeof window === "undefined") {
return WORKFLOW_OPTIONS_DEFAULT_WIDTH;
}
const saved = window.localStorage.getItem(WORKFLOW_OPTIONS_STORAGE_KEY);
const parsed = saved ? Number(saved) : NaN;
return Number.isFinite(parsed) ? parsed : WORKFLOW_OPTIONS_DEFAULT_WIDTH;
});
const [highlightedTransition, setHighlightedTransition] = useState<{ const [highlightedTransition, setHighlightedTransition] = useState<{
taskId: string; taskId: string;
transitionIndex: number; transitionIndex: number;
} | null>(null); } | null>(null);
const [isResizingSidebar, setIsResizingSidebar] = useState(false);
// Start-node warning toast state // Start-node warning toast state
const [startWarningVisible, setStartWarningVisible] = useState(false); const [startWarningVisible, setStartWarningVisible] = useState(false);
@@ -261,6 +272,71 @@ export default function WorkflowBuilderPage() {
return null; return null;
}, [state.tasks, startingTaskIds]); }, [state.tasks, startingTaskIds]);
const getMaxWorkflowOptionsWidth = useCallback(() => {
if (typeof window === "undefined") {
return WORKFLOW_OPTIONS_DEFAULT_WIDTH;
}
return Math.max(
ACTIONS_SIDEBAR_WIDTH,
Math.floor(window.innerWidth * 0.5),
);
}, []);
const clampWorkflowOptionsWidth = useCallback(
(width: number) =>
Math.min(
Math.max(Math.round(width), ACTIONS_SIDEBAR_WIDTH),
getMaxWorkflowOptionsWidth(),
),
[getMaxWorkflowOptionsWidth],
);
useEffect(() => {
setWorkflowOptionsWidth((prev) => clampWorkflowOptionsWidth(prev));
}, [clampWorkflowOptionsWidth]);
useEffect(() => {
const handleResize = () => {
setWorkflowOptionsWidth((prev) => clampWorkflowOptionsWidth(prev));
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [clampWorkflowOptionsWidth]);
useEffect(() => {
window.localStorage.setItem(
WORKFLOW_OPTIONS_STORAGE_KEY,
String(workflowOptionsWidth),
);
}, [workflowOptionsWidth]);
useEffect(() => {
if (!isResizingSidebar) return;
const handleMouseMove = (event: MouseEvent) => {
setWorkflowOptionsWidth(clampWorkflowOptionsWidth(event.clientX));
};
const handleMouseUp = () => {
setIsResizingSidebar(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizingSidebar, clampWorkflowOptionsWidth]);
// Render-phase state adjustment: detect warning key changes for immediate // Render-phase state adjustment: detect warning key changes for immediate
// show/hide without refs or synchronous setState inside effects. // show/hide without refs or synchronous setState inside effects.
const warningKey = startNodeWarning const warningKey = startNodeWarning
@@ -475,7 +551,6 @@ export default function WorkflowBuilderPage() {
out_schema: out_schema:
Object.keys(state.output).length > 0 ? state.output : undefined, Object.keys(state.output).length > 0 ? state.output : undefined,
tags: state.tags.length > 0 ? state.tags : undefined, tags: state.tags.length > 0 ? state.tags : undefined,
enabled: state.enabled,
}, },
}); });
} else { } else {
@@ -493,7 +568,6 @@ export default function WorkflowBuilderPage() {
out_schema: out_schema:
Object.keys(state.output).length > 0 ? state.output : undefined, Object.keys(state.output).length > 0 ? state.output : undefined,
tags: state.tags.length > 0 ? state.tags : undefined, tags: state.tags.length > 0 ? state.tags : undefined,
enabled: state.enabled,
}; };
try { try {
await saveWorkflowFile.mutateAsync(fileData); await saveWorkflowFile.mutateAsync(fileData);
@@ -635,6 +709,8 @@ export default function WorkflowBuilderPage() {
const isSaving = saveWorkflowFile.isPending || updateWorkflowFile.isPending; const isSaving = saveWorkflowFile.isPending || updateWorkflowFile.isPending;
const isExecuting = requestExecution.isPending; const isExecuting = requestExecution.isPending;
const sidebarWidth =
sidebarTab === "inputs" ? workflowOptionsWidth : ACTIONS_SIDEBAR_WIDTH;
if (isEditing && workflowLoading) { if (isEditing && workflowLoading) {
return ( return (
@@ -692,24 +768,9 @@ export default function WorkflowBuilderPage() {
/> />
<span className="text-gray-400 text-lg font-light"></span> <span className="text-gray-400 text-lg font-light"></span>
<span className="truncate text-sm text-gray-600">
{/* Label */} {state.label || "Untitled workflow"}
<input </span>
type="text"
value={state.label}
onChange={(e) => updateMetadata({ label: e.target.value })}
className="px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 flex-1 min-w-[160px] max-w-[300px]"
placeholder="Workflow Label"
/>
{/* Version */}
<input
type="text"
value={state.version}
onChange={(e) => updateMetadata({ version: e.target.value })}
className="px-2 py-1.5 border border-gray-300 rounded text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-20"
placeholder="1.0.0"
/>
</div> </div>
</div> </div>
@@ -819,59 +880,6 @@ export default function WorkflowBuilderPage() {
</div> </div>
</div> </div>
{/* Description row (collapsible) */}
<div className="mt-2 flex items-center gap-2">
<input
type="text"
value={state.description}
onChange={(e) => updateMetadata({ description: e.target.value })}
className="flex-1 px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="Workflow description (optional)"
/>
<div className="flex items-center gap-1.5 flex-shrink-0">
<input
type="text"
value={state.tags.join(", ")}
onChange={(e) =>
updateMetadata({
tags: e.target.value
.split(",")
.map((t) => t.trim())
.filter(Boolean),
})
}
className="px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 w-40"
placeholder="Tags (comma-sep)"
/>
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={state.enabled}
onChange={(e) => updateMetadata({ enabled: e.target.checked })}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Enabled
</label>
<select
value={state.cancellationPolicy}
onChange={(e) =>
updateMetadata({
cancellationPolicy: e.target.value as CancellationPolicy,
})
}
className="px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white"
title="Cancellation policy: controls how running tasks behave when the workflow is cancelled"
>
{Object.entries(CANCELLATION_POLICY_LABELS).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</div>
</div>
</div> </div>
{/* Validation errors panel */} {/* Validation errors panel */}
@@ -987,8 +995,11 @@ export default function WorkflowBuilderPage() {
</div> </div>
) : ( ) : (
<> <>
{/* Left sidebar: tabbed Actions / Inputs */} {/* Left sidebar: tabbed Actions / Workflow Options */}
<div className="w-64 border-r border-gray-200 bg-gray-50 flex flex-col h-full overflow-hidden"> <div
className="border-r border-gray-200 bg-gray-50 flex flex-col h-full overflow-hidden relative flex-shrink-0"
style={{ width: sidebarWidth }}
>
{/* Tab header */} {/* Tab header */}
<div className="flex border-b border-gray-200 bg-white flex-shrink-0"> <div className="flex border-b border-gray-200 bg-white flex-shrink-0">
<button <button
@@ -1011,7 +1022,7 @@ export default function WorkflowBuilderPage() {
}`} }`}
> >
<Settings2 className="w-3.5 h-3.5" /> <Settings2 className="w-3.5 h-3.5" />
Inputs Workflow Options
{Object.keys(state.parameters).length > 0 && ( {Object.keys(state.parameters).length > 0 && (
<span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full"> <span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full">
{Object.keys(state.parameters).length} {Object.keys(state.parameters).length}
@@ -1029,8 +1040,22 @@ export default function WorkflowBuilderPage() {
/> />
) : ( ) : (
<WorkflowInputsPanel <WorkflowInputsPanel
label={state.label}
version={state.version}
description={state.description}
tags={state.tags}
cancellationPolicy={state.cancellationPolicy}
parameters={state.parameters} parameters={state.parameters}
output={state.output} output={state.output}
onLabelChange={(label) => updateMetadata({ label })}
onVersionChange={(version) => updateMetadata({ version })}
onDescriptionChange={(description) =>
updateMetadata({ description })
}
onTagsChange={(tags) => updateMetadata({ tags })}
onCancellationPolicyChange={(cancellationPolicy) =>
updateMetadata({ cancellationPolicy })
}
onParametersChange={(parameters) => onParametersChange={(parameters) =>
setState((prev) => ({ ...prev, parameters })) setState((prev) => ({ ...prev, parameters }))
} }
@@ -1039,6 +1064,30 @@ export default function WorkflowBuilderPage() {
} }
/> />
)} )}
{sidebarTab === "inputs" && (
<div
className={`absolute top-0 right-0 h-full w-2 translate-x-1/2 cursor-col-resize group ${
isResizingSidebar ? "z-30" : "z-10"
}`}
onMouseDown={(event) => {
event.preventDefault();
setIsResizingSidebar(true);
}}
title="Resize workflow options panel"
>
<div
className={`mx-auto h-full w-px transition-colors ${
isResizingSidebar
? "bg-blue-500"
: "bg-transparent group-hover:bg-blue-300"
}`}
/>
<div className="absolute top-3 right-0 -translate-y-1/2 translate-x-1/2 rounded-full border border-gray-200 bg-white p-1 text-gray-300 shadow-sm group-hover:text-blue-500">
<PanelLeftClose className="w-3 h-3" />
</div>
</div>
)}
</div> </div>
{/* Center: Canvas */} {/* Center: Canvas */}

View File

@@ -224,8 +224,6 @@ export interface WorkflowBuilderState {
tasks: WorkflowTask[]; tasks: WorkflowTask[];
/** Tags */ /** Tags */
tags: string[]; tags: string[];
/** Whether the workflow is enabled */
enabled: boolean;
/** Cancellation policy (default: allow_finish) */ /** Cancellation policy (default: allow_finish) */
cancellationPolicy: CancellationPolicy; cancellationPolicy: CancellationPolicy;
} }
@@ -285,7 +283,6 @@ export interface ActionYamlDefinition {
ref: string; ref: string;
label: string; label: string;
description?: string; description?: string;
enabled: boolean;
workflow_file: string; workflow_file: string;
parameters?: Record<string, unknown>; parameters?: Record<string, unknown>;
output?: Record<string, unknown>; output?: Record<string, unknown>;
@@ -358,8 +355,6 @@ export interface SaveWorkflowFileRequest {
out_schema?: Record<string, unknown>; out_schema?: Record<string, unknown>;
/** Tags */ /** Tags */
tags?: string[]; tags?: string[];
/** Whether the workflow is enabled */
enabled?: boolean;
} }
/** An action summary used in the action palette */ /** An action summary used in the action palette */
@@ -581,7 +576,6 @@ export function builderStateToActionYaml(
const action: ActionYamlDefinition = { const action: ActionYamlDefinition = {
ref: `${state.packRef}.${state.name}`, ref: `${state.packRef}.${state.name}`,
label: state.label, label: state.label,
enabled: state.enabled,
workflow_file: `workflows/${state.name}.workflow.yaml`, workflow_file: `workflows/${state.name}.workflow.yaml`,
}; };
@@ -751,7 +745,6 @@ export function definitionToBuilderState(
vars: definition.vars || {}, vars: definition.vars || {},
tasks, tasks,
tags: definition.tags || [], tags: definition.tags || [],
enabled: true,
cancellationPolicy: definition.cancellation_policy || "allow_finish", cancellationPolicy: definition.cancellation_policy || "allow_finish",
}; };
} }