From f2f0812a04c9256cfc8eba5e0dcf38d71d971410 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 4 Jun 2026 20:57:05 +0200 Subject: [PATCH 01/32] feat(flows): opt-in to include the stopping step's result in early-stop errors (#9446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flows): early stop can include the stopping step's result in the raised error When a step uses Early Stop with "Raise an error message if stopped", the flow result was entirely replaced with a static error object ({"error": {"name": "EarlyStopError", "message": "..."}}), discarding the stopping step's own output. This made it impossible to stop+fail a flow while preserving the data the step produced (e.g. an API that returns HTTP 200 with a userErrors payload). Add an opt-in `error_include_result` flag on StopAfterIf. When enabled on the raise-error path, the raised payload becomes {"error": {...}, "result": } instead of dropping the result. Default is false, so existing behavior is unchanged. The option is threaded through the worker's stop-after-if handling (including stop_after_all_iters_if for loops/branchall) and exposed in the flow editor's Early Stop panel. Fixes WIN-2012 Co-Authored-By: Claude Opus 4.8 (1M context) * test(flows): cover early-stop error_include_result payload shaping Add a regression test asserting that a step using Early Stop with a raised error message and error_include_result=true fails the flow while preserving the step output as {"error": {..}, "result": }, and that with the flag off the result is the bare {"error": {..}} object. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(flows): nest early-stop step result inside the error object Embed the stopping step's result under `error.result` rather than as a top-level sibling of `error`. This keeps the flow result shape as `{ "error": { .. } }` — identical to a normal error — so consumers that key off the top-level shape (single `error` key) keep working, while the data is still preserved for those that look inside the error object. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(flows): always include the stopping step's result in early-stop errors Drop the opt-in `error_include_result` gate. Since the step result is nested inside the error object (`error.result`), the top-level result shape stays `{ "error": .. }` — identical to a normal error — so consumers that detect or parse failures by the top-level shape are unaffected. Gating it added schema surface, plumbing, and a UI toggle for no real compatibility benefit. Now, whenever a step early-stops with a raised error message, the flow fails and the raised error embeds the stopping step's own result under `error.result` (aggregated iteration results for loops/branchall). This reverts the `StopAfterIf.error_include_result` field, its threading, the OpenAPI/generated-client surface, and the editor toggle; the "Raise an error message" tooltip now notes that the step result is included. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(flows): gate early-stop result inclusion behind opt-in flag Re-introduce the per-step `error_include_result` flag (default off) instead of always embedding the step result. Although nesting the result under `error.result` keeps the result *shape* backward-compatible, it does not address data exposure: a failed flow's result is propagated to synchronous webhook callers, the flow's failure module, and the workspace/global error handler (commonly a Slack/email/outbound-webhook notifier). Always including the step output would surface previously-redacted intermediate data to all of those sinks for every existing error-stop flow. Gating keeps the existing behavior (bare `{ "error": .. }`) as the default and only embeds `error.result` when the flow author explicitly opts in, matching the original issue's intent. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flows): omit error_include_result when false; refresh generated prompts - Add `skip_serializing_if = "is_false"` to `StopAfterIf.error_include_result` so serialized flows are byte-identical when the flag is off. Fixes the `flowmodule_serde` round-trip test (cargo_test) and avoids churn on existing flows. - Regenerate `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts` for the new OpenFlow `error_include_result` property. Fixes check-freshness. Co-Authored-By: Claude Opus 4.8 (1M context) * test(flows): cover error_include_result for the loop "stop after all iters" path Add a regression test for the stop_after_all_iters_if branch, where `nresult` already holds the aggregated iteration results — confirming `error.result` carries each iteration's output (distinct from the per-step fallback path). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/tests/flow_engine_parity.rs | 169 ++++++++++++++++++ backend/windmill-queue/src/jobs.rs | 1 + backend/windmill-types/src/flows.rs | 5 + backend/windmill-worker/src/worker_flow.rs | 100 +++++++---- cli/src/guidance/skills.gen.ts | 2 +- .../copilot/chat/flow/openFlow.json | 2 +- .../copilot/chat/flow/openFlowZod.gen.ts | 6 +- .../flows/content/FlowModuleEarlyStop.svelte | 28 ++- openflow.openapi.yaml | 3 + system_prompts/auto-generated/flow.md | 2 +- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/write-flow/SKILL.md | 2 +- 12 files changed, 274 insertions(+), 48 deletions(-) diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index bae776e7f1..78e45e404b 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2916,6 +2916,7 @@ export function main() { expr: "flow_env.STOP === true".to_string(), skip_if_stopped: true, error_message: None, + error_include_result: false, }); m }; @@ -2966,6 +2967,92 @@ export function main() { Ok(()) } +// stop_after_if with `error_message` + `error_include_result` should fail the +// flow but preserve the stopping step's own result inside the raised error +// object, i.e. `{ "error": { .., "result": } }`. With the flag off +// (the default) the error object carries no `result`. Regression for the +// early-stop branch in `update_flow_status_after_job_completion_internal`. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_stop_after_if_error_include_result(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let make_flow = |include_result: bool| { + let mut m = flow_module( + "step", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return { userErrors: ["email taken"], ok: false }; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + m.stop_after_if = Some(windmill_common::flows::StopAfterIf { + expr: "true".to_string(), + skip_if_stopped: false, + error_message: Some("API returned userErrors".to_string()), + error_include_result: include_result, + }); + FlowValue { modules: vec![m], same_worker: false, ..Default::default() } + }; + + // include_result = true: result preserves both the error and the step output + let job = RunJob::from(JobPayload::RawFlow { + value: make_flow(true), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!( + !job.success, + "flow with raised early-stop error should fail" + ); + let result = job.json_result().unwrap(); + assert_eq!( + result["error"]["name"], "EarlyStopError", + "expected EarlyStopError; got {result:?}" + ); + assert_eq!(result["error"]["message"], "API returned userErrors"); + assert_eq!( + result["error"]["result"], + json!({ "userErrors": ["email taken"], "ok": false }), + "step result should be preserved under `error.result`; got {result:?}" + ); + + // include_result = false (default behavior): result is the bare error object + let job = RunJob::from(JobPayload::RawFlow { + value: make_flow(false), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!( + !job.success, + "flow with raised early-stop error should fail" + ); + let result = job.json_result().unwrap(); + assert_eq!(result["error"]["name"], "EarlyStopError"); + assert!( + result["error"].get("result").is_none(), + "without the flag the error must not embed the step result; got {result:?}" + ); + + Ok(()) +} + // retry_if predicate sees flow_env. Regression for the two evaluate_retry // call sites in `update_flow_status_after_job_completion_internal` (lines // 1194 and 1576) which used to pass `None` for flow_env. @@ -3093,6 +3180,7 @@ export function main(i: number) { expr: "flow_env.STOP === true".to_string(), skip_if_stopped: true, error_message: None, + error_include_result: false, }); m }; @@ -3143,3 +3231,84 @@ export function main() { Ok(()) } + +// stop_after_all_iters_if with `error_message` + `error_include_result` fails the +// flow and embeds the loop's aggregated iteration results under `error.result`. +// Covers the loop/branch-all path where `nresult` is already populated with the +// aggregated results (distinct from the per-step fallback to `result`). +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_stop_after_all_iters_if_error_includes_result( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let inner = flow_module( + "iter_step", + FlowModuleValue::RawScript { + input_transforms: [js_input("i", "flow_input.iter.value")].into(), + language: ScriptLang::Deno, + content: r#" +export function main(i: number) { + return { iter: i }; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let loop_module = { + let mut m = flow_module( + "loop", + FlowModuleValue::ForloopFlow { + iterator: InputTransform::Javascript { expr: "[1, 2, 3]".to_string() }, + modules: vec![inner], + modules_node: None, + skip_failures: false, + parallel: false, + parallelism: None, + squash: None, + }, + ); + m.stop_after_all_iters_if = Some(windmill_common::flows::StopAfterIf { + expr: "true".to_string(), + skip_if_stopped: false, + error_message: Some("loop failed".to_string()), + error_include_result: true, + }); + m + }; + + let flow = FlowValue { modules: vec![loop_module], same_worker: false, ..Default::default() }; + + let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await; + + assert!( + !job.success, + "loop with a raised early-stop error should fail" + ); + let result = job.json_result().unwrap(); + assert_eq!(result["error"]["name"], "EarlyStopError", "got {result:?}"); + assert_eq!(result["error"]["message"], "loop failed"); + // error.result holds the aggregated iteration results (one per iteration) + let iters = result["error"]["result"].as_array().unwrap_or_else(|| { + panic!("error.result should be an array of iteration results; got {result:?}") + }); + let iter_values: Vec<_> = iters.iter().map(|r| r["iter"].clone()).collect(); + assert_eq!( + iter_values, + vec![json!(1), json!(2), json!(3)], + "error.result should contain each iteration's output; got {result:?}" + ); + + Ok(()) +} diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 7c4a832760..0a707418ba 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5292,6 +5292,7 @@ async fn push_inner<'c, 'd>( expr: skip_handler.stop_condition, skip_if_stopped: true, error_message: Some(skip_handler.stop_message), + error_include_result: false, }), ..Default::default() }); diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 9335f60d4f..c7d291d6a1 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -315,6 +315,11 @@ pub struct StopAfterIf { pub expr: String, pub skip_if_stopped: bool, pub error_message: Option, + /// When stopping with an error (`error_message` set), embed the stopping + /// step's own result inside the raised error object (as `error.result`) + /// instead of discarding it. The top-level result stays `{ "error": .. }`. + #[serde(default, skip_serializing_if = "is_false")] + pub error_include_result: bool, } #[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 281a9785c2..178a2d4309 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -311,13 +311,14 @@ struct RecoveryObject { recover: Option, } -fn get_stop_after_if_data(stop_after_if: Option<&StopAfterIf>) -> (bool, Option) { +/// Returns `(skip_if_stopped, error_message, include_step_result)`. +fn get_stop_after_if_data(stop_after_if: Option<&StopAfterIf>) -> (bool, Option, bool) { if let Some(stop_after_if) = stop_after_if { // skip_if_stopped and error_message are mutually exclusive: // skip_if_stopped=true means clean stop (mark remaining as skipped), // error_message means stop with error. skip_if_stopped takes precedence. if stop_after_if.skip_if_stopped { - return (true, None); + return (true, None, false); } let err_msg = stop_after_if.error_message.as_ref().and_then(|message| { if message.is_empty() { @@ -326,9 +327,9 @@ fn get_stop_after_if_data(stop_after_if: Option<&StopAfterIf>) -> (bool, Option< Some(message.clone()) } }); - return (false, err_msg); + return (false, err_msg, stop_after_if.error_include_result); } - return (false, None); + return (false, None, false); } async fn get_id_ctx_for_expr( @@ -358,6 +359,7 @@ async fn evaluate_stop_after_all_iters_if( stop_early: &mut bool, skip_if_stop_early: &mut bool, stop_early_err_msg: &mut Option, + stop_early_include_result: &mut bool, nresult: &mut Option>>, args: HashMap>, flow_env: Option<&HashMap>>, @@ -394,8 +396,11 @@ async fn evaluate_stop_after_all_iters_if( if stop_early_after_all_iters { *stop_early = true; - (*skip_if_stop_early, *stop_early_err_msg) = - get_stop_after_if_data(Some(stop_after_all_iters_if)); + ( + *skip_if_stop_early, + *stop_early_err_msg, + *stop_early_include_result, + ) = get_stop_after_if_data(Some(stop_after_all_iters_if)); } Ok(()) } @@ -655,19 +660,24 @@ pub async fn update_flow_status_after_job_completion_internal( false }; - let (mut stop_early, mut stop_early_err_msg, mut skip_if_stop_early, continue_on_error) = - if stop_early_override.is_some() - && !is_flow_stop_early_override - && !parallel_loop - && !parallel_branchall - { - // we ignore stop_early_override (stop_early in children) if module is parallel or is a flow step - let se = stop_early_override.as_ref().unwrap(); - (true, None, *se, false) - } else if is_failure_step || module_step.is_preprocessor_step() { - (false, None, false, false) - } else if let Some(current_module) = current_module { - let stop_early = success + let ( + mut stop_early, + mut stop_early_err_msg, + mut skip_if_stop_early, + mut stop_early_include_result, + continue_on_error, + ) = if stop_early_override.is_some() + && !is_flow_stop_early_override + && !parallel_loop + && !parallel_branchall + { + // we ignore stop_early_override (stop_early in children) if module is parallel or is a flow step + let se = stop_early_override.as_ref().unwrap(); + (true, None, *se, false, false) + } else if is_failure_step || module_step.is_preprocessor_step() { + (false, None, false, false, false) + } else if let Some(current_module) = current_module { + let stop_early = success && !is_branch_all // we don't support stop_early per branch && !parallel_loop // we don't support anymore stop_early per iteration when parallel for loop (removed from frontend) && !is_identity_job // don't evaluate stop_after_if for skipped (identity) steps @@ -717,22 +727,23 @@ pub async fn update_flow_status_after_job_completion_internal( } else { false }; - let (skip_if_stopped, stop_early_err_msg) = if stop_early { - get_stop_after_if_data(current_module.stop_after_if.as_ref()) - } else { - (false, None) - }; - - ( - stop_early, - stop_early_err_msg, - skip_if_stopped, - current_module.continue_on_error.unwrap_or(false), - ) + let (skip_if_stopped, stop_early_err_msg, include_result) = if stop_early { + get_stop_after_if_data(current_module.stop_after_if.as_ref()) } else { - (false, None, false, false) + (false, None, false) }; + ( + stop_early, + stop_early_err_msg, + skip_if_stopped, + include_result, + current_module.continue_on_error.unwrap_or(false), + ) + } else { + (false, None, false, false, false) + }; + let skip_seq_branch_failure = match module_status { FlowStatusModule::InProgress { branchall: Some(BranchAllStatus { branch, .. }), @@ -974,6 +985,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early, &mut skip_if_stop_early, &mut stop_early_err_msg, + &mut stop_early_include_result, &mut nresult, args, resolved_flow_env.as_deref(), @@ -1173,6 +1185,7 @@ pub async fn update_flow_status_after_job_completion_internal( stop_early = false; stop_early_err_msg = None; skip_if_stop_early = false; + stop_early_include_result = false; } if is_loop || (is_branch_all && !stop_early) { @@ -1194,6 +1207,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early, &mut skip_if_stop_early, &mut stop_early_err_msg, + &mut stop_early_include_result, &mut nresult, args, resolved_flow_env.as_deref(), @@ -1310,12 +1324,22 @@ pub async fn update_flow_status_after_job_completion_internal( }; if stop_early && stop_early_err_msg.is_some() { - nresult = Some(Arc::new(to_raw_value(&serde_json::json! ({ - "error": { - "name": "EarlyStopError", - "message": stop_early_err_msg.as_ref().unwrap(), - } - })))); + let mut error = serde_json::json!({ + "name": "EarlyStopError", + "message": stop_early_err_msg.as_ref().unwrap(), + }); + if stop_early_include_result { + // Embed the stopping step's own result inside the error object instead + // of discarding it, keeping the top-level result shape `{ "error": .. }` + // unchanged. `nresult` is already set for loops/branchall (aggregated + // iteration results), otherwise fall back to the step result. + let step_result = nresult.clone().unwrap_or_else(|| result.clone()); + error["result"] = + serde_json::to_value(&step_result).unwrap_or(serde_json::Value::Null); + } + nresult = Some(Arc::new(to_raw_value( + &serde_json::json!({ "error": error }), + ))); } let step_counter = if inc_step_counter { diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 35cbbfef35..61d92755fb 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -4831,7 +4831,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index dc95ea41b0..0309aeaf91 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.692.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file +{"openapi":"3.0.3","info":{"version":"1.716.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts index 1214dc2369..21e6735ab1 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -20,7 +20,7 @@ export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "i }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") -export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration. Use stop_after_if to control when the loop ends"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')") }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("Short description of what this tool does (shown to the AI)").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -37,7 +37,7 @@ export const flowModuleSchema = z.object({ "id": z.string().describe("Unique ide message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task"), "type": z.literal("aiagent"), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") export const flowModulesSchema = z.array(flowModuleSchema) diff --git a/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte b/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte index 627808dda1..fbe747cff3 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte @@ -99,7 +99,8 @@ flowModule.stop_after_if = { expr: 'result == undefined', skip_if_stopped: false, - error_message: undefined + error_message: undefined, + error_include_result: false } } }} @@ -137,6 +138,7 @@ on:change={(event) => { if (flowModule.stop_after_if && event.detail) { flowModule.stop_after_if.error_message = undefined + flowModule.stop_after_if.error_include_result = false raise_error_message_stop_after_if = false } }} @@ -154,6 +156,7 @@ flowModule.stop_after_if.skip_if_stopped = false } else { flowModule.stop_after_if.error_message = undefined + flowModule.stop_after_if.error_include_result = false } } }} @@ -171,6 +174,15 @@ bind:value={flowModule.stop_after_if.error_message} placeholder="Enter custom error message (optional)" /> + {/if} Stop condition expression
@@ -253,7 +265,8 @@ flowModule.stop_after_all_iters_if = { expr: 'result == undefined', skip_if_stopped: false, - error_message: undefined + error_message: undefined, + error_include_result: false } } }} @@ -283,6 +296,7 @@ on:change={(event) => { if (flowModule.stop_after_all_iters_if && event.detail) { flowModule.stop_after_all_iters_if.error_message = undefined + flowModule.stop_after_all_iters_if.error_include_result = false raise_error_message_stop_after_all_if = false } }} @@ -300,6 +314,7 @@ flowModule.stop_after_all_iters_if.skip_if_stopped = false } else { flowModule.stop_after_all_iters_if.error_message = undefined + flowModule.stop_after_all_iters_if.error_include_result = false } } }} @@ -317,6 +332,15 @@ bind:value={flowModule.stop_after_all_iters_if.error_message} placeholder="Enter custom error message (optional)" /> + {/if} Stop condition expression
diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index fab9801446..e6437bf810 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -271,6 +271,9 @@ components: type: string nullable: true description: Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised. + error_include_result: + type: boolean + description: "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." required: - expr diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index 6a2fe07ba2..b7bca6375b 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -295,4 +295,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index ecd42d6eff..3d8819c4fe 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2546,7 +2546,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index a8b57a93e4..d5a044fd18 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -381,4 +381,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file From eb55356018c2762f1fa12550559fecd370da532f Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 4 Jun 2026 20:58:29 +0200 Subject: [PATCH 02/32] add wiz icon (#9448) Wiz star logomark (brand blue #0254EC) for the shared icon map (APP_TO_ICON_COMPONENT), for windmill-integrations#144. Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/icons/WizIcon.svelte | 12 ++++++++++++ frontend/src/lib/components/icons/index.ts | 7 +++++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/icons/WizIcon.svelte diff --git a/frontend/src/lib/components/icons/WizIcon.svelte b/frontend/src/lib/components/icons/WizIcon.svelte new file mode 100644 index 0000000000..f3ee8e842e --- /dev/null +++ b/frontend/src/lib/components/icons/WizIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 78767697a1..daadb3449b 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -214,6 +214,7 @@ import YnabIcon from './YnabIcon.svelte' import SpeechifyIcon from './SpeechifyIcon.svelte' import ConvertKitIcon from './ConvertKitIcon.svelte' import BrowserlessIcon from './BrowserlessIcon.svelte' +import WizIcon from './WizIcon.svelte' import type { Component } from 'svelte' export const APP_TO_ICON_COMPONENT = { postgresql: PostgresIcon, @@ -436,7 +437,8 @@ export const APP_TO_ICON_COMPONENT = { ynab: YnabIcon, speechify: SpeechifyIcon, convertkit: ConvertKitIcon, - browserless: BrowserlessIcon + browserless: BrowserlessIcon, + wiz: WizIcon } as unknown as Record // to generate correct svelte package types export { @@ -647,5 +649,6 @@ export { YnabIcon, SpeechifyIcon, ConvertKitIcon, - BrowserlessIcon + BrowserlessIcon, + WizIcon } From 93a74f229a91c82a8e474f7f3665eedfa103af68 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 4 Jun 2026 20:58:47 +0200 Subject: [PATCH 03/32] oauth: add ServiceNow + make per-instance OAuth providers registry-driven (#9449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * oauth: add ServiceNow provider; make per-instance OAuth registry-driven ServiceNow's OAuth endpoints are per-instance (https://.service-now.com/oauth_auth.do + /oauth_token.do), like Snowflake's. Rather than add another bespoke special-case, generalize: a registry entry may carry a `connect_config_template` (label/placeholder/ help_url + {instance}-templated auth_url/token_url + req_body_auth + optional extra_params_key/strip_suffix). The instance-settings UI renders one generic instance-name input for any such provider and substitutes {instance} to build the per-client connect_config — a new per-instance provider needs only a JSON entry, no frontend code. - oauth_connect.json: servicenow + snowflake_oauth now carry a connect_config_template (snowflake keeps its account_identifier extra_params key for backward compatibility). - windmill-oauth: add the ConnectConfigTemplate struct (frontend-only metadata; the backend's existing connect_config override resolves the concrete URLs generically — no other backend change). - AuthSettings/InstanceSettings: replace the Snowflake + ServiceNow special-cases with one registry-driven path (instanceInputs map, setupTemplatedOauthUrls, loadInstanceInputs); per-instance providers are derived from the registry for the builtins list + dropdown. Pairs with windmill-integrations#139 (ServiceNow hub integration). Co-Authored-By: Claude Opus 4.8 (1M context) * ci: point ee-repo-ref at servicenow-oauth EE branch (revert at merge) Temporary CI pointer so check_ee_full / cargo_test build against the EE slack-literal fix (windmill-ee-private#602). Revert to a pinned SHA once that EE PR is merged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/ee-repo-ref.txt | 2 +- backend/oauth_connect.json | 48 ++++++---- backend/windmill-oauth/src/lib.rs | 44 +++++++++ .../src/lib/components/AppConnectInner.svelte | 24 ++++- .../src/lib/components/AuthSettings.svelte | 60 ++++++++---- .../lib/components/InstanceSettings.svelte | 96 +++++++++++-------- 6 files changed, 197 insertions(+), 77 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4e1f516570..15e5b90000 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -3742e0659c5e97aab03b9efeea14cd94a3ac658a +servicenow-oauth diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 36f565fc2b..01becd80f3 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -154,29 +154,34 @@ "zoho": { "auth_url": "https://accounts.zoho.com/oauth/v2/auth", "token_url": "https://accounts.zoho.com/oauth/v2/token", - "scopes": [ - "ZohoAssist.sessionapi.ALL" - ], + "scopes": ["ZohoAssist.sessionapi.ALL"], "extra_params": { "access_type": "offline" } }, - "snowflake_oauth": {}, + "snowflake_oauth": { + "connect_config_template": { + "display_name": "Snowflake", + "label": "Snowflake Account Identifier", + "placeholder": "-", + "help_url": "https://docs.snowflake.com/en/user-guide/admin-account-identifier#using-an-account-name-as-an-identifier", + "auth_url": "https://{instance}.snowflakecomputing.com/oauth/authorize", + "token_url": "https://{instance}.snowflakecomputing.com/oauth/token-request", + "req_body_auth": false, + "extra_params_key": "account_identifier", + "resource_mapping": { "account_identifier": "{instance}" } + } + }, "apify": { "auth_url": "https://console.apify.com/authorize/oauth", "token_url": "https://console-backend.apify.com/oauth/apps/token", - "scopes": [ - "profile", - "full_api_access" - ], + "scopes": ["profile", "full_api_access"], "extra_params": {} }, "docusign": { "auth_url": "https://account.docusign.com/oauth/auth", "token_url": "https://account.docusign.com/oauth/token", - "scopes": [ - "signature" - ], + "scopes": ["signature"], "sandbox": { "auth_url": "https://account-d.docusign.com/oauth/auth", "token_url": "https://account-d.docusign.com/oauth/token" @@ -185,14 +190,25 @@ "salesforce": { "auth_url": "https://login.salesforce.com/services/oauth2/authorize", "token_url": "https://login.salesforce.com/services/oauth2/token", - "scopes": [ - "api", - "refresh_token", - "offline_access" - ], + "scopes": ["api", "refresh_token", "offline_access"], "sandbox": { "auth_url": "https://test.salesforce.com/services/oauth2/authorize", "token_url": "https://test.salesforce.com/services/oauth2/token" } + }, + "servicenow": { + "connect_config_template": { + "display_name": "ServiceNow", + "label": "ServiceNow Instance", + "placeholder": " (e.g. dev12345)", + "help_url": "https://www.servicenow.com/docs/bundle/zurich-platform-security/page/administer/security/concept/c_OAuthApplications.html", + "auth_url": "https://{instance}.service-now.com/oauth_auth.do", + "token_url": "https://{instance}.service-now.com/oauth_token.do", + "req_body_auth": true, + "strip_suffix": ".service-now.com", + "resource_mapping": { + "instance_url": "https://{instance}.service-now.com" + } + } } } diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 874a859200..59b4184cea 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -92,6 +92,12 @@ pub struct OAuthConfig { /// entry, `build_oauth_clients` registers a second client under that key. #[serde(skip_serializing_if = "Option::is_none")] pub sandbox: Option, + /// Frontend-only metadata for per-instance OAuth providers (Snowflake, + /// ServiceNow, …) whose authorize/token URLs are derived from an + /// admin-entered instance name. Ignored by the backend, which only ever + /// sees the resulting concrete `connect_config`. + #[serde(skip_serializing_if = "Option::is_none")] + pub connect_config_template: Option, } /// URL overrides for an OAuth provider's sandbox environment. Inherits @@ -106,6 +112,43 @@ pub struct OAuthSandboxOverride { pub userinfo_url: Option, } +/// Frontend metadata for a per-instance OAuth provider. The instance-settings +/// UI renders one generic instance-name input and substitutes `{instance}` into +/// `auth_url`/`token_url` to build the per-client `connect_config`. Adding a new +/// per-instance provider needs only a registry entry carrying this template — +/// no frontend code change. The backend never reads it. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ConnectConfigTemplate { + /// Properly-cased provider name for the settings dropdown (e.g. "ServiceNow"); + /// the UI falls back to a capitalized registry key when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub label: String, + pub placeholder: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub help_url: Option, + pub auth_url: String, + pub token_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub req_body_auth: Option, + /// Key under `connect_config.extra_params` where the instance name is + /// stored (defaults to `instance`). Snowflake uses `account_identifier` for + /// backward compatibility with previously-saved configs. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_params_key: Option, + /// Optional host suffix stripped from the input before substitution (e.g. + /// `.service-now.com`), so the admin can paste a full host or a bare name. + #[serde(skip_serializing_if = "Option::is_none")] + pub strip_suffix: Option, + /// Maps OAuth-connected resource arg fields to value templates substituting + /// `{instance}` (e.g. ServiceNow's `instance_url` -> + /// `https://{instance}.service-now.com`). Applied by the resource-connect + /// flow so the created resource carries the instance-specific fields the + /// scripts need (ServiceNow's token response omits the host). + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_mapping: Option>, +} + impl OAuthConfig { /// Returns a copy of this config with sandbox URL overrides applied and /// the nested `sandbox` field cleared. Returns `None` if no overrides are @@ -817,6 +860,7 @@ mod tests { token_url: Some("https://account-d.example.com/oauth/token".to_string()), userinfo_url: None, }), + connect_config_template: None, } } diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 7b6ed37ecc..4872629cc1 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -13,6 +13,7 @@ type ResourceType } from '$lib/gen' import { emptyString, truncateRev, urlize } from '$lib/utils' + import oauthConnectRegistry from '$oauth_connect_registry' import { createEventDispatcher, onDestroy } from 'svelte' import Path from './Path.svelte' import { Button, Skeleton } from './common' @@ -489,12 +490,25 @@ throw Error(`Resource at path ${path} already exists. Delete it or pick another path`) } - if (resourceType == 'snowflake_oauth') { - const account_identifier = extra_params.find(([key, _]) => key == 'account_identifier') - if (account_identifier) { - args['account_identifier'] = account_identifier[1] + // Per-instance OAuth providers (Snowflake, ServiceNow, …): copy the + // admin-configured instance from the OAuth client's extra_params into the + // resource args, per the registry template's resource_mapping (e.g. + // ServiceNow -> instance_url: https://{instance}.service-now.com). Generic + // so a new per-instance provider needs only a registry entry. + const connectTemplate = (oauthConnectRegistry as Record)[resourceType] + ?.connect_config_template + if (connectTemplate?.resource_mapping) { + const instanceKey = connectTemplate.extra_params_key ?? 'instance' + const found = extra_params.find(([key, _]) => key === instanceKey) + if (found) { + for (const [argField, valueTemplate] of Object.entries( + connectTemplate.resource_mapping as Record + )) { + args[argField] = valueTemplate.replaceAll('{instance}', found[1]) + } } - } else if (resourceType === 'quickbooks' && responseExtra['realmId']) { + } + if (resourceType === 'quickbooks' && responseExtra['realmId']) { args['realmId'] = responseExtra['realmId'] } diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 734c6a1fe3..31706433f6 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -29,7 +29,10 @@ import oauthConnectRegistry from '$oauth_connect_registry' interface Props { - snowflakeAccountIdentifier?: string + // Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name + // keyed by provider, used to build their per-instance connect_config URLs. + // Required (and always bound by InstanceSettings) so it is never undefined. + instanceInputs: Record oauths?: Record requirePreexistingUserForOauth?: boolean baseUrl?: string @@ -39,7 +42,7 @@ } let { - snowflakeAccountIdentifier = $bindable(), + instanceInputs = $bindable(), oauths = $bindable(), requirePreexistingUserForOauth = $bindable(), baseUrl, @@ -49,9 +52,6 @@ }: Props = $props() $effect(() => { - if (snowflakeAccountIdentifier == undefined) { - snowflakeAccountIdentifier = '' - } if (oauths == undefined) { oauths = {} } @@ -79,7 +79,6 @@ 'visma', 'sage_intacct', 'spotify', - 'snowflake_oauth', 'teams', 'zoho', 'xero', @@ -96,9 +95,20 @@ const windmillBuiltinsWithSandbox = Object.entries(oauthConnectRegistry) .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'sandbox' in cfg) .map(([name]) => name) + // Per-instance providers (Snowflake, ServiceNow, …): registry entries that + // carry a `connect_config_template`. Derived from the registry so adding a + // new one needs only a JSON entry — they get a builtin tile + the generic + // instance-name input below, with no frontend change. + const connectConfigTemplates: Record = Object.fromEntries( + Object.entries(oauthConnectRegistry) + .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) + .map(([name, cfg]) => [name, (cfg as any).connect_config_template]) + ) + const windmillBuiltinsTemplated = Object.keys(connectConfigTemplates) const windmillBuiltins = [ ...windmillBuiltinsBase, - ...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`) + ...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`), + ...windmillBuiltinsTemplated ] let showCustomOAuthForm = $state(false) @@ -238,6 +248,20 @@ } }) + // Add per-instance providers (registry entries with a connect_config_template) + windmillBuiltinsTemplated.forEach((name) => { + if (!oauths || !oauths[name]) { + const icon = getOAuthProviderIcon(name) + items.push({ + // Prefer the template's display_name (properly cased, e.g. "ServiceNow") + // over capitalize(name) which yields "Servicenow"/"Snowflake_oauth". + displayName: connectConfigTemplates[name]?.display_name ?? capitalize(name), + action: () => createOAuthClient(name), + icon: icon + }) + } + }) + // Add custom option items.push({ displayName: `Custom OAuth client ${!$enterpriseLicense ? '(requires ee)' : ''}`, @@ -486,19 +510,23 @@ {:else if !windmillBuiltins.includes(k) && k != 'slack'} {/if} - {#if k == 'snowflake_oauth'} + {#if connectConfigTemplates[k]} + {@const tmpl = connectConfigTemplates[k]} {/if} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 7342f1d3fa..9581357e84 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -13,6 +13,7 @@ import { createEventDispatcher } from 'svelte' import { setLicense } from '$lib/enterpriseUtils' import AuthSettings from './AuthSettings.svelte' + import oauthConnectRegistry from '$oauth_connect_registry' import InstanceSetting from './InstanceSetting.svelte' import { writable, type Writable } from 'svelte/store' import { ExternalLink, Loader2 } from 'lucide-svelte' @@ -54,7 +55,9 @@ let initialValues: Record = $state({}) let baseUrlIsFallback = $state(false) - let snowflakeAccountIdentifier = $state('') + // Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name + // keyed by provider, used to build their per-instance connect_config URLs. + let instanceInputs: Record = $state({}) let version: string = $state('') let loading = $state(true) @@ -147,12 +150,8 @@ $values = nvalues loading = false - // populate snowflake account identifier from db - const account_identifier = - oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier - if (account_identifier) { - snowflakeAccountIdentifier = account_identifier - } + // populate per-instance OAuth provider inputs (snowflake, servicenow, …) from db + loadInstanceInputs(oauths) } export async function saveSettings() { @@ -162,13 +161,7 @@ } } - if ( - oauths?.snowflake_oauth && - oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !== - snowflakeAccountIdentifier - ) { - setupSnowflakeUrls() - } + setupTemplatedOauthUrls() // Remove empty or invalid entries for critical error channels $values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => { @@ -283,19 +276,54 @@ } } - function setupSnowflakeUrls() { - // strip all whitespaces from account identifier - snowflakeAccountIdentifier = snowflakeAccountIdentifier.replace(/\s/g, '') + // Per-instance OAuth providers (Snowflake, ServiceNow, …) keyed by name -> + // their registry connect_config_template. Adding a new one needs only a + // registry entry — no code here. + const connectConfigTemplates: Record = Object.fromEntries( + Object.entries(oauthConnectRegistry) + .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) + .map(([name, cfg]) => [name, (cfg as any).connect_config_template]) + ) - const connect_config = { - scopes: [], - auth_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/authorize`, - token_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/token-request`, - req_body_auth: false, - extra_params: { account_identifier: snowflakeAccountIdentifier }, - extra_params_callback: {} + function normalizeInstanceInput(tmpl: any, raw: string): string { + let v = (raw ?? '').replace(/\s/g, '') + if (tmpl.strip_suffix) { + // accept a full host/URL or a bare name -> reduce to the bare instance + v = v.replace(/^https?:\/\//, '').replace(/\/.*$/, '') + if (v.endsWith(tmpl.strip_suffix)) { + v = v.slice(0, -tmpl.strip_suffix.length) + } + } + return v + } + + // Build each per-instance provider's connect_config from the admin-entered + // instance name + its registry template (substituting {instance} into the + // URLs). Replaces the old per-provider setup functions. + function setupTemplatedOauthUrls() { + for (const [name, tmpl] of Object.entries(connectConfigTemplates)) { + if (!oauths?.[name]) continue + const key = tmpl.extra_params_key ?? 'instance' + const v = normalizeInstanceInput(tmpl, instanceInputs[name] ?? '') + instanceInputs[name] = v + if (oauths[name].connect_config?.extra_params?.[key] === v) continue + oauths[name].connect_config = { + scopes: [], + auth_url: tmpl.auth_url.replaceAll('{instance}', v), + token_url: tmpl.token_url.replaceAll('{instance}', v), + req_body_auth: tmpl.req_body_auth ?? false, + extra_params: { [key]: v }, + extra_params_callback: {} + } + } + } + + // Recover the instance-name inputs from a saved oauths config (for load/discard). + function loadInstanceInputs(savedOauths: Record) { + for (const [name, tmpl] of Object.entries(connectConfigTemplates)) { + const key = tmpl.extra_params_key ?? 'instance' + instanceInputs[name] = savedOauths?.[name]?.connect_config?.extra_params?.[key] ?? '' } - oauths['snowflake_oauth'].connect_config = connect_config } let sendingStats = $state(false) @@ -510,9 +538,7 @@ if (category === 'Auth/OAuth/SAML') { oauths = JSON.parse(JSON.stringify(initialOauths)) requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth - const account_identifier = - initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier - snowflakeAccountIdentifier = account_identifier ?? '' + loadInstanceInputs(initialOauths) } else if (category === 'Registries') { const v = initialValues['workspace_registries'] $values['workspace_registries'] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined @@ -524,9 +550,7 @@ $values = JSON.parse(JSON.stringify(initialValues)) oauths = JSON.parse(JSON.stringify(initialOauths)) requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth - const account_identifier = - initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier - snowflakeAccountIdentifier = account_identifier ?? '' + loadInstanceInputs(initialOauths) if (yamlMode) { syncFormToYaml() } @@ -535,13 +559,7 @@ export async function saveCategorySettings(category: string) { // Category-specific pre-processing if (category === 'Auth/OAuth/SAML') { - if ( - oauths?.snowflake_oauth && - oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !== - snowflakeAccountIdentifier - ) { - setupSnowflakeUrls() - } + setupTemplatedOauthUrls() } if (category === 'Alerts' && $values?.critical_error_channels) { @@ -1116,7 +1134,7 @@ {:else if category == 'Auth/OAuth/SAML'} Date: Thu, 4 Jun 2026 21:01:57 +0200 Subject: [PATCH 04/32] add adobe acrobat sign icon (#9447) Adds AdobeAcrobatSignIcon.svelte and registers `adobe_acrobat_sign` in APP_TO_ICON_COMPONENT, for the Adobe Acrobat Sign hub integration (windmill-labs/windmill-integrations#143). Co-authored-by: Claude Opus 4.8 (1M context) --- .../icons/AdobeAcrobatSignIcon.svelte | 24 +++++++++++++++++++ frontend/src/lib/components/icons/index.ts | 2 ++ 2 files changed, 26 insertions(+) create mode 100644 frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte diff --git a/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte new file mode 100644 index 0000000000..2f04246966 --- /dev/null +++ b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index daadb3449b..aa159f3a96 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -27,6 +27,7 @@ import QRCodeIcon from './QRCodeIcon.svelte' import LinkedinIcon from './LinkedinIcon.svelte' import HubspotIcon from './HubspotIcon.svelte' import DatadogIcon from './DatadogIcon.svelte' +import AdobeAcrobatSignIcon from './AdobeAcrobatSignIcon.svelte' import StripeIcon from './StripeIcon.svelte' import TelegramIcon from './TelegramIcon.svelte' import FunkwhaleIcon from './FunkwhaleIcon.svelte' @@ -245,6 +246,7 @@ export const APP_TO_ICON_COMPONENT = { linkedin: LinkedinIcon, hubspot: HubspotIcon, datadog: DatadogIcon, + adobe_acrobat_sign: AdobeAcrobatSignIcon, stripe: StripeIcon, telegram: TelegramIcon, funkwhale: FunkwhaleIcon, From 00a96b82f35680e4b3947c4d57bdc63e5ea856d6 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 4 Jun 2026 21:02:51 +0200 Subject: [PATCH 05/32] add databricks icon (#9445) Adds DatabricksIcon.svelte (brand mark, #FF3621) and registers it under `databricks` in the shared APP_TO_ICON_COMPONENT map, so both the app and hub frontends pick it up for the new Databricks hub integration. Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Ruben Fiszel --- .../components/icons/DatabricksIcon.svelte | 21 +++++++++++++++++++ frontend/src/lib/components/icons/index.ts | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 frontend/src/lib/components/icons/DatabricksIcon.svelte diff --git a/frontend/src/lib/components/icons/DatabricksIcon.svelte b/frontend/src/lib/components/icons/DatabricksIcon.svelte new file mode 100644 index 0000000000..e767337442 --- /dev/null +++ b/frontend/src/lib/components/icons/DatabricksIcon.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index aa159f3a96..56d77c810c 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -27,6 +27,7 @@ import QRCodeIcon from './QRCodeIcon.svelte' import LinkedinIcon from './LinkedinIcon.svelte' import HubspotIcon from './HubspotIcon.svelte' import DatadogIcon from './DatadogIcon.svelte' +import DatabricksIcon from './DatabricksIcon.svelte' import AdobeAcrobatSignIcon from './AdobeAcrobatSignIcon.svelte' import StripeIcon from './StripeIcon.svelte' import TelegramIcon from './TelegramIcon.svelte' @@ -246,6 +247,7 @@ export const APP_TO_ICON_COMPONENT = { linkedin: LinkedinIcon, hubspot: HubspotIcon, datadog: DatadogIcon, + databricks: DatabricksIcon, adobe_acrobat_sign: AdobeAcrobatSignIcon, stripe: StripeIcon, telegram: TelegramIcon, From fee23a51859d84e843a789958db8eae6b771bacc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 5 Jun 2026 01:00:12 +0000 Subject: [PATCH 06/32] threat_model v0 --- backend/THREAT_MODEL.md | 172 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 backend/THREAT_MODEL.md diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md new file mode 100644 index 0000000000..5a891bda77 --- /dev/null +++ b/backend/THREAT_MODEL.md @@ -0,0 +1,172 @@ +# Threat Model: Windmill Backend + +## 1. System context + +Windmill is an open-source (AGPLv3) developer platform for internal tools, +workflows, background jobs, API integrations, and UIs — a self-hostable +alternative to Retool / Pipedream / Airplane. The backend is a Rust workspace +(~60 crates: `windmill-api`, `windmill-worker`, `windmill-queue`, +`windmill-common`, a family of `windmill-trigger-*` crates, `windmill-mcp`, +`windmill-sandbox`, etc.) fronting a PostgreSQL database. A Svelte 5 frontend +(not in scope here, but referenced where stored-XSS threats originate) is +served by the same instance. The product ships in a Community Edition (CE, +public Docker images) and an Enterprise Edition (EE, `*_ee.rs` files gated by +`enterprise`/`private`/`license` cargo features). + +The defining characteristic for threat modeling is that **Windmill executes +arbitrary user-supplied code** (Python, TypeScript via Bun/Deno, Go, Bash, +SQL, GraphQL, PowerShell, Rust, …) on its workers, and **stores the +credentials to every system its users connect to** (databases, cloud +accounts, SaaS APIs, OAuth tokens). It is therefore simultaneously an +arbitrary-code-execution engine and a credential vault — compromising one +instance can pivot into an organization's entire connected estate. Crucially, +the owner confirms `nsjail` is **off by default everywhere** (`ENABLE_NSJAIL` +is opt-in) and network isolation (`clone_newnet`) is separately gated: the +*only* job isolation present in a default install is PID-namespace `unshare`. +Filesystem and outbound-network isolation are therefore absent unless an +operator deliberately enables them, which makes "weak-by-default isolation" a +more accurate frame than "sandbox escape" for typical deployments. Cross-tenant +separation is enforced in software via workspace IDs, token scopes, folder +ACLs, and Postgres row-level security; on the managed offering, sensitive +customers can opt into dedicated DB / worker / namespace infrastructure, but +the shared tier relies entirely on that software boundary. Administrators are +strongly encouraged to use nsjail sandboxing and are reminded that if they don't, +their security model is that they trust their developers that write code ran on windmill +to not do anything TOO malicious on the workers. When the default +database secret backend is used, only per-workspace secret *variables* are +encrypted at rest — instance-level `global_settings` (OAuth client secrets, +SMTP, object-store keys, license) are stored plaintext, so a database read +yields the instance-wide credential set. Internet-facing instances are +typically exposed directly with no built-in rate limiting or WAF. + +It is deployed self-hosted (Docker Compose, Kubernetes/Helm, bare metal), on +cloud providers, and as a Windmill-Labs-managed multi-tenant service. The API +server is internet-facing in most deployments; workers pull jobs from the +Postgres queue. The large public attack surface (a sprawling authenticated +HTTP API, unauthenticated public-app and webhook/trigger endpoints, outbound +HTTP from user code and proxies) combined with the high-value assets makes +authorization-enforcement bugs, SSRF, SQL injection, and sandbox escape the +dominant risk categories — a pattern strongly confirmed by the project's +published advisory history (73 GHSA advisories, several rated 9.9 critical). + +## 2. Assets + +| asset | description | sensitivity | +|---|---|---| +| Workspace encryption keys | Per-workspace key (`workspace_key`) used to encrypt secret variables (MagicCrypt256); decrypts all secrets in the workspace | critical | +| Secret variables | User secrets stored encrypted in `variable` (is_secret) | critical | +| Resource credentials | DB passwords, cloud creds, API keys, connection strings in `resource` JSONB | critical | +| OAuth / external-account tokens | Refresh/access tokens in `account`, MCP OAuth tables | critical | +| User password hashes | Argon2 hashes in `password` table | critical | +| API tokens & session cookies | Bearer tokens / cookies in `token`; superadmin & scoped tokens | critical | +| Instance global settings | License key, JWT secret, SUPERADMIN_SECRET, SMTP, object-store + secret-backend (Vault/KMS/SM) creds in `global_settings` | critical | +| Worker host & process integrity | The host that runs untrusted user code | critical | +| Cross-tenant / cross-workspace isolation | The software boundary separating workspaces, folders, and tenants | critical | +| Downstream connected systems | Windmill is a credential vault: stored creds reach external DBs, cloud accounts, SaaS | critical | +| Script / flow / app source | Customer IP & business logic in `script`, `flow`, `app`, `raw_app` | high | +| Job arguments, results & logs | `queue`/`completed_job` args+result, `job_logs`; routinely contain secrets | high | +| Object store / S3 data | Files uploaded/produced by jobs | high | +| Audit logs | `audit`/`audit_partitioned` action trail | high | +| Service availability | API server + worker fleet uptime | high | +| PII | User emails, group membership | medium | + +## 3. Entry points & trust boundaries + +| entry_point | description | trust_boundary | reachable_assets | +|---|---|---|---| +| EP1 Authenticated job-execution API | `jobs/run/preview`, `run/h/{hash}`, `run_flow/run_script` — runs user code on workers | authenticated user → arbitrary code on worker | Worker host, downstream systems, isolation, job args/results/logs | +| EP2 Unauthenticated public endpoints | `apps_u/*`, `jobs_u/getupdate*`, `scripts_u`, `settings_u`, `resources_u` (`public_app_layer.rs`) | unauth HTTP → app logic & job data | Job results, scripts, secrets, PII | +| EP3 HTTP-trigger & webhook ingestion | `/api/r/*`, GCP/Azure push, Slack callback, `capture_u/*` | untrusted webhook → job queue | Job execution integrity, worker host | +| EP4 Message-queue / native triggers | kafka, postgres, mqtt, websocket, nats, sqs, email triggers | external broker/message → job queue | Job execution integrity, availability | +| EP5 HTTP API authorization layer | Token/scope/RLS/folder-ACL enforcement across all workspaced routes (`windmill-api-auth`) | scoped token / low-priv user → other users' & workspaces' data | Scripts, job data, secrets, isolation | +| EP6 AI proxy & MCP endpoints | `ai/proxy/*`, `mcp` — resolve `$var:`/resources, proxy to LLM APIs, `X-Resource-Path` | authenticated user → outbound HTTP + secret resolution | Secrets, resource creds, internal network, downstream | +| EP7 Outbound HTTP from executors/resources | GraphQL/HTTP/Postgres executors, webhook delivery, `test_object_storage_config`, git clone, npm tarball fetch | user-controlled URL → server-side request | Cloud metadata, internal network, downstream creds | +| EP8 SQL query builders & contextual-var substitution | App DB query builder (`whereClause`/`tags`), Postgres-trigger `where_clause`, `%%WM_*%%` interpolation, `WM_INTERNAL_DB` | user input → raw SQL | Database, connected DBs | +| EP9 Worker sandbox | nsjail / unshare / dind / rootless podman isolating user code | user code → host & cross-tenant filesystem/network | Worker host, isolation, downstream | +| EP10 Worker code generation / wrappers | Entrypoint override, env-var names, workspace env interpolated into generated wrapper code | user-controlled identifier → executable code | Worker host, isolation | +| EP11 OAuth / OIDC / SAML / MCP-OAuth / logout | Login callbacks, MCP OAuth client registration, logout `rd` redirect | untrusted IdP / redirect input → session | Session tokens, accounts | +| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers | stored user content → admin browser (same origin) | Admin session, account takeover | +| EP13 Log/file reading & export endpoints | `service_logs`, `jobs_u/getupdate` log file read (symlinks), workspace/tarball export | authed/unauth request → arbitrary file or admin-only config | Arbitrary files, global settings | +| EP14 Secret-value & resource-value caches | In-memory caches in `windmill-store` keyed (historically un-keyed) by path | cache lookup crossing identity/folder boundary | Secret variables, resource creds | +| EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS=false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets | +| EP16 Supply chain | Cached hub scripts, GitHub workflow actions, vendored deps, Docker base image | build/update-time input → host & build integrity | Worker host, build integrity | +| EP17 Token lifecycle | Token create/rescope/refresh, script-issued JWTs | scoped caller → broader privilege | Tokens, accounts, isolation | + +## 4. Threats + +| id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence | +|---|---|---|---|---|---|---|---|---|---| +| T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b | +| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | +| T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 | +| T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 | +| T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e | +| T6 | Disclosure of secrets, resource credentials, and workspace encryption keys across the authorization boundary (AI proxy, MCP, caches, export); database read additionally yields plaintext instance-level `global_settings` secrets | remote_auth | EP6, EP14, EP13 | Secret variables, encryption keys, resource creds, global settings | critical | likely | partially_mitigated | RLS on `$var:`, cache scoping by caller, admin checks on export; per-workspace secret *variables* encrypted at rest, but `global_settings` is plaintext under the default DB secret backend | GHSA-jwg4-v3cj-rvfm, GHSA-8m2p-2crh-9h3w, GHSA-6635-6fch-v8px, GHSA-437f-725p-7w84, GHSA-f27g-j463-q85w (CVE-2026-26964), GHSA-j679-v6vj-jfxc, GHSA-6vrr-fq33-qpfp, 0ba128afe7, 7836a4e733, ff8e39c69b | +| T7 | Full instance compromise from insecure deployment defaults (dind control, default admin/`changeme`, exposed Postgres, publicly readable SUPERADMIN_SECRET) | remote_unauth | EP15 | All assets | critical | likely | partially_mitigated | first-time-setup warning on default admin; docs recommend hardening | GHSA-3vpp-vf62-wqp6, GHSA-24fr-44f8-fqwg (CVE-2026-29059), GHSA-6q36-5p3h-766j | +| T8 | Unauthenticated RCE via the Debugger WebSocket in the default `windmill_extra` configuration | remote_unauth | EP15 | Worker host, all assets | critical | possible | unmitigated | `REQUIRE_SIGNED_DEBUG_REQUESTS` exists but defaults to false | GHSA-725h-99vx-9xr4 | +| T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) | +| T10 | Unauthenticated disclosure of job results, args, logs, and admin config via missing-authz public endpoints | remote_unauth | EP2, EP13 | Job results/args/logs, global settings, scripts | high | likely | partially_mitigated | anonymous-job checks, log-endpoint authz hardening | GHSA-qfg7-x243-5hg4, GHSA-v448-fmm4-52fp, 108a88a180, bb90f4ce83 | +| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, or S3 download content-type | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0 | +| T12 | Webhook authentication bypass / signature replay forges trigger invocations and approvals | remote_unauth | EP3 | Job execution integrity, approvals | high | likely | partially_mitigated | HMAC verification on some triggers; signing-oracle fix | GHSA-jw8c-h45c-xpjw, GHSA-hh9x-rcf8-xjr2, GHSA-q9g3-q6fj-hc2x, GHSA-8jc4-wj2p-2vmp, ab2a15b2a8 | +| T13 | Path traversal / arbitrary file read via log-reading and MCP path endpoints (incl. symlink following) | remote_auth | EP13 | Arbitrary files on server, global settings | high | likely | partially_mitigated | traversal checks + no-symlink-follow added | GHSA-4hrf-mgvv-xp9x, bb90f4ce83, df451aa64f, ad5ec293b5, 5f2d3e6812 | +| T14 | Privilege escalation via token rescope/refresh, script-issued JWTs, or operator-permission gaps | remote_auth | EP17, EP5 | Tokens, isolation, accounts | high | likely | partially_mitigated | monotonic-privilege enforcement on token lifecycle; SECURITY DEFINER triggers | GHSA-p62p-67xp-v775, GHSA-vv9w-wx3c-q3x2, 2ddf93de96, 865ab70c89, 33fb08cf3d | +| T15 | Credential leakage via worker `/proc` environment and unmasked secrets in job logs | remote_auth | EP9, EP1 | DB creds, secrets, downstream | high | likely | partially_mitigated | Aho-Corasick secret masking in logs | GHSA-pmp9-9924-f9cx, 0885d8c986 | +| T16 | Denial of service via resource exhaustion: unbounded uploads, runaway jobs, queue flooding, or trigger-message storms | remote_auth | EP1, EP3, EP4 | Service availability, worker fleet | high | likely | risk_accepted | Per-job rlimits/timeouts exist; instance-wide DoS by an authenticated tenant is largely accepted on shared self-host (operator's job to add global quotas). Hard requirement only for managed multi-tenant | | +| T17 | Account/credential theft via unauthenticated MCP-OAuth client registration and open redirect on logout | remote_unauth | EP11 | Accounts, session tokens | high | possible | partially_mitigated | redirect-URI handling / registration hardening | GHSA-q9xg-f2v2-695g, GHSA-53xj-pvqf-wpm9, GHSA-rr8j-ffc4-pf7h, GHSA-6c5w-777m-8rv5 | +| T18 | Account takeover via missing rate limiting / brute force on auth endpoints | remote_unauth | EP11 | Accounts | medium | likely | unmitigated | none built-in; owner confirms instances are typically exposed directly with no app-level rate limiting or WAF | GHSA-cmv6-m7wc-c87p | +| T19 | Enterprise license bypass and account impersonation | remote_auth | EP5 | Global settings, accounts | medium | possible | unmitigated | license validation gated by `license` feature | GHSA-48j5-p323-4mpx, GHSA-pv35-65rq-w29h, GHSA-2qx7-634r-qj6r | +| T20 | Trigger spoofing: an actor with broker/queue access injects messages that execute jobs without app-level auth | adjacent_network | EP4 | Job execution integrity, downstream | medium | possible | risk_accepted | Owner confirms trust is delegated to broker ACLs by design; no app-level message authenticity check. Anyone able to publish to a subscribed topic/queue can cause job execution | | +| T21 | Data-in-transit interception/tampering from TLS-disabled defaults (DB `sslmode=disable`, HTTP-only Caddy) | adjacent_network | EP15 | DB creds, secrets, session tokens | medium | possible | unmitigated | docs recommend TLS; not default | | +| T22 | Repudiation / incident blind spots from gaps in audit coverage of sensitive actions | remote_auth | EP5 | Audit logs | medium | possible | partially_mitigated | `windmill-audit` records many actions | | + +## 5. Deprioritized + +| threat | reason | +|---|---| +| Physical access to the host / cold-boot key extraction | Out of scope; deployment-environment responsibility, not addressable in this codebase | +| Memory-safety RCE in the Rust backend itself | Rust's safety model makes this rare; no evidence in history. Note: `unsafe` FFI (duckdb) is a narrow exception folded into supply-chain/T9 | +| Client-side-only nuisance bugs (CSS, layout) with no security impact | No asset compromised | +| Insider with legitimate superadmin / DB-root access | Trusted role; mitigations are operational (least privilege, audit), not technical controls in scope | +| Spoofing of a fully-trusted upstream IdP that has itself been compromised | Out of model; Windmill trusts the configured IdP by design | +| Instance-wide DoS by an authenticated tenant on shared self-host (T16) | Risk accepted (owner): per-job rlimits/timeouts are in place; global concurrency/queue quotas are the operator's responsibility on self-host. Remains a hard requirement for the managed multi-tenant fleet | +| Job execution triggered by an actor with legitimate broker/queue publish access (T20) | Risk accepted (owner): trigger authenticity is delegated to broker ACLs by design; consuming from a configured source and acting on its messages is the intended behavior | + +## 6. Open questions + +Facts that drove the score changes above. Two were confirmed in code during +the interview (`[Code-verified]`); the rest remain `[Owner-states]` pending a +check. + +- [Code-verified] nsjail is off by default in every configuration: `DISABLE_NSJAIL` defaults to `true` (`windmill-worker/src/worker.rs:346`), and `is_sandboxing_enabled()` requires `DISABLE_NSJAIL=false` or the `job_isolation` global setting = `nsjail_sandboxing` (`worker.rs:890`). PID-ns `unshare` is also off at the code level (`is_unshare_enabled()`, `worker.rs:903`); the shipped `docker-compose.yml` sets `FAVOR_UNSHARE_PID=true` (line 91), so the official compose gives PID-ns unshare only, nsjail off — a bare install gets no isolation at all. No separate `clone_newnet` flag exists; network isolation is an nsjail feature, so outbound network from user code is unrestricted by default. Affects: T2 controls/likelihood, T5 status (unmitigated), T8. +- [Code-verified] `global_settings` is plaintext at rest under the default DB backend: `set_value_in_global_settings` stores the raw JSON value with no encryption (`windmill-common/src/global_settings.rs:259`); the encrypting secret backend (`secret_backend/database.rs:66`) only encrypts per-workspace `variable` rows with `is_secret=true`. Instance-level SMTP/OAuth/AI/object-store secrets are therefore plaintext. Affects: T6 impact/controls, T7. +- [Owner-states] Internet-facing instances are typically exposed directly with no built-in rate limiting / WAF. Affects: T16, T18 likelihood. Verify by: confirm absence of a rate-limit layer in `windmill-api/src/lib.rs` middleware stack. +- [Owner-states] Managed offering provides an optional dedicated DB/worker/namespace tier for sensitive tenants; the shared tier relies solely on the software authz boundary. Affects: T3 controls. Verify by: deployment topology (not in this repo) — out-of-tree. +- [Owner-states] Per-job rlimits/timeouts exist; instance-wide DoS by an authed tenant is risk-accepted on shared self-host. Affects: T16 status. Verify by: locate the rlimit/timeout enforcement in the worker execution path and confirm there is no global queue/concurrency cap. +- [Owner-states] Message-queue trigger authenticity is delegated to broker ACLs only. Affects: T20 status. Verify by: review `windmill-trigger-{kafka,sqs,nats,mqtt,postgres}` consume paths for any payload authentication. + +## 7. Provenance + +- mode: bootstrap-then-interview +- date: 2026-06-05 +- target: /home/rfiszel/windmill/backend @ 819ba5e150 +- inputs: git-log mined + GitHub security advisories (gh api, 73 advisories) + CHANGELOG; seed: THREAT_MODEL.md (bootstrap pass) +- owner: Ruben Fiszel (Windmill core dev) + +## 8. Recommended mitigations + +| mitigation | threat_ids | closes_class | effort | +|---|---|---|---| +| Centralize a single audited query-builder that forbids string-interpolated SQL; ban `format!`-built queries via lint/CI | T1 | yes | M | +| Route all outbound requests through one SSRF-guarded HTTP client (allowlist/denylist of private+metadata ranges, redirects disabled, re-validated per hop) | T2 | yes | M | +| Enforce authorization centrally in middleware (scope + RLS + folder ACL) with deny-by-default and a per-route coverage test, instead of per-handler checks | T3, T10, T14, T22 | yes | L | +| Treat all user-supplied identifiers as data: pass via argv/env/structured params, never splice into generated wrapper source; validate against strict allowlists at the boundary | T4 | yes | M | +| Make `nsjail` + network-namespace isolation default-on / fail-closed (flip `ENABLE_NSJAIL` and `clone_newnet` defaults) and remove privileged/dind defaults from shipped compose; default-deny debugger | T2, T5, T7, T8 | partial | L | +| Encrypt `global_settings` at rest under the workspace/instance key even on the default DB secret backend, so a DB read no longer yields plaintext instance-wide credentials | T6, T7 | partial | M | +| Ship hardened defaults: random per-install secrets, no default admin password, Postgres not exposed, CORS locked to configured origin, TLS-on | T7, T18, T21 | partial | M | +| Resolve secrets/resources only with the caller's identity and scope every cache entry by (caller, scope); apply uniformly to AI proxy, MCP, and exports | T6 | yes | M | +| Output-encode/sanitize all stored content at render and force `nosniff` + restrictive CSP on every user-content response | T11 | yes | M | +| Verify webhook authenticity uniformly (constant-time HMAC + timestamp/nonce anti-replay) in a shared trigger-auth helper | T12 | yes | S | +| Canonicalize + confine all file-path inputs to a base dir and never follow symlinks in log/file readers | T13 | yes | S | +| Mask secrets at the log sink and keep secrets out of worker process env (`/proc`) — pass via files/pipes scrubbed after use | T15 | partial | M | +| Add global rate limiting and per-tenant resource/queue quotas at the edge | T16, T18 | partial | M | +| Pin and integrity-verify hub scripts and CI actions; SBOM + automated base-image CVE scanning in release | T9 | partial | M | From fb175e1c9d24533caab1c771e97d817b052ee3d0 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 5 Jun 2026 10:07:01 +0200 Subject: [PATCH 07/32] fix ee repo ref dynamic oauth urls (#9451) * ee repo ref * fix(ee-ref): pin to EE commit that includes read_only create_session_token fix The previous pin (f7a83d9) carried only the connect_config_template change and dropped Ruben's read_only=false fix (EE 3742e06). CE #9371 made create_session_token require 6 args, so the EE overlay fails check_ee_full with an arity error without it. Bump the pin to 9be38de, which includes both fixes. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: update ee-repo-ref to fb106b89cdf4088b004dac6062adb029f3923887 This commit updates the EE repository reference after PR #603 was merged in windmill-ee-private. Previous ee-repo-ref: 9be38def879f702cd0b134d9e71bbb17fbb9cfa4 New ee-repo-ref: fb106b89cdf4088b004dac6062adb029f3923887 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 15e5b90000..73eaef904f 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -servicenow-oauth +fb106b89cdf4088b004dac6062adb029f3923887 From 1727271e197b34026efeaf1b6561bb404a440baa Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 5 Jun 2026 10:35:51 +0200 Subject: [PATCH 08/32] feat: sandboxed daemonless container runtime via '# sandbox ' (#9453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add sandboxed docker v2 runtime via '# docker ' Run a container image as a subprogram of the job's own nsjail sandbox: extract the image rootfs with podman (rootless) and run it chrooted inside the job's nsjail, so the container inherits the job's confinement and is safe under nsjail / for untrusted code. Selected by '# docker '; a bare '# docker' keeps the v1 (dind) path untouched. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: default to daemonless docker (drop dind from compose, allow docker on cloud) docker-compose no longer ships the dind sidecar (v2 is daemonless: podman + nsjail in the worker); removed the dind service, DOCKER_HOST env, depends_on and volume. Removed the language-picker guard that blocked Docker scripts on the multi-tenant platform, now that v2 makes docker safe to run sandboxed. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: select sandboxed container via # sandbox ; add pull policy + size guards - Surface moved from '# docker ' to '# sandbox ' (groups under the sandbox annotation; '# docker' stays v1-only, '# sandbox' stays nsjail-bash). - SANDBOX_IMAGE_PULL_POLICY (default 'newer') so moving tags don't go stale. - SANDBOX_IMAGE_MAX_SIZE_MB rejects oversized images before extraction. - SANDBOX_IMAGE_CACHE_MAX_MB best-effort LRU eviction of podman's image store. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sandbox): support # volume, honor nsjail tmp instance settings, v2 docker template - Thread shared_mount into the sandbox container nsjail config so '# volume' mounts (and the same-worker /tmp/shared folder) apply inside the container. - Use resolve_nsjail_tmp_mount_block for the container's /tmp so it honors the same nsjail_tmp_backing / nsjail_tmpfs_size_mb instance settings as other nsjail jobs. - docker-compose comment + the editor's Docker template now use '# sandbox '. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sandbox): make image size/cache/pull-policy UI instance settings Convert SANDBOX_IMAGE_* from worker env vars to DB-backed instance settings (sandbox_image_max_size_mb, sandbox_image_cache_max_mb, sandbox_image_pull_policy), hot-reloaded via the same mechanism as nsjail_tmpfs_size_mb and configurable in #superadmin-settings. No worker restart needed. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sandbox): windmill-managed registry — default registry + private auth Two new instance settings: - sandbox_image_default_registry: prepended to unqualified image refs (alpine -> /alpine); fully-qualified refs untouched. - sandbox_registry_auth: docker/podman auth.json blob written to a per-job authfile (0600, removed with the job) and passed to podman --authfile for private registries. Both hot-reloaded and configurable in #superadmin-settings. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): protobuf-safe proto_str escaper, atomic 0600 authfile, registry tests Addresses local-review P2s: proto_str now emits valid protobuf octal escapes for control/non-ASCII bytes (not Rust \u{..} that nsjail would reject); the registry authfile is created 0600 atomically (no world-readable window); add a registry_qualified table test + a non-ASCII proto_str case. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): P0 — deliver image env via nsjail envar:, never the launcher process env CI review (P0): the image's OCI Env (attacker-controlled keys+values) was applied to the nsjail launcher process via .envs(), so a hostile image could set LD_PRELOAD/ LD_LIBRARY_PATH/LD_AUDIT on nsjail itself and execute code as the worker outside the jail. Now the image env is rendered as proto-escaped 'envar:' directives (child-only) and nsjail's process env carries only windmill-trusted keys (reserved vars + proxy). Also: warn instead of silently bypassing the size guard on inspect failure; reset the eviction guard via a Drop guard (no stuck flag on panic/early-return). +render_envars test. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): P0 symlink-write escape via rootfs script; P1 redact registry-auth logging CI review: - P0 (Codex): the body was written into the image-controlled rootfs as .windmill_docker_main.sh via write_file (follows symlinks) — a hostile image could plant that path as a symlink to a host file and capture the worker's write before nsjail starts. Now the body is passed straight to 'sh -c sh '; no file is written into the rootfs at all. - P1 (Codex): sandbox_registry_auth flowed through the generic setting loader which logs the value (raw auth.json credentials). Replaced with a secret-aware reload that loads directly and logs only a redacted 'configured=' message. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): redact sandbox_registry_auth in instance-settings write log too The settings API also logs 'Set global setting to ' via format_setting_value; add sandbox_registry_auth to SENSITIVE_SETTINGS so the credential is redacted there as well as on reload. * fix(sandbox): don't silently disable cache eviction on podman images parse error Re-review (cubic/Claude P2): serde_json::from_slice(...).unwrap_or_default() meant any parse hiccup (e.g. podman omitting Size/Created via omitempty for a zero value, or schema drift) silently degraded to an empty Vec and disabled eviction with no log. Now Size/Created are #[serde(default)] (a missing omitempty key -> 0, not a whole-array parse failure) and a real parse error warns + breaks instead of being swallowed. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/src/main.rs | 33 +- backend/src/monitor.rs | 75 +- .../windmill-common/src/global_settings.rs | 5 + .../windmill-common/src/instance_config.rs | 1 + backend/windmill-common/src/worker.rs | 59 ++ .../nsjail/run.docker.config.proto | 103 +++ backend/windmill-worker/src/bash_executor.rs | 36 +- backend/windmill-worker/src/common.rs | 10 + backend/windmill-worker/src/docker_v2.rs | 683 ++++++++++++++++++ backend/windmill-worker/src/lib.rs | 1 + backend/windmill-worker/src/worker.rs | 21 + docker-compose.yml | 35 +- docs/docker-v2-runtime.md | 106 +++ .../src/lib/components/ScriptBuilder.svelte | 15 - .../flows/content/FlowInputs.svelte | 19 - .../flows/content/FlowInputsQuick.svelte | 19 - .../src/lib/components/instanceSettings.ts | 54 ++ frontend/src/lib/script_helpers.ts | 21 +- 18 files changed, 1181 insertions(+), 115 deletions(-) create mode 100644 backend/windmill-worker/nsjail/run.docker.config.proto create mode 100644 backend/windmill-worker/src/docker_v2.rs create mode 100644 docs/docker-v2-runtime.md diff --git a/backend/src/main.rs b/backend/src/main.rs index e5daf3201b..b4d3cef4f9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -57,11 +57,14 @@ use windmill_common::{ PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, - SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, - WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, - WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING, + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING, + SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, + STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, + WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -134,8 +137,11 @@ use crate::monitor::{ reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting, - reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, - reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, + reload_pip_index_url_setting, reload_retention_period_setting, + reload_sandbox_image_cache_max_setting, reload_sandbox_image_default_registry_setting, + reload_sandbox_image_max_size_setting, reload_sandbox_image_pull_policy_setting, + reload_sandbox_registry_auth_setting, reload_scim_token_setting, reload_smtp_config, + reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting, reload_worker_config, MonitorIteration, }; @@ -1827,6 +1833,19 @@ async fn process_notify_event( JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await, NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await, NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING => { + reload_sandbox_image_max_size_setting(conn).await + } + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING => { + reload_sandbox_image_cache_max_setting(conn).await + } + SANDBOX_IMAGE_PULL_POLICY_SETTING => { + reload_sandbox_image_pull_policy_setting(conn).await + } + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING => { + reload_sandbox_image_default_registry_setting(conn).await + } + SANDBOX_REGISTRY_AUTH_SETTING => reload_sandbox_registry_auth_setting(conn).await, #[cfg(feature = "parquet")] OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 13f52037e7..789706e7f8 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -66,7 +66,9 @@ use windmill_common::{ OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, - RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, + RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, @@ -112,8 +114,10 @@ use windmill_worker::{ JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, - PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, - UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB, + SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, + SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, + UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -407,6 +411,11 @@ pub async fn initial_load( reload_job_isolation_setting(&conn).await; reload_nsjail_tmpfs_size_setting(&conn).await; reload_nsjail_tmp_backing_setting(&conn).await; + reload_sandbox_image_max_size_setting(&conn).await; + reload_sandbox_image_cache_max_setting(&conn).await; + reload_sandbox_image_pull_policy_setting(&conn).await; + reload_sandbox_image_default_registry_setting(&conn).await; + reload_sandbox_registry_auth_setting(&conn).await; reload_extra_pip_index_url_setting(&conn).await; reload_pip_index_url_setting(&conn).await; reload_uv_index_strategy_setting(&conn).await; @@ -2045,6 +2054,66 @@ pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) { .await; } +pub async fn reload_sandbox_image_max_size_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + "SANDBOX_IMAGE_MAX_SIZE_MB", + SANDBOX_IMAGE_MAX_SIZE_MB.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_cache_max_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + "SANDBOX_IMAGE_CACHE_MAX_MB", + SANDBOX_IMAGE_CACHE_MAX_MB.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_pull_policy_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_PULL_POLICY_SETTING, + "SANDBOX_IMAGE_PULL_POLICY", + SANDBOX_IMAGE_PULL_POLICY.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_default_registry_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, + "SANDBOX_IMAGE_DEFAULT_REGISTRY", + SANDBOX_IMAGE_DEFAULT_REGISTRY.clone(), + ) + .await; +} + +pub async fn reload_sandbox_registry_auth_setting(conn: &Connection) { + // Secret-aware: the value is a raw docker/podman auth.json with credentials, so + // it must never be logged. Load directly (the generic reload_option_setting path + // logs the value via load_option_setting_value) and only log a redacted message. + let q = + match load_value_from_global_settings_with_conn(conn, SANDBOX_REGISTRY_AUTH_SETTING, true) + .await + { + Ok(q) => q, + Err(e) => { + tracing::error!("Error reloading setting SANDBOX_REGISTRY_AUTH: {e:?}"); + return; + } + }; + let value = q.and_then(|q| serde_json::from_value::(q).ok()); + let configured = value.as_ref().is_some_and(|v| !v.trim().is_empty()); + *SANDBOX_REGISTRY_AUTH.write().await = value; + tracing::info!("Loaded setting SANDBOX_REGISTRY_AUTH (redacted), configured={configured}"); +} + pub async fn reload_job_isolation_setting(conn: &Connection) { let value = match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 75f3fdf06b..8192f186d0 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,6 +58,11 @@ pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; pub const NSJAIL_TMP_BACKING_DISK: &str = "disk"; pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs"; +pub const SANDBOX_IMAGE_MAX_SIZE_MB_SETTING: &str = "sandbox_image_max_size_mb"; +pub const SANDBOX_IMAGE_CACHE_MAX_MB_SETTING: &str = "sandbox_image_cache_max_mb"; +pub const SANDBOX_IMAGE_PULL_POLICY_SETTING: &str = "sandbox_image_pull_policy"; +pub const SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING: &str = "sandbox_image_default_registry"; +pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth"; pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 2239868982..bb56d5d0cc 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -976,6 +976,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ "ruby_repos", "powershell_repo_pat", "workspace_registries", + "sandbox_registry_auth", ]; /// Object-valued settings that contain sensitive sub-fields. diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 92ebf08477..c7816d7a70 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -859,6 +859,37 @@ pub struct BashAnnotations { pub sandbox: bool, } +impl BashAnnotations { + /// If the script declares `# sandbox ` (an image ref after the sandbox + /// annotation), returns that image ref. This selects the daemonless, sandboxed + /// container runtime: extract the image's rootfs and run it inside the job's + /// nsjail sandbox. + /// + /// A bare `# sandbox` (no image argument) returns `None` and keeps the plain + /// nsjail-sandboxed-bash behavior (the `sandbox` boolean modifier). `# docker` + /// is unaffected and keeps the legacy v1 (dind/daemon) path. + pub fn sandbox_image(code: &str) -> Option { + for line in code.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Mirror the annotation parser: stop at the first non-comment line. + if !line.starts_with('#') { + break; + } + let mut tokens = line[1..].split_whitespace(); + if tokens.next() == Some("sandbox") { + // `# sandbox ` -> container; bare `# sandbox` -> nsjail bash. + if let Some(image) = tokens.next() { + return Some(image.to_string()); + } + } + } + None + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum SqlResultCollectionStrategy { LastStatementAllRows, @@ -2224,6 +2255,34 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn test_bash_sandbox_image_annotation() { + // `# sandbox ` selects the container runtime and returns the image. + assert_eq!( + BashAnnotations::sandbox_image("# sandbox alpine:latest\necho hi"), + Some("alpine:latest".to_string()) + ); + // Extra whitespace and a leading non-spaced `#` still work. + assert_eq!( + BashAnnotations::sandbox_image("#sandbox python:3.12-slim\n"), + Some("python:3.12-slim".to_string()) + ); + // A bare `# sandbox` (no image) keeps the nsjail-bash modifier -> None. + assert_eq!(BashAnnotations::sandbox_image("# sandbox\necho hi"), None); + // `sandbox` must be its own token, not a prefix. + assert_eq!(BashAnnotations::sandbox_image("# sandboxed foo"), None); + // Stops at the first non-comment line (image declared too late is ignored). + assert_eq!( + BashAnnotations::sandbox_image("echo hi\n# sandbox alpine"), + None + ); + // `# docker` is a different annotation -> not a sandbox image. + assert_eq!( + BashAnnotations::sandbox_image("# docker alpine\necho hi"), + None + ); + } + #[test] fn test_mixed_tags() { let input = vec![ diff --git a/backend/windmill-worker/nsjail/run.docker.config.proto b/backend/windmill-worker/nsjail/run.docker.config.proto new file mode 100644 index 0000000000..a2da459fbe --- /dev/null +++ b/backend/windmill-worker/nsjail/run.docker.config.proto @@ -0,0 +1,103 @@ +name: "docker v2 run" + +mode: ONCE +hostname: "container" +log_level: ERROR +time_limit: {TIMEOUT} + +disable_rl: true + +cwd: {WORKDIR} + +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +# keep_env forwards nsjail's OWN process env (only windmill-trusted keys: reserved +# vars + proxy) to the child. The image's attacker-controlled Env is delivered via +# the envar directives below — NEVER nsjail's process env, so a hostile image cannot +# set LD_PRELOAD/LD_LIBRARY_PATH/LD_AUDIT on the nsjail binary itself. +keep_env: true +mount_proc: true + +# Image Env (+ PATH/HOME fallbacks), proto-escaped. Applied to the child only. +{ENVARS} + +# Map uid/gid 0 inside the jail to the (single) worker user outside. The image's +# rootfs is extracted as the worker user, so a root process inside the container +# owns the rootfs and runs like a normal "root in container" — without any subuid +# range. Multi-uid images are a later enhancement (newuidmap range). +uidmap { + inside_id: "0" + outside_id: "" + count: 1 +} +gidmap { + inside_id: "0" + outside_id: "" + count: 1 +} + +# The image's root filesystem, bound one top-level entry at a time. Binding the +# whole rootfs at "/" trips nsjail's read-only remount of its base root in a +# rootless userns ("mount(... MS_REMOUNT|MS_BIND|MS_RDONLY): Operation not +# permitted"); per-entry binds sit as rw submounts under nsjail's own tmpfs root +# and avoid it. Generated from the extracted rootfs. +{ROOTFS_MOUNTS} + +# Pseudo-filesystems the image expects. /tmp honors the same instance settings as +# every other nsjail job (nsjail_tmp_backing tmpfs/disk, nsjail_tmpfs_size_mb); +# /dev gets the standard nodes; /proc comes from mount_proc (the jail's own pid ns). +{TMP_MOUNT_BLOCK} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/zero" + dst: "/dev/zero" + is_bind: true + rw: true +} + +mount { + src: "/dev/random" + dst: "/dev/random" + is_bind: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true +} + +# Host DNS config layered over the image's /etc so name resolution works on the +# job's network (mandatory:false: some minimal images have no /etc files to shadow). +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +# `# volume` mounts (and the same-worker /tmp/shared folder). Placed after the +# rootfs binds and the tmpfs /tmp so a volume target overrides any colliding image +# path and isn't shadowed by the tmpfs. Empty when there are no volumes. +{SHARED_MOUNT} + +iface_no_lo: true + +#{DEV} diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 516c75fdba..4c470b8dfc 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -40,9 +40,9 @@ use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{ - build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + build_args_map, build_command_with_isolation, get_reserved_variables, raw_to_string, + read_file, read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -57,14 +57,6 @@ lazy_static::lazy_static! { pub static ref ANSI_ESCAPE_RE: Regex = Regex::new(r"\x1b\[[0-9;]*m").unwrap(); } -fn raw_to_string(x: &str) -> String { - match serde_json::from_str::(x) { - Ok(serde_json::Value::String(x)) => x, - Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()), - _ => String::new(), - } -} - #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_bash_job( mem_peak: &mut i32, @@ -84,6 +76,28 @@ pub async fn handle_bash_job( ) -> Result, Error> { let annotation = windmill_common::worker::BashAnnotations::parse(&content); + // `# sandbox ` selects the daemonless, nsjail-sandboxed container runtime + // (extract the image's rootfs + run it inside the job's sandbox). A bare + // `# sandbox` keeps the plain nsjail-bash modifier; `# docker` keeps v1 (dind). + if let Some(image) = windmill_common::worker::BashAnnotations::sandbox_image(content) { + return crate::docker_v2::handle_docker_v2_job( + &image, + mem_peak, + canceled_by, + job, + conn, + client, + parent_runnable_path, + content, + job_dir, + shared_mount, + base_internal_url, + worker_name, + occupancy_metrics, + ) + .await; + } + // Check if sandbox annotation is used but nsjail is not available if annotation.sandbox && NSJAIL_AVAILABLE.is_none() { return Err(Error::ExecutionErr( diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 9864093cd0..1bef9e4c6d 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -68,6 +68,16 @@ mount { #[cfg(not(debug_assertions))] pub const DEV_CONF_NSJAIL: &str = ""; +/// Turn a JSON value into the string a shell/CLI arg should receive: a JSON string +/// becomes its inner value, anything else is re-serialized compactly. +pub(crate) fn raw_to_string(x: &str) -> String { + match serde_json::from_str::(x) { + Ok(serde_json::Value::String(x)) => x, + Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()), + _ => String::new(), + } +} + pub async fn build_args_map<'a>( job: &'a MiniPulledJob, client: &AuthedClient, diff --git a/backend/windmill-worker/src/docker_v2.rs b/backend/windmill-worker/src/docker_v2.rs new file mode 100644 index 0000000000..6a99d252f3 --- /dev/null +++ b/backend/windmill-worker/src/docker_v2.rs @@ -0,0 +1,683 @@ +//! Sandboxed container runtime: run a container as a sandboxed subprogram of the job. +//! +//! Unlike the legacy `# docker` (dind/daemon) path, this has no daemon and no Docker +//! API. It splits *pull* from *run*: +//! +//! 1. **pull/extract** (podman, rootless): materialize the image's root filesystem +//! into `{job_dir}/rootfs` and read its OCI config (Env/Cmd/Entrypoint/WorkingDir). +//! 2. **run** (the job's own nsjail sandbox): execute the image command with the +//! extracted rootfs bound in as the new root, so the container inherits exactly +//! the job's confinement (filesystem mask, pid namespace, network, uid) and can't +//! escape past what the job itself can reach. +//! +//! Selected by `# sandbox ` (a bare `# sandbox` keeps plain nsjail-bash; +//! `# docker` keeps the v1 daemon path). The script body runs inside the image via +//! `/bin/sh`; an empty body runs the image's ENTRYPOINT/CMD. + +use std::process::Stdio; + +use serde::Deserialize; +use serde_json::{json, value::RawValue}; +use sqlx::types::Json; +use tokio::process::Command; + +use windmill_common::{client::AuthedClient, scripts::ScriptLang}; +use windmill_common::{ + error::Error, + worker::{to_raw_value, write_file, Connection}, +}; + +use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; + +use crate::{ + common::{ + build_args_map, get_reserved_variables, raw_to_string, resolve_nsjail_timeout, + resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + }, + get_proxy_envs_for_lang, + handle_child::handle_child, + DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB, + SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, + SANDBOX_REGISTRY_AUTH, +}; + +const NSJAIL_CONFIG_RUN_DOCKER_CONTENT: &str = include_str!("../nsjail/run.docker.config.proto"); + +const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +lazy_static::lazy_static! { + pub static ref PODMAN_PATH: String = + std::env::var("PODMAN_PATH").unwrap_or_else(|_| "podman".to_string()); +} + +/// Guards against overlapping cache-eviction passes across concurrent jobs. +static EVICTION_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// podman pull policy from the `sandbox_image_pull_policy` instance setting. `newer` +/// (the default when unset/invalid) re-pulls only when the registry digest changed — +/// one cheap manifest check per job, no transfer if unchanged — so moving tags like +/// `:latest` don't go stale. `missing` is fastest (tags can go stale); `always` +/// re-checks every job. +async fn pull_policy() -> String { + let p = SANDBOX_IMAGE_PULL_POLICY.read().await.clone(); + match p.as_deref() { + Some(p @ ("missing" | "newer" | "always" | "never")) => p.to_string(), + _ => "newer".to_string(), + } +} + +/// `sandbox_image_max_size_mb` instance setting; 0 (or unset/non-positive) = no limit. +async fn max_image_size_mb() -> u64 { + SANDBOX_IMAGE_MAX_SIZE_MB.read().await.unwrap_or(0).max(0) as u64 +} + +/// `sandbox_image_cache_max_mb` instance setting; 0 (or unset/non-positive) = unbounded. +async fn image_cache_max_mb() -> u64 { + SANDBOX_IMAGE_CACHE_MAX_MB.read().await.unwrap_or(0).max(0) as u64 +} + +/// A ref is registry-qualified if the component before the first `/` looks like a +/// host (contains `.` or `:`, or is `localhost`). Bare repos (`alpine`, +/// `alpine:latest`, `myorg/img`) are unqualified and resolve against docker.io — +/// or the configured default registry. +fn registry_qualified(image: &str) -> bool { + match image.split_once('/') { + None => false, + Some((first, _)) => first.contains('.') || first.contains(':') || first == "localhost", + } +} + +/// Prepend the `sandbox_image_default_registry` instance setting to unqualified image +/// refs (fully-qualified refs are left untouched). +async fn resolve_image_ref(image: &str) -> String { + let registry = SANDBOX_IMAGE_DEFAULT_REGISTRY.read().await.clone(); + match registry { + Some(registry) if !registry.trim().is_empty() && !registry_qualified(image) => { + format!("{}/{}", registry.trim().trim_end_matches('/'), image) + } + _ => image.to_string(), + } +} + +/// If the `sandbox_registry_auth` instance setting holds a docker/podman `auth.json` +/// blob, write it to a per-job authfile (0600, removed with the job) and return its +/// path to pass to `podman --authfile`. Returns `None` when unset. +async fn write_auth_file(job_dir: &str) -> Result, Error> { + let auth = SANDBOX_REGISTRY_AUTH.read().await.clone(); + let Some(auth) = auth.filter(|a| !a.trim().is_empty()) else { + return Ok(None); + }; + let path = format!("{job_dir}/registry_auth.json"); + // Create 0600 from the start (registry credentials) — no world-readable window. + #[cfg(unix)] + { + use tokio::io::AsyncWriteExt; + let mut f = tokio::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path) + .await?; + f.write_all(auth.as_bytes()).await?; + } + #[cfg(not(unix))] + tokio::fs::write(&path, auth).await?; + Ok(Some(path)) +} + +/// The subset of an image's OCI config we apply to the run. +#[derive(Deserialize, Default, Debug)] +struct OciConfig { + #[serde(default, rename = "Env")] + env: Option>, + #[serde(default, rename = "Cmd")] + cmd: Option>, + #[serde(default, rename = "Entrypoint")] + entrypoint: Option>, + #[serde(default, rename = "WorkingDir")] + working_dir: Option, +} + +/// Quote a string as a protobuf-text-format string literal for safe inclusion in +/// the nsjail config. Image-controlled values (mount srcs/dsts, symlink targets, +/// WorkingDir) flow into the config, so they MUST be escaped — an unescaped `"` or +/// newline would otherwise let a hostile image config inject arbitrary nsjail +/// directives and break out of the sandbox. Every byte is emitted as a printable +/// ASCII char or a valid protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit +/// octal `\NNN` for control/non-ASCII bytes), so the result always parses. +fn proto_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for &b in s.as_bytes() { + match b { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + 0x20..=0x7e => out.push(b as char), + _ => out.push_str(&format!("\\{b:03o}")), + } + } + out.push('"'); + out +} + +/// Render container env vars as nsjail `envar:` directives (one per line). Each +/// `KEY=VALUE` is proto-escaped, so image-controlled keys/values can neither break +/// the config nor reach nsjail's own process environment. +fn render_envars(env: &[(String, String)]) -> String { + env.iter() + .map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}")))) + .collect::>() + .join("\n") +} + +async fn podman(args: &[&str]) -> Result { + Command::new(PODMAN_PATH.as_str()) + .args(args) + .output() + .await + .map_err(|e| Error::ExecutionErr(format!("failed to run podman {}: {e}", args.join(" ")))) +} + +/// Pull (if needed) and unpack `image` into `{job_dir}/rootfs`, returning its OCI +/// config. Uses podman rootless: `create` (auto-pulls) + `export | tar -x`, with the +/// config read from the resulting container (== image config, no command override). +async fn extract_image(image: &str, job_dir: &str) -> Result { + let rootfs = format!("{job_dir}/rootfs"); + tokio::fs::create_dir_all(&rootfs).await?; + + // `podman create` (no command) pulls the image per the configured policy and + // records the image's own Cmd/Entrypoint, which we then read back from the + // container config. `--` guards against an `image` ref that starts with `-` being + // parsed as a flag (e.g. `--authfile=...`) — the ref is attacker-controlled in + // the untrusted case. + let pull = format!("--pull={}", pull_policy().await); + let mut create_args = vec!["create", &pull]; + let authfile = write_auth_file(job_dir).await?; + if let Some(authfile) = authfile.as_deref() { + create_args.push("--authfile"); + create_args.push(authfile); + } + create_args.push("--"); + create_args.push(image); + let created = podman(&create_args).await?; + if !created.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to pull/create image {image}: {}", + String::from_utf8_lossy(&created.stderr) + ))); + } + let container_id = String::from_utf8_lossy(&created.stdout).trim().to_string(); + + // Always clean up the container, even on a later failure. + let result = extract_created(image, &container_id, &rootfs).await; + let _ = podman(&["rm", "-f", &container_id]).await; + result +} + +async fn extract_created( + image: &str, + container_id: &str, + rootfs: &str, +) -> Result { + // Reject oversized images before paying the (large) extraction cost. + enforce_image_size_limit(image).await?; + + let inspected = podman(&["inspect", container_id, "--format", "{{json .Config}}"]).await?; + if !inspected.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to inspect image {image}: {}", + String::from_utf8_lossy(&inspected.stderr) + ))); + } + let config: OciConfig = serde_json::from_slice(&inspected.stdout) + .map_err(|e| Error::ExecutionErr(format!("failed to parse image {image} config: {e}")))?; + + // Flatten the image's layers into a rootfs directory. Go through a tar on disk + // (in the job dir, cleaned up with the job) rather than a shell pipe. Extracted + // as the worker user, so the rootfs is owned by the worker user — which the + // single-uid jail maps to uid 0 inside. + let tar_path = format!("{rootfs}.tar"); + let exported = podman(&["export", container_id, "--output", &tar_path]).await?; + if !exported.status.success() { + let _ = tokio::fs::remove_file(&tar_path).await; + return Err(Error::ExecutionErr(format!( + "failed to export image {image}: {}", + String::from_utf8_lossy(&exported.stderr) + ))); + } + let untar = Command::new("tar") + .args(["-xf", &tar_path, "-C", rootfs]) + .output() + .await + .map_err(|e| Error::ExecutionErr(format!("failed to run tar: {e}")))?; + let _ = tokio::fs::remove_file(&tar_path).await; + if !untar.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to unpack image {image}: {}", + String::from_utf8_lossy(&untar.stderr) + ))); + } + + Ok(config) +} + +/// Reject the image if its on-disk (uncompressed) size exceeds +/// `SANDBOX_IMAGE_MAX_SIZE_MB`. No-op when the limit is 0 (unset). +async fn enforce_image_size_limit(image: &str) -> Result<(), Error> { + let max = max_image_size_mb().await; + if max == 0 { + return Ok(()); + } + let out = podman(&["image", "inspect", image, "--format", "{{.Size}}"]).await?; + if !out.status.success() { + // Don't silently bypass the guard — surface it so an operator can see the + // size limit isn't being enforced for this image. + tracing::warn!( + "sandbox image size guard: `podman image inspect {image}` failed, not \ + enforcing SANDBOX_IMAGE_MAX_SIZE_MB: {}", + String::from_utf8_lossy(&out.stderr) + ); + return Ok(()); + } + let bytes: u64 = String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .unwrap_or(0); + let mb = bytes / 1_000_000; + if mb > max { + return Err(Error::ExecutionErr(format!( + "image {image} is {mb} MB, over the SANDBOX_IMAGE_MAX_SIZE_MB limit of {max} MB" + ))); + } + Ok(()) +} + +#[derive(Deserialize)] +struct PodmanImage { + #[serde(rename = "Id")] + id: String, + // `default`: podman tags Size/Created `omitempty`, so a degenerate image with a + // zero value drops the key — without this the whole array would fail to parse. + #[serde(default, rename = "Size")] + size: u64, + #[serde(default, rename = "Created")] + created: i64, +} + +/// Best-effort eviction: while the summed size of podman's images exceeds +/// `SANDBOX_IMAGE_CACHE_MAX_MB`, remove the oldest (by created time, an LRU proxy). +/// No-op when the limit is 0 (unset). Skipped if another pass is already running. +/// Images currently backing a container (e.g. a concurrent job mid-extract) fail +/// `rmi` and stop the pass, so in-use images are never removed. +async fn enforce_image_cache_limit() { + use std::sync::atomic::Ordering; + let max_mb = image_cache_max_mb().await; + if max_mb == 0 { + return; + } + if EVICTION_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + // Reset the guard on every exit path (incl. an early `break` or a panic), so a + // stuck flag can never permanently disable eviction until a worker restart. + struct ResetOnDrop; + impl Drop for ResetOnDrop { + fn drop(&mut self) { + EVICTION_RUNNING.store(false, std::sync::atomic::Ordering::SeqCst); + } + } + let _reset = ResetOnDrop; + let max_bytes = max_mb.saturating_mul(1_000_000); + loop { + let Ok(out) = podman(&["images", "--format", "json"]).await else { + break; + }; + if !out.status.success() { + break; + } + let mut imgs: Vec = match serde_json::from_slice(&out.stdout) { + Ok(v) => v, + Err(e) => { + // Don't silently disable eviction on a schema hiccup — surface it. + tracing::warn!( + "sandbox image cache eviction: cannot parse `podman images` json: {e}" + ); + break; + } + }; + let total: u64 = imgs.iter().map(|i| i.size).sum(); + if total <= max_bytes || imgs.is_empty() { + break; + } + imgs.sort_by_key(|i| i.created); + let victim = imgs[0].id.clone(); + match podman(&["rmi", &victim]).await { + Ok(rm) if rm.status.success() => { + tracing::info!("sandbox image cache eviction: removed {victim}"); + } + Ok(rm) => { + tracing::warn!( + "sandbox image cache eviction: cannot remove {victim} (in use?): {}", + String::from_utf8_lossy(&rm.stderr) + ); + break; + } + Err(_) => break, + } + } + // `_reset` drops here and clears EVICTION_RUNNING. +} + +/// Build the nsjail mount block that binds each top-level entry of the rootfs in +/// place. Binding the whole rootfs at `/` trips nsjail's read-only remount of its +/// base root in a rootless userns; per-entry binds avoid it. `proc`, `dev`, `tmp` +/// and `sys` are skipped — the profile provides them. +async fn generate_rootfs_mounts(rootfs: &str) -> Result { + let mut block = String::new(); + let mut entries = tokio::fs::read_dir(rootfs).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if matches!(name.as_ref(), "proc" | "dev" | "tmp" | "sys") { + continue; + } + let src = proto_str(&format!("{rootfs}/{name}")); + let dst = proto_str(&format!("/{name}")); + let file_type = entry.file_type().await?; + if file_type.is_symlink() { + // Recreate top-level symlinks (e.g. usr-merged /bin -> usr/bin) as + // symlinks in the jail. The target is image-controlled but only ever + // *resolved inside the jail* (against the bound rootfs dirs / jail + // pseudo-fs) — there is no host `/` in the jail for it to point at — and + // it is escaped via proto_str, so it can neither escape nor inject config. + let target = tokio::fs::read_link(entry.path()) + .await + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + block.push_str(&format!( + "mount {{\n src: {}\n dst: {dst}\n is_symlink: true\n mandatory: false\n}}\n", + proto_str(&target), + )); + } else { + block.push_str(&format!( + "mount {{\n src: {src}\n dst: {dst}\n is_bind: true\n rw: true\n mandatory: false\n}}\n", + )); + } + } + Ok(block) +} + +#[tracing::instrument(level = "trace", skip_all)] +pub async fn handle_docker_v2_job( + image: &str, + mem_peak: &mut i32, + canceled_by: &mut Option, + job: &MiniPulledJob, + conn: &Connection, + client: &AuthedClient, + parent_runnable_path: Option, + content: &str, + job_dir: &str, + shared_mount: &str, + base_internal_url: &str, + worker_name: &str, + occupancy_metrics: &mut OccupancyMetrics, +) -> Result, Error> { + // The sandboxed container runtime *is* nsjail, so it requires nsjail. (`# docker` + // keeps the v1 dind path for non-sandboxed workers.) + if NSJAIL_AVAILABLE.is_none() { + return Err(Error::ExecutionErr(format!( + "`# sandbox {image}` runs the image inside nsjail, which is not available on \ + this worker. Install nsjail, or use a bare `# docker` (dind) instead." + ))); + } + + // Apply the default-registry instance setting to unqualified refs. + let resolved_image = resolve_image_ref(image).await; + let image = resolved_image.as_str(); + + append_logs( + &job.id, + &job.workspace_id, + format!("\n\n--- SANDBOXED CONTAINER (nsjail) ---\nextracting image {image}...\n"), + conn, + ) + .await; + + let config = extract_image(image, job_dir).await?; + let rootfs = format!("{job_dir}/rootfs"); + + // Best-effort: keep podman's image store under its size cap (overlaps the run). + tokio::spawn(enforce_image_cache_limit()); + + // Resolve the script args from the bash signature, like the bash executor. + let args = build_args_map(job, client, conn).await?.map(Json); + let job_args = if args.is_some() { + args.as_ref() + } else { + job.args.as_ref() + }; + let args_owned = windmill_parser_bash::parse_bash_sig(content)? + .args + .iter() + .map(|arg| { + job_args + .and_then(|x| x.get(&arg.name).map(|x| raw_to_string(x.get()))) + .unwrap_or_else(String::new) + }) + .collect::>(); + + // The body is everything that isn't a leading `#` annotation/comment line. With + // a body we run it via the image's `/bin/sh`; without one we run the image's + // ENTRYPOINT + CMD. + let has_body = content + .lines() + .any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#')); + + let cmd_args: Vec = if has_body { + // Pass the body straight to `sh -c` rather than writing a script file into + // the image-controlled rootfs: a malicious image could plant that path as a + // symlink to a host file and capture the worker's write before nsjail starts + // (sandbox-boundary bypass). `sh -c sh ` binds args as $1.. . + let mut v = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("set -e\n{content}"), + "sh".to_string(), + ]; + v.extend(args_owned.iter().cloned()); + v + } else { + let mut v = config.entrypoint.clone().unwrap_or_default(); + v.extend(config.cmd.clone().unwrap_or_default()); + if v.is_empty() { + return Err(Error::ExecutionErr(format!( + "image {image} has no ENTRYPOINT/CMD and the script body is empty — \ + nothing to run" + ))); + } + v.extend(args_owned.iter().cloned()); + v + }; + + let working_dir = config + .working_dir + .as_deref() + .filter(|w| !w.is_empty()) + .unwrap_or("/"); + + // The image's OCI Env is attacker-controlled (BOTH keys and values), so it must + // NOT enter the nsjail launcher's own process env: a hostile image could set + // LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT and have the dynamic loader run code in + // the nsjail binary as the worker — outside the jail — before it sandboxes. + // Deliver it to the *child only* via proto-escaped `envar:` directives. + let mut container_env: Vec<(String, String)> = Vec::new(); + for kv in config.env.unwrap_or_default() { + if let Some((k, v)) = kv.split_once('=') { + container_env.push((k.to_string(), v.to_string())); + } + } + if !container_env.iter().any(|(k, _)| k == "PATH") { + container_env.push(("PATH".to_string(), DEFAULT_PATH.to_string())); + } + if !container_env.iter().any(|(k, _)| k == "HOME") { + container_env.push(("HOME".to_string(), "/root".to_string())); + } + let envars = render_envars(&container_env); + + // Render the nsjail profile: dynamic per-entry rootfs binds + image WorkingDir. + let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; + let rootfs_mounts = generate_rootfs_mounts(&rootfs).await?; + write_file( + job_dir, + "run.docker.config.proto", + &NSJAIL_CONFIG_RUN_DOCKER_CONTENT + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + // proto_str-quoted: WorkingDir is image-controlled, must not break out + // of the `cwd:` string and inject nsjail directives. + .replace("{WORKDIR}", &proto_str(working_dir)) + .replace("{ROOTFS_MOUNTS}", &rootfs_mounts) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + // `# volume` mounts + same-worker shared folder (empty if none). + .replace("{SHARED_MOUNT}", shared_mount) + // Image env as `envar:` directives (child-only), so it never touches + // nsjail's process env. + .replace("{ENVARS}", &envars) + .replace("#{DEV}", DEV_CONF_NSJAIL), + )?; + + // nsjail's OWN process env: only windmill-trusted keys (reserved vars so + // `wmill`/API calls work, + proxy). `keep_env: true` forwards these to the + // child. The image env is NOT here — see container_env above. + let mut reserved_variables = + get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; + reserved_variables.insert("RUST_LOG".to_string(), "info".to_string()); + reserved_variables.insert( + "BASE_INTERNAL_URL".to_string(), + base_internal_url.to_string(), + ); + + let proxy_envs = get_proxy_envs_for_lang( + &ScriptLang::Bash, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?; + + let mut nsjail_run_args = vec!["--config", "run.docker.config.proto", "--"]; + nsjail_run_args.extend(cmd_args.iter().map(|s| s.as_str())); + + let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); + nsjail_cmd + .current_dir(job_dir) + .env_clear() + .envs(reserved_variables) + .envs(proxy_envs) + .args(nsjail_run_args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?; + + handle_child( + &job.id, + conn, + mem_peak, + canceled_by, + child, + true, + worker_name, + &job.workspace_id, + "sandboxed container run", + job.timeout, + true, + &mut Some(occupancy_metrics), + None, + None, + ) + .await?; + + Ok(to_raw_value(&json!(format!( + "sandboxed container ({image}) completed successfully" + )))) +} + +#[cfg(test)] +mod tests { + use super::{proto_str, registry_qualified, render_envars}; + + #[test] + fn render_envars_emits_proto_directives() { + // Image-controlled env (incl. loader vars) is rendered as `envar:` directives + // — i.e. delivered to the child via the config, NOT nsjail's process env, so + // it can never set LD_PRELOAD/etc. on the nsjail binary itself. + let env = vec![ + ("PATH".to_string(), "/usr/bin".to_string()), + ("LD_PRELOAD".to_string(), "rootfs/evil.so".to_string()), + ]; + let out = render_envars(&env); + assert_eq!( + out, + "envar: \"PATH=/usr/bin\"\nenvar: \"LD_PRELOAD=rootfs/evil.so\"" + ); + // A value trying to inject extra directives is escaped, not interpreted. + let evil = vec![("X".to_string(), "v\"\nclone_newuser: false".to_string())]; + let line = render_envars(&evil); + assert!(line.starts_with("envar: \"")); + assert!(!line.contains("\nclone_newuser")); + assert!(line.contains("\\n")); + } + + #[test] + fn proto_str_escapes_injection() { + // Normal paths are just wrapped in quotes. + assert_eq!(proto_str("/app"), "\"/app\""); + // A `"` is escaped so it cannot close the surrounding string and inject + // subsequent nsjail directives — this is what the WorkingDir / mount-src + // sandboxing fixes depend on. + let malicious = "/x\"\nmount { src: \"/\" dst: \"/host\" is_bind: true }\n#"; + let escaped = proto_str(malicious); + assert!(escaped.starts_with('"') && escaped.ends_with('"')); + // No raw quote or newline survives inside the rendered literal. + let inner = &escaped[1..escaped.len() - 1]; + assert!(!inner.contains('\n')); + assert!(!inner.contains("\"") || inner.contains("\\\"")); + assert!(escaped.contains("\\\"")); // the inner quote is backslash-escaped + assert!(escaped.contains("\\n")); // the newline is escaped + // Control and non-ASCII bytes render as valid 3-digit octal escapes (never + // a raw byte or an invalid `\u{..}` that nsjail's parser would reject). + assert_eq!(proto_str("a\u{1b}b"), "\"a\\033b\""); // ESC (0x1b) + assert_eq!(proto_str("é"), "\"\\303\\251\""); // UTF-8 bytes 0xc3 0xa9 + } + + #[test] + fn registry_qualified_classifies_refs() { + // Unqualified: bare repos (with/without tag) and docker.io org/repo. + for img in ["alpine", "alpine:latest", "myorg/img", "myorg/img:1.2"] { + assert!(!registry_qualified(img), "{img} should be unqualified"); + } + // Qualified: the first path component is a host (has `.`/`:`) or localhost. + for img in [ + "ghcr.io/org/img", + "registry.example.com/img:tag", + "localhost:5000/img", + "localhost/img", + "host:5000/a/b", + ] { + assert!(registry_qualified(img), "{img} should be qualified"); + } + } +} diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 08f9381205..7727982cf8 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -31,6 +31,7 @@ mod csharp_executor; mod dedicated_worker_ee; mod dedicated_worker_oss; mod deno_executor; +mod docker_v2; #[cfg(feature = "duckdb")] mod duckdb_executor; mod global_cache; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 7fb0dee9f5..b7ae7c2eed 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -694,6 +694,27 @@ lazy_static::lazy_static! { /// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`. pub static ref NSJAIL_TMP_BACKING: Arc>> = Arc::new(RwLock::new(None)); + /// Reject a `# sandbox ` whose on-disk size exceeds this many MB, before + /// extraction. `None`/non-positive = no limit. (`sandbox_image_max_size_mb`.) + pub static ref SANDBOX_IMAGE_MAX_SIZE_MB: Arc>> = Arc::new(RwLock::new(None)); + + /// Best-effort cap (MB) on podman's sandbox-image store; oldest images evicted + /// after a run when exceeded. `None`/non-positive = unbounded. (`sandbox_image_cache_max_mb`.) + pub static ref SANDBOX_IMAGE_CACHE_MAX_MB: Arc>> = Arc::new(RwLock::new(None)); + + /// podman pull policy for sandbox images (`missing`/`newer`/`always`/`never`). + /// `None`/unrecognized falls back to `newer`. (`sandbox_image_pull_policy`.) + pub static ref SANDBOX_IMAGE_PULL_POLICY: Arc>> = Arc::new(RwLock::new(None)); + + /// If set, unqualified sandbox image refs (e.g. `alpine`) are pulled from this + /// registry instead of docker.io. Fully-qualified refs are unaffected. + /// (`sandbox_image_default_registry`.) + pub static ref SANDBOX_IMAGE_DEFAULT_REGISTRY: Arc>> = Arc::new(RwLock::new(None)); + + /// Optional docker/podman `auth.json` blob for private registries, written to a + /// per-job authfile and passed to `podman --authfile`. (`sandbox_registry_auth`.) + pub static ref SANDBOX_REGISTRY_AUTH: Arc>> = Arc::new(RwLock::new(None)); + /// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR` /// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var /// or the `uv_python_install_mirror` instance setting. diff --git a/docker-compose.yml b/docker-compose.yml index 8b636c7702..75252cb802 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,25 +49,6 @@ services: logging: *default-logging - # Docker-in-Docker sidecar: provides an isolated Docker daemon so user scripts - # can run containers without accessing the host Docker socket. - dind: - image: docker:dind - privileged: true - restart: unless-stopped - environment: - DOCKER_TLS_CERTDIR: "" - volumes: - - dind-data:/var/lib/docker - expose: - - 2375 - healthcheck: - test: ["CMD", "docker", "info"] - interval: 10s - timeout: 5s - retries: 5 - logging: *default-logging - windmill_worker: image: ${WM_IMAGE} pull_policy: always @@ -89,22 +70,19 @@ services: # If running with non-root/non-windmill UID (e.g., user: "1001:1001"), # add: - HOME=/tmp - FAVOR_UNSHARE_PID=true - # Connect to the dind sidecar instead of the host Docker socket - - DOCKER_HOST=tcp://dind:2375 depends_on: db: condition: service_healthy - dind: - condition: service_healthy # to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill volumes: - worker_dependency_cache:/tmp/windmill/cache - worker_logs:/tmp/windmill/logs - ## WARNING: mounting the host Docker socket grants user scripts full access to - ## the host Docker daemon, enabling host filesystem access and privilege escalation. - ## Only use this if you fully trust all users who can run scripts. - ## To use it, remove the DOCKER_HOST env var and dind depends_on above, - ## and uncomment the line below: + ## Sandboxed containers (`# sandbox `) run daemonless via podman + nsjail + ## inside the worker itself — no Docker socket or dind sidecar required. + ## For the legacy full-compat docker (a bare `# docker`, trusted users only), + ## mount the host Docker socket by uncommenting the line below. WARNING: this + ## grants user scripts full access to the host Docker daemon (host filesystem + ## access and privilege escalation) — only use it if you fully trust all users. # - /var/run/docker.sock:/var/run/docker.sock logging: *default-logging @@ -237,4 +215,3 @@ volumes: windmill_index: null lsp_cache: null caddy_data: null - dind-data: null diff --git a/docs/docker-v2-runtime.md b/docs/docker-v2-runtime.md new file mode 100644 index 0000000000..c981e89b69 --- /dev/null +++ b/docs/docker-v2-runtime.md @@ -0,0 +1,106 @@ +# Sandboxed container runtime (daemonless docker) + +Windmill bash scripts can run a container image. There are **two** runtimes: + +| | legacy `# docker` | sandboxed `# sandbox ` | +|---|---|---| +| selected by | bare `# docker` | `# sandbox ` | +| runtime | dind / Docker daemon (bollard, `dind` feature) | daemonless: extract rootfs + nsjail-run | +| boundary | separate (daemon outside the jail) | the job's own nsjail sandbox | +| nsjail | not provided (trusted-tenant) | **required** — this *is* the sandbox | +| safety | trusted-tenant | sandboxed (untrusted-capable) | +| compat | full `docker run`/`-d`/API | run-a-command subset | + +The three bash annotations are distinct and don't overload each other: + +- `# docker` → legacy daemon docker (unchanged). +- `# sandbox` → run the bash script under nsjail. +- `# sandbox ` → run that image's command under nsjail (this runtime). + +## Using it + +Put the image ref on a `# sandbox` annotation line; the rest of the script runs +**inside** that image: + +```bash +# sandbox python:3.12-slim +name="$1" # windmill args bind positionally, like any bash script +python3 -c "import sys; print('hello', sys.argv[1])" "$name" +``` + +- The body runs via the image's `/bin/sh -c` (so the image needs a shell). +- An **empty** body runs the image's `ENTRYPOINT` + `CMD`. +- Windmill args (declared `x="$1"`, …) are appended to the command. +- The image's `Env`, `WorkingDir` are applied; the windmill reserved variables + (`WM_TOKEN`, `BASE_INTERNAL_URL`, …) are injected so `wmill`/API calls work. + +## How it works + +1. **Pull/extract** (podman, rootless): `podman create --pull= ` + + `podman export | tar -x` materializes the image's flattened root filesystem + into `{job_dir}/rootfs`, and `podman inspect` reads its OCI config. podman's + image store dedups pulls across jobs. +2. **Run** (the job's nsjail sandbox): nsjail binds each top-level entry of the + rootfs in place (binding the whole rootfs at `/` trips nsjail's read-only + remount of its base root in a rootless userns), mounts the standard + pseudo-filesystems (`/proc` from the jail's pid namespace, a tmpfs `/tmp`, + `/dev` nodes), maps uid/gid 0 inside → the worker user outside, and runs the + command. The container *is* the jail. + +``` +# sandbox ─▶ podman create+export ─▶ {job_dir}/rootfs ─▶ nsjail (chroot rootfs) + podman inspect (OCI config) ──────────────────▶ Env / Cmd / WorkingDir +``` + +Because the run is just the job's own nsjail with the image's filesystem as root, +the container inherits exactly the job's confinement: + +- **Filesystem**: only the rootfs + the job's mounts are visible — no host `/`, + no other job dirs, no dep cache. There is nothing to bind-mount escape to. +- **/proc**: the jail's own pid namespace — the worker and other jobs aren't + visible. +- **uid**: a single-uid jail — an escape lands as the unprivileged worker user. +- **network**: the job's network (same as any bash job). + +## Image storage, freshness & limits + +- **Where pulls live:** podman's rootless graph root (default + `$HOME/.local/share/containers/storage`) — persistent, dedups pulls across jobs. + The per-job extracted rootfs lives in `{job_dir}/rootfs` and is removed with the + job; the transient `rootfs.tar` is removed right after extraction. +- **Freshness (`SANDBOX_IMAGE_PULL_POLICY`, default `newer`):** `newer` re-pulls + only when the registry digest changed (one cheap manifest check per job, no data + transfer if unchanged) — so moving tags like `:latest` don't go stale. `missing` + is fastest but tags can go stale; `always` re-checks every job. Pinning a digest + (`img@sha256:…`) is immutable and never stale. +- **Per-image size cap (`SANDBOX_IMAGE_MAX_SIZE_MB`, default 0 = off):** images + whose on-disk size exceeds the cap are rejected before extraction. +- **Cache size cap (`SANDBOX_IMAGE_CACHE_MAX_MB`, default 0 = off):** best-effort + LRU eviction — after a run, the oldest images are removed until podman's image + store is back under the cap. In-use images are never removed. + +## Requirements + +- `podman` (rootless) and `tar` on the worker for image pull/extract. +- `nsjail` on the worker — **required**. If nsjail is absent, a `# sandbox ` + job errors clearly (use a bare `# docker` + a daemon instead). + +## Limitations (by design — daemonless, run-to-completion) + +- No `docker run -d` + later `exec`/`attach`/`logs -f`, no `docker build`, + `compose`, swarm, healthchecks. +- No arbitrary `-v` host bind mounts, `--privileged`, `--cap-add`, `--device`, + host namespace sharing. +- Images that drop to a non-root uid or chown to arbitrary uids inside need a + subuid **range** in the jail (single-uid only today — follow-up: `newuidmap` + range mapping). +- The script result is a completion message; capture output via stdout/logs. + +## Follow-ups + +- Content-addressed rootfs cache keyed by image digest (today each job re-exports; + podman's image store still dedups the network pull). +- Pre-pull size guard via `skopeo` manifest inspection (reject before download). +- Subuid-range nsjail variant for multi-uid images. +- Per-container isolated networking (slirp/pasta). +- Support under the non-nsjail `unshare` isolation mode. diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 8f4e75ff3d..325f2753e5 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -1007,21 +1007,6 @@ function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) { if (lang == 'docker') { - if (isCloudHosted()) { - sendUserToast( - 'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.', - true, - [ - { - label: 'Learn more', - callback: () => { - window.open('https://www.windmill.dev/docs/advanced/docker', '_blank') - } - } - ] - ) - return - } template = 'docker' } else if (lang == 'bunnative') { template = 'bunnative' diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index 353dbb3a2f..335a37b0c7 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -7,8 +7,6 @@ import FlowScriptPicker from '../pickers/FlowScriptPicker.svelte' import PickHubScript from '../pickers/PickHubScript.svelte' import WorkspaceScriptPicker from '../pickers/WorkspaceScriptPicker.svelte' - import { isCloudHosted } from '$lib/cloud' - import { sendUserToast } from '$lib/toast' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { Check, Code, Zap } from 'lucide-svelte' @@ -259,23 +257,6 @@ {label} lang={lang == 'docker' ? 'bash' : lang} on:click={() => { - if (lang == 'docker') { - if (isCloudHosted()) { - sendUserToast( - 'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.', - true, - [ - { - label: 'Learn more', - callback: () => { - window.open('https://www.windmill.dev/docs/advanced/docker', '_blank') - } - } - ] - ) - return - } - } dispatch('new', { language: lang == 'docker' ? 'bash' : lang, kind, diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index db199c745a..3f070dd74e 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -4,7 +4,6 @@ + + leafHaystack(x.leaf)} + opts={{}} +/> + +{#snippet defaultLeafIcon(leaf: DrillLeaf)} + {#if leafIcon} + {@render leafIcon(leaf)} + {:else if leaf.icon} + {@const Icon = leaf.icon} + + {/if} +{/snippet} + +{#snippet defaultBranchIcon(branch: DrillBranch)} + {#if branchIcon} + {@render branchIcon(branch)} + {:else if branch.icon} + {@const Icon = branch.icon} + + {/if} +{/snippet} + +{#snippet leafRow(leaf: DrillLeaf, secondary: string | undefined, baseClass: string)} + {@const key = leaf.key} + {@const isHl = key === highlightedKey} + {@const isCur = !!leaf.current} + +{/snippet} + + +
(mouseActive = true)} +> + {#if externalFilter === undefined} +
+ +
+ {/if} + + {#if scope.length > 0 && !isSearching} + + {/if} + +
+ {#if isSearching} + {@const total = (searchedItems ?? []).length} + {#if !searchedItems} +
+ Searching… +
+ {:else if total === 0} +
No matches
+ {:else} + {#each searchResultsByGroup as { group, items } (group?.key ?? '__none')} + {#if group} +
+ {group.label} +
+ {/if} +
    + {#each items as r (r.leaf.key)} +
  • {@render leafRow(r.leaf, r.leaf.secondary ?? r.leaf.label, 'py-1.5')}
  • + {/each} +
+ {/each} + {/if} + {:else if branchLoading && entryList.length === 0} +
+ Loading… +
+ {:else if entryList.length === 0} +
Empty
+ {:else} +
+ {#each entryList as entry (entry.key)} + {@const isHl = entry.key === highlightedKey} + {#if entry.type === 'leaf'} + {@render leafRow( + entry.node, + leafSecondary?.(entry.node, scope) ?? entry.node.secondary, + 'py-1.5' + )} + {:else} + + {/if} + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 8c36de8b11..24459967ab 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -735,7 +735,18 @@ flowStore.val = redo(history) } + let flowBuilderRoot: HTMLDivElement | undefined = $state() + function onKeyDown(event: KeyboardEvent) { + // Defer to anything that has explicitly grabbed focus — menus, modals, + // drawers etc. live outside the flow root. Flow nodes aren't focusable, + // so the unfocused default (activeElement === body) means "flow is the + // canvas" and we should react. + const active = document.activeElement + if (active && active !== document.body && !flowBuilderRoot?.contains(active)) { + return + } + let classes = event.target?.['className'] if ( (typeof classes === 'string' && classes.includes('inputarea')) || @@ -1175,7 +1186,7 @@ -
+
- (x.summary ? `${x.summary} (${x.path})` : x.path)} - opts={{}} -/> - -{#snippet leafRow(it: Item, secondary: string, baseClass: string)} - {@const key = leafKey(it)} - pick(it)} - onmouseenter={() => setHoverHighlight(key)} - /> +{#snippet leafIcon(leaf: DrillLeaf)} + {/snippet} - -
(mouseActive = true)} -> -
- -
- - {#if scope} - {@const s = scope} - +{#snippet branchIcon(branch: DrillBranch)} + {#if branch.key === 'kind:flow' || branch.key === 'kind:script' || branch.key === 'kind:app'} + {@const k = branch.key.slice(5) as Kind} + + {:else if branch.icon} + {@const Icon = branch.icon} + {/if} +{/snippet} -
- {#if isSearching} - {@const total = (searchedItems ?? []).length} - {@const anyKindLoading = kinds.some((k) => loadingKind[k])} - {#if !searchedItems || anyKindLoading} - -
- Searching… -
- {:else if total === 0} -
No matches
- {:else} - {#each kinds as k (k)} - {@const results = searchResultsByKind[k]} - {#if results.length > 0} -
- {KIND_LABEL[k]} -
-
    - {#each results as it (leafKey(it))} -
  • {@render leafRow(it, it.path, 'py-1.5')}
  • - {/each} -
- {/if} - {/each} - {/if} - {:else if scopeLoading && entries.length === 0} -
- Loading… -
- {:else if entries.length === 0} -
Empty
- {:else} -
- {#each entries as entry (entry.key)} - {@const isHl = entry.key === highlightedKey} - {#if entry.type === 'leaf'} - {@render leafRow( - entry.item, - scope?.dir ? entry.item.path.slice(scope.dir.length + 1) : entry.item.path, - 'py-1.5' - )} - {:else} - - {/if} - {/each} -
- {/if} -
-
- - + onPick(leaf.data)} + initialScope={computedInitialScope} + {initialHighlight} + {externalFilter} + {autoFocus} + {flush} + {leafIcon} + {branchIcon} + leafSecondary={(leaf, scope) => relativizeWorkspacePath(leaf.data.path, scope)} + onScopeChange={(scope) => { + if (scope.length > 0) loader.ensureForScopeSegment(scope[0]) + // Single-kind layout has no kind branch at root — `buildWorkspaceTree` + // collapses to the kind's children. The picker mounts with scope=[], + // so without this fallback nothing fires until the user searches. + else if (kinds.length === 1) loader.ensureLoaded(kinds[0]) + }} + onFilterChange={loader.onFilterChange} +/> diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 1252b999d7..e86ce913c7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -1,7 +1,7 @@ - -
{ - // avoids triggering onblur on the textinput and closing the tooltip - // but allow input elements to receive focus for the search input - if (!(e.target instanceof HTMLInputElement)) { - e.preventDefault() - } - }} - role="listbox" - tabindex={0} -> - {#if stringSearch.length > 0} - - {#each filteredAvailableContext as element, i (element.type + '-' + element.title)} - {@const Icon = ContextIconMap[element.type]} - - {/each} - {#if filteredAvailableContext.length === 0} -
No matching context
- {/if} - {:else if currentView === 'categories'} - - {#each availableCategories as category, i (category.id)} - {@const Icon = category.icon} - - {/each} - {#if availableCategories.length === 0} -
No available context
- {/if} - {:else if isSearchableView} - - - - - - {#if workspaceSearchLoading} -
- - Searching... -
- {:else if workspaceSearchResults.length === 0} -
- No results found -
- {:else} - {#each workspaceSearchResults as item, i (currentView + '-' + item.path)} - {@const isAlreadySelected = selectedContext.some( - (c) => - ((c.type === 'workspace_script' && currentView === 'scripts') || - (c.type === 'workspace_flow' && currentView === 'flows')) && - c.title === item.path - )} - - {/each} - {/if} - {:else} - - - - {#if currentCategoryItems.length === 0} -
No items in this category
- {:else} - {#each currentCategoryItems as element, i (element.type + '-' + element.title)} - {@const Icon = ContextIconMap[element.type]} - - {/each} - {/if} - {/if} -
diff --git a/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte new file mode 100644 index 0000000000..def6ea737b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte @@ -0,0 +1,273 @@ + + + +{#snippet leafIcon(leaf: DrillLeaf)} + {@const d = leaf.data} + {#if 'kind' in d} + + {:else if d.type === 'flow_module'} + + {:else} + {@const Icon = ContextIconMap[d.type]} + {#if Icon}{/if} + {/if} +{/snippet} + +{#snippet branchIcon(branch: DrillBranch)} + {#if branch.key === 'kind:flow' || branch.key === 'kind:script' || branch.key === 'kind:app'} + {@const k = branch.key.slice(5) as WorkspaceItemKind} + + {:else if branch.icon} + {@const Icon = branch.icon} + + {/if} +{/snippet} + + + 'kind' in leaf.data ? relativizeWorkspacePath(leaf.data.path, scope) : undefined} + onScopeChange={handleScopeChange} + onFilterChange={loader.onFilterChange} +/> diff --git a/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte b/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte index ae58552055..b42eae4835 100644 --- a/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte @@ -30,9 +30,13 @@ {#snippet trigger()} + {@const label = + contextElement.type === 'diff' + ? contextElement.title.replace(/_/g, ' ') + : contextElement.title}
(showDelete = true)} onmouseleave={() => (showDelete = false)} @@ -50,11 +54,7 @@ {/if} - - {contextElement.type === 'diff' - ? contextElement.title.replace(/_/g, ' ') - : contextElement.title} - + {label}
{/snippet} {#snippet content()} @@ -127,11 +127,7 @@
{contextElement.source} (L{contextElement.startLine}-L{contextElement.endLine})
- +
{:else if contextElement.type === 'app_datatable'}
diff --git a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts index be993f312e..a69d6670b4 100644 --- a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts @@ -149,9 +149,17 @@ export default class ContextManager { let newSelectedContext: ContextElement[] = [...currentlySelectedContext] - // Filter selected context to only include available items + // Filter selected context to only include available items. Workspace + // references (workspace_script / workspace_flow) are user-picked via + // the @-mention picker and intentionally aren't in availableContext — + // preserve them unconditionally so the badge survives editor refreshes. newSelectedContext = newSelectedContext - .filter((c) => newAvailableContext.some((ac) => ac.type === c.type && ac.title === c.title)) + .filter( + (c) => + c.type === 'workspace_script' || + c.type === 'workspace_flow' || + newAvailableContext.some((ac) => ac.type === c.type && ac.title === c.title) + ) .map((c) => c.type === 'db' && dbSchemas[c.title] ? { @@ -232,16 +240,22 @@ export default class ContextManager { ] } - let newSelectedContext: ContextElement[] = [...currentlySelectedContext] - - newSelectedContext = [ + // Seed with the (refreshed) code block + everything else previously + // selected. The filter further down validates each entry against + // newAvailableContext (and the per-type allowlist for code_piece / + // workspace_*); types that are auto-derived (diff/error/db) survive + // when they're still in availableContext, user-picked workspace refs + // survive unconditionally, and `code` is excluded from the carryover + // because we just rebuilt it. + let newSelectedContext: ContextElement[] = [ { type: 'code', title: this.getContextCodePath(scriptOptions) ?? '', content: scriptOptions.code, lang: scriptOptions.lang, deletable: false - } + }, + ...currentlySelectedContext.filter((c) => c.type !== 'code') ] const db = this.getSelectedDBSchema(scriptOptions, dbSchemas) @@ -265,22 +279,33 @@ export default class ContextManager { (c) => (c.type === 'code_piece' && scriptOptions.code.includes(c.content)) || c.type === 'code' || + // Workspace references are user-picked via @-mention and not in + // availableContext; preserve so badges survive editor refreshes. + c.type === 'workspace_script' || + c.type === 'workspace_flow' || newAvailableContext.some((ac) => ac.type === c.type && ac.title === c.title) ) - .map((c) => - c.type === 'code' - ? { - ...c, - content: scriptOptions.code, - title: this.getContextCodePath(scriptOptions) - } - : c.type === 'db' && dbSchemas[c.title] - ? { - ...c, - schema: dbSchemas[c.title] - } - : c - ) + .map((c) => { + if (c.type === 'code') { + return { + ...c, + content: scriptOptions.code, + title: this.getContextCodePath(scriptOptions) + } + } + if (c.type === 'db' && dbSchemas[c.title]) { + return { ...c, schema: dbSchemas[c.title] } + } + // For other auto-derived types (diff, error), rehydrate from the + // freshly-built newAvailableContext so the carryover doesn't keep + // stale `content` / `diff` payloads — preserve the user-set + // `deletable` flag on top of the fresh entry. + const fresh = newAvailableContext.find((ac) => ac.type === c.type && ac.title === c.title) + if (fresh && 'deletable' in c) { + return { ...fresh, deletable: c.deletable } as ContextElement + } + return fresh ?? c + }) this.availableContext = newAvailableContext this.selectedContext = newSelectedContext diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 6f924de457..b89e7954cb 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -1,22 +1,26 @@
@@ -343,10 +398,12 @@
- { @@ -356,14 +413,10 @@ onAddContext(element) updateInstructionsWithContext(element) showContextTooltip = false - // Refocus the textarea since focus may have been on the search input setTimeout(() => textarea?.focus(), 0) }} - showAllAvailable={true} - stringSearch={contextTooltipWord.slice(1)} - onViewChange={(newNumber) => { - tooltipCurrentViewNumber = newNumber - }} + externalFilter={contextTooltipWord.slice(1)} + autoFocus={false} setShowing={(showing) => { showContextTooltip = showing }} diff --git a/frontend/src/lib/components/drillPicker.test.ts b/frontend/src/lib/components/drillPicker.test.ts new file mode 100644 index 0000000000..41abab1281 --- /dev/null +++ b/frontend/src/lib/components/drillPicker.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest' +import { + collectLeavesGrouped, + leafHaystack, + resolveScope, + scopeChain, + type DrillBranch, + type DrillLeaf, + type DrillNode +} from './drillPicker' + +const leaf = (key: string, label = key, secondary?: string): DrillLeaf => ({ + type: 'leaf', + key, + label, + secondary, + data: key +}) + +const branch = ( + key: string, + children: DrillNode[], + opts: { label?: string; omitFromSearch?: boolean; searchGroup?: boolean } = {} +): DrillBranch => ({ + type: 'branch', + key, + label: opts.label ?? key, + children, + omitFromSearch: opts.omitFromSearch, + searchGroup: opts.searchGroup +}) + +describe('resolveScope', () => { + const tree: DrillNode[] = [ + branch('a', [branch('a.x', [leaf('a.x.1')]), leaf('a.2')]), + branch('b', [leaf('b.1')]), + leaf('top') + ] + + it('returns null at the root (empty scope)', () => { + expect(resolveScope(tree, [])).toBeNull() + }) + + it('returns the branch at a one-level scope', () => { + expect(resolveScope(tree, ['a'])?.key).toBe('a') + }) + + it('returns the branch at a nested scope', () => { + expect(resolveScope(tree, ['a', 'a.x'])?.key).toBe('a.x') + }) + + it('returns null when any segment is missing', () => { + expect(resolveScope(tree, ['a', 'missing'])).toBeNull() + expect(resolveScope(tree, ['nope'])).toBeNull() + }) + + it('returns null when a segment resolves to a leaf (not a branch)', () => { + expect(resolveScope(tree, ['top'])).toBeNull() + expect(resolveScope(tree, ['a', 'a.2'])).toBeNull() + }) +}) + +describe('scopeChain', () => { + const tree: DrillNode[] = [ + branch('a', [branch('a.x', [leaf('a.x.1')]), leaf('a.2')]), + branch('b', [leaf('b.1')]) + ] + + it('returns [] at the root', () => { + expect(scopeChain(tree, [])).toEqual([]) + }) + + it('returns one branch for a one-level scope', () => { + const chain = scopeChain(tree, ['a']) + expect(chain.map((b) => b.key)).toEqual(['a']) + }) + + it('returns each branch along the path for a nested scope', () => { + const chain = scopeChain(tree, ['a', 'a.x']) + expect(chain.map((b) => b.key)).toEqual(['a', 'a.x']) + }) + + it('stops at the first missing/non-branch segment', () => { + const chain = scopeChain(tree, ['a', 'a.2', 'never-reached']) + expect(chain.map((b) => b.key)).toEqual(['a']) + }) +}) + +describe('collectLeavesGrouped', () => { + it('flattens all leaves with null group when no branch has searchGroup', () => { + const tree: DrillNode[] = [branch('a', [leaf('a.1')]), leaf('top')] + const result = collectLeavesGrouped(tree) + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['a.1', undefined], + ['top', undefined] + ]) + }) + + it('groups leaves under their nearest searchGroup ancestor', () => { + const tree: DrillNode[] = [ + branch('flows', [branch('flows-folder', [leaf('flows-folder.1')]), leaf('flows.root')], { + searchGroup: true + }) + ] + const result = collectLeavesGrouped(tree) + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['flows-folder.1', 'flows'], + ['flows.root', 'flows'] + ]) + }) + + it('the DEEPEST searchGroup wins when nested', () => { + const tree: DrillNode[] = [ + branch('outer', [branch('inner', [leaf('deep')], { searchGroup: true })], { + searchGroup: true + }) + ] + const result = collectLeavesGrouped(tree) + expect(result[0].group?.key).toBe('inner') + }) + + it('skips branches marked omitFromSearch entirely', () => { + const tree: DrillNode[] = [ + branch('all', [leaf('shared')], { omitFromSearch: true }), + branch('flows', [leaf('shared'), leaf('uniq')], { searchGroup: true }) + ] + const result = collectLeavesGrouped(tree) + // `all` branch is skipped, so `shared` is only seen once and grouped under `flows`. + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['shared', 'flows'], + ['uniq', 'flows'] + ]) + }) + + it('deduplicates leaves by key (first occurrence wins)', () => { + // Simulate the workspace 'All' branch (omitFromSearch=true) plus per-kind + // branches having the same leaf — even without omitFromSearch the dedup + // would still guarantee no double-counting if the search tree changes. + const tree: DrillNode[] = [ + branch('flows', [leaf('a')], { searchGroup: true }), + branch('scripts', [leaf('a')], { searchGroup: true }) + ] + const result = collectLeavesGrouped(tree) + expect(result.length).toBe(1) + expect(result[0].group?.key).toBe('flows') + }) + + it('handles a mix of top-level leaves and branches', () => { + const tree: DrillNode[] = [ + leaf('root-leaf'), + branch('b', [leaf('b.1')], { searchGroup: true }) + ] + const result = collectLeavesGrouped(tree) + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['root-leaf', undefined], + ['b.1', 'b'] + ]) + }) +}) + +describe('leafHaystack', () => { + it('uses searchableText when present (overrides label/secondary)', () => { + expect(leafHaystack({ ...leaf('k', 'Label'), searchableText: 'custom' })).toBe('custom') + }) + + it('joins label and secondary with parens when both are present', () => { + expect(leafHaystack(leaf('k', 'My Flow', 'f/demo/my_flow'))).toBe('My Flow (f/demo/my_flow)') + }) + + it('uses just label when secondary is absent', () => { + expect(leafHaystack(leaf('k', 'just label'))).toBe('just label') + }) + + it('falls back to secondary when label is empty', () => { + expect(leafHaystack({ type: 'leaf', key: 'k', label: '', secondary: 'sec', data: 'd' })).toBe( + 'sec' + ) + }) + + it('returns the empty string when nothing is set', () => { + expect(leafHaystack({ type: 'leaf', key: 'k', label: '', data: 'd' })).toBe('') + }) +}) diff --git a/frontend/src/lib/components/drillPicker.ts b/frontend/src/lib/components/drillPicker.ts new file mode 100644 index 0000000000..5f3b5d6a25 --- /dev/null +++ b/frontend/src/lib/components/drillPicker.ts @@ -0,0 +1,116 @@ +import type { Component, ComponentType } from 'svelte' + +/** Icon constructor accepted by the picker — covers Svelte-5 `Component` and + * legacy `ComponentType` (lucide icons resolve to the former, but other + * callers in the repo still hand in the latter, see `TriggersBadge.svelte`). */ +export type DrillIcon = ComponentType | Component + +/** Leaf node — terminal entry the user picks. The picker emits the leaf + * back via `onPick` so callers can react with the original `data` payload. */ +export type DrillLeaf = { + type: 'leaf' + key: string + /** Primary line. */ + label: string + /** Optional secondary line (e.g. full path). */ + secondary?: string + /** Lucide-style component rendered with `size={12}`. The picker also + * accepts a `leafIcon` snippet override that gets the whole leaf. */ + icon?: DrillIcon + data: L + /** Optional override for the fuzzy-search haystack. Defaults to + * `label` (or `secondary` when label is empty). */ + searchableText?: string + /** Marks this leaf as the user's current location — gets `aria-current` + * and a styled, no-op click. */ + current?: boolean + /** When true, leaf is rendered but disabled (greyed + no-op click). */ + disabled?: boolean +} + +/** Branch node — interior entry the user drills into. */ +export type DrillBranch = { + type: 'branch' + key: string + label: string + icon?: DrillIcon + children: DrillNode[] + /** Show a spinner alongside the branch (async loading in progress). */ + loading?: boolean + /** Hide from search index traversal. Used by the workspace adapter to + * keep the cross-kind 'all' branch out of search (its leaves are + * duplicates of the per-kind branches' leaves). */ + omitFromSearch?: boolean + /** When true, leaves under this branch are grouped under its label in + * the search-results display. The DEEPEST such ancestor wins. Used to + * collapse folder hierarchies into kind/section headers — e.g. a leaf + * at `Workspace > Flows > f/demo > foo` groups under "Flows" (not + * "f/demo"). */ + searchGroup?: boolean +} + +export type DrillNode = DrillBranch | DrillLeaf + +/** Walk the tree to the branch at the given scope path. Returns null at + * root (empty scope) or when any segment doesn't resolve to a branch. */ +export function resolveScope(tree: DrillNode[], scope: string[]): DrillBranch | null { + if (scope.length === 0) return null + let level: DrillNode[] = tree + let current: DrillBranch | null = null + for (const key of scope) { + const node = level.find((n) => n.key === key) + if (!node || node.type !== 'branch') return null + current = node + level = node.children + } + return current +} + +/** Walk the tree to the branch at scope, returning ALL branches along the + * path (for breadcrumb rendering). The root is implicit and not returned. */ +export function scopeChain(tree: DrillNode[], scope: string[]): DrillBranch[] { + const chain: DrillBranch[] = [] + let level: DrillNode[] = tree + for (const key of scope) { + const node = level.find((n) => n.key === key) + if (!node || node.type !== 'branch') break + chain.push(node) + level = node.children + } + return chain +} + +/** Flatten the tree into a leaf list with each leaf's deepest + * `searchGroup`-anchor ancestor (or null if none). Skips branches marked + * `omitFromSearch`. Deduplicates leaves by `key` (first occurrence wins). */ +export function collectLeavesGrouped( + tree: DrillNode[] +): { leaf: DrillLeaf; group: DrillBranch | null }[] { + const out: { leaf: DrillLeaf; group: DrillBranch | null }[] = [] + const seen = new Set() + + function walk(nodes: DrillNode[], group: DrillBranch | null) { + for (const n of nodes) { + if (n.type === 'leaf') { + if (!seen.has(n.key)) { + seen.add(n.key) + out.push({ leaf: n, group }) + } + } else { + if (n.omitFromSearch) continue + // Deeper `searchGroup` anchors override shallower ones. + const nextGroup = n.searchGroup ? n : group + walk(n.children, nextGroup) + } + } + } + walk(tree, null) + return out +} + +/** Fuzzy-search haystack string for a leaf. */ +export function leafHaystack(leaf: DrillLeaf): string { + if (leaf.searchableText) return leaf.searchableText + if (leaf.label && leaf.secondary) return `${leaf.label} (${leaf.secondary})` + return leaf.label || leaf.secondary || '' +} diff --git a/frontend/src/lib/components/workspaceItemsLoader.svelte.ts b/frontend/src/lib/components/workspaceItemsLoader.svelte.ts new file mode 100644 index 0000000000..45d64bd01b --- /dev/null +++ b/frontend/src/lib/components/workspaceItemsLoader.svelte.ts @@ -0,0 +1,109 @@ +import { untrack } from 'svelte' +import { + getCachedItems, + loadKind, + type WorkspaceItem, + type WorkspaceItemKind +} from './workspacePicker' + +/** + * Shared loader for workspace items in drill pickers. Owns the + * `loaded` / `loadingKind` state, the stale-while-revalidate `ensureLoaded` + * coroutine, the `kind:` / `dir:` scope-segment decoder, and the + * "load every kind on first search" filter callback. + * + * Both `WorkspaceItemDrillPicker` and `ChatContextPicker` mount a + * `DrillPicker` over a workspace tree built from these maps. They each + * keep their own scope-walking policy (chat collapses an optional + * `'workspace'` wrapper segment; workspace handles single-kind mode at + * the top), but the kind decoding and lazy fetch live here. + * + * Both getters are read inside the returned closures so changing + * workspace or kinds after mount Just Works. + */ +export function useWorkspaceItemsLoader( + workspace: () => string | undefined, + kinds: () => readonly WorkspaceItemKind[] +) { + // Seed from the module-level cache so kinds already fetched in this + // session render on the first frame. Re-fetching `ensureLoaded` later + // quietly swaps in fresh data (stale-while-revalidate). + let loaded = $state>>( + (() => { + const ws = untrack(workspace) + if (!ws) return {} + const out: Partial> = {} + for (const k of untrack(kinds)) { + const cached = getCachedItems(ws, k) + if (cached) out[k] = cached + } + return out + })() + ) + let loadingKind = $state>>({}) + + async function ensureLoaded(kind: WorkspaceItemKind) { + const ws = workspace() + if (!ws) return + // `loaded[kind]` read inside `untrack` so callers wiring this into + // a reactive context (DrillPicker's onFilterChange effect) don't + // subscribe to a signal `ensureLoaded` itself writes — that would + // re-fire the effect on every assignment and busy-loop. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true + try { + const items = await loadKind(ws, kind) + loaded[kind] = items + } finally { + loadingKind[kind] = false + } + } + + function ensureAll() { + for (const k of kinds()) ensureLoaded(k) + } + + /** Decode one scope segment and trigger loads for the kind(s) it refers to. + * Accepts: + * - `kind:` (or `kind:all` — loads everything) + * - `dir::` (the single-kind layout where there's no `kind:` + * wrapper at the top of the path) + * Unknown segments and kinds outside the current `kinds()` set are + * ignored — the caller has already filtered scope chains it cares about. + */ + function ensureForScopeSegment(segment: string) { + const ks = kinds() + const triggerKind = (k: string) => { + if (k === 'all') return ensureAll() + if ((ks as readonly string[]).includes(k)) ensureLoaded(k as WorkspaceItemKind) + } + if (segment.startsWith('kind:')) { + triggerKind(segment.slice(5)) + return + } + if (segment.startsWith('dir:')) { + const rest = segment.slice(4) + const colon = rest.indexOf(':') + if (colon > 0) triggerKind(rest.slice(0, colon)) + } + } + + /** Global search → load every kind so results appear across the tree. + * Skip on the empty filter so a bare mount doesn't cold-load anything. */ + function onFilterChange(filter: string) { + if (filter.trim() === '') return + ensureAll() + } + + return { + get loaded() { + return loaded + }, + get loadingKind() { + return loadingKind + }, + ensureLoaded, + ensureAll, + ensureForScopeSegment, + onFilterChange + } +} diff --git a/frontend/src/lib/components/workspaceTree.test.ts b/frontend/src/lib/components/workspaceTree.test.ts new file mode 100644 index 0000000000..9698da9f71 --- /dev/null +++ b/frontend/src/lib/components/workspaceTree.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect } from 'vitest' +import { buildWorkspaceTree, legacyScopeToPath, relativizeWorkspacePath } from './workspaceTree' +import { + dirKey, + kindKey, + leafKeyFor, + type WorkspaceItem, + type WorkspaceItemKind +} from './workspacePicker' +import type { DrillBranch, DrillLeaf, DrillNode } from './drillPicker' + +const item = ( + kind: WorkspaceItemKind, + path: string, + summary?: string, + raw_app?: boolean +): WorkspaceItem => ({ kind, path, summary: summary ?? '', raw_app }) + +const isBranch = (n: DrillNode | undefined): n is DrillBranch => !!n && n.type === 'branch' +const isLeaf = (n: DrillNode | undefined): n is DrillLeaf => !!n && n.type === 'leaf' + +const childKeys = (b: DrillBranch) => b.children.map((c) => c.key) +const findBranch = (nodes: DrillNode[], key: string): DrillBranch => { + const n = nodes.find((x) => x.key === key) + if (!isBranch(n)) throw new Error(`expected branch ${key} in [${nodes.map((x) => x.key)}]`) + return n +} + +describe('buildWorkspaceTree', () => { + describe('shape', () => { + it('returns an empty tree when kinds is empty', () => { + expect(buildWorkspaceTree({ loaded: {}, kinds: [], loadingKind: {} })).toEqual([]) + }) + + it('multi-kind: prepends an All branch then per-kind branches', () => { + const tree = buildWorkspaceTree({ + loaded: { + flow: [item('flow', 'f/demo/a')], + script: [item('script', 'f/demo/b')] + }, + kinds: ['flow', 'script'], + loadingKind: {} + }) + expect(tree.map((n) => n.key)).toEqual([kindKey('all'), kindKey('flow'), kindKey('script')]) + }) + + it('All branch is omitFromSearch and labeled "All"', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')], script: [] }, + kinds: ['flow', 'script'], + loadingKind: {} + }) + const all = findBranch(tree, kindKey('all')) + expect(all.omitFromSearch).toBe(true) + expect(all.label).toBe('All') + }) + + it('per-kind branches are searchGroup anchors', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')], script: [] }, + kinds: ['flow', 'script'], + loadingKind: {} + }) + const flow = findBranch(tree, kindKey('flow')) + expect(flow.searchGroup).toBe(true) + }) + + it("single-kind: returns that kind branch's children directly (no kind-level)", () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a'), item('flow', 'u/alice/b')] }, + kinds: ['flow'], + loadingKind: {} + }) + // At the top we should see the scope dirs (f/demo, u/alice) directly, + // not a single 'kind:flow' branch wrapping them. + expect(tree.every((n) => isBranch(n) && n.key.startsWith('dir:flow:'))).toBe(true) + // f-scopes come before u-scopes + expect(tree.map((n) => n.key)).toEqual([dirKey('flow', 'f/demo'), dirKey('flow', 'u/alice')]) + }) + }) + + describe('loading state', () => { + it('per-kind branch is loading=true when loaded[k] is undefined and loadingKind[k] is true', () => { + const tree = buildWorkspaceTree({ + loaded: {}, + kinds: ['flow', 'script'], + loadingKind: { flow: true } + }) + const flow = findBranch(tree, kindKey('flow')) + expect(flow.loading).toBe(true) + }) + + it('per-kind branch is not loading once loaded[k] is set, even mid-refetch', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [] }, + kinds: ['flow', 'script'], + loadingKind: { flow: true } + }) + const flow = findBranch(tree, kindKey('flow')) + expect(flow.loading).toBeFalsy() + }) + + it('All branch is loading when any kind is loading', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [] }, + kinds: ['flow', 'script'], + loadingKind: { script: true } + }) + const all = findBranch(tree, kindKey('all')) + expect(all.loading).toBe(true) + }) + }) + + describe('dir forest', () => { + it('groups leaves under their scope, then nested folders', () => { + const tree = buildWorkspaceTree({ + loaded: { + flow: [ + item('flow', 'f/demo/a'), + item('flow', 'f/demo/sub/b'), + item('flow', 'f/demo/sub/c'), + item('flow', 'u/alice/d') + ] + }, + kinds: ['flow'], + loadingKind: {} + }) + // Top-level: f/demo (folder scope), u/alice (user scope) + expect(tree.map((n) => n.key)).toEqual([dirKey('flow', 'f/demo'), dirKey('flow', 'u/alice')]) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + // Children: nested folder `sub` first, then leaf `a` + expect(childKeys(demo)).toEqual([ + dirKey('flow', 'f/demo/sub'), + leafKeyFor('flow', 'f/demo/a') + ]) + const sub = findBranch(demo.children, dirKey('flow', 'f/demo/sub')) + expect(childKeys(sub)).toEqual([ + leafKeyFor('flow', 'f/demo/sub/b'), + leafKeyFor('flow', 'f/demo/sub/c') + ]) + }) + + it('skips items with paths shorter than 3 segments', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo'), item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {} + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(childKeys(demo)).toEqual([leafKeyFor('flow', 'f/demo/a')]) + }) + }) + + describe('leaf shape', () => { + it('uses summary as label and path as secondary when summary is present', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a', 'Hello')] }, + kinds: ['flow'], + loadingKind: {} + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const leaf = demo.children[0] + if (!isLeaf(leaf)) throw new Error('expected leaf') + expect(leaf.label).toBe('Hello') + expect(leaf.secondary).toBe('f/demo/a') + }) + + it('falls back to path as label when summary is empty', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {} + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const leaf = demo.children[0] + if (!isLeaf(leaf)) throw new Error('expected leaf') + expect(leaf.label).toBe('f/demo/a') + expect(leaf.secondary).toBeUndefined() + }) + + it('marks the currentItem leaf with current=true', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a'), item('flow', 'f/demo/b')] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: item('flow', 'f/demo/a') + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const [a, b] = demo.children + if (!isLeaf(a) || !isLeaf(b)) throw new Error('expected leaves') + expect(a.current).toBe(true) + expect(b.current).toBeFalsy() + }) + }) + + describe('withCurrent: rename suppression', () => { + it('injects currentItem at its live path when not already in the list', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: { ...item('flow', 'f/demo/new'), summary: 'My Flow' } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.map((c) => c.key)).toEqual([leafKeyFor('flow', 'f/demo/new')]) + }) + + it('drops the savedPath entry during a mid-rename so only the live one shows', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/old', 'My Flow')] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: { ...item('flow', 'f/demo/new', 'My Flow'), savedPath: 'f/demo/old' } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const paths = demo.children.map((c) => c.key) + expect(paths).toContain(leafKeyFor('flow', 'f/demo/new')) + expect(paths).not.toContain(leafKeyFor('flow', 'f/demo/old')) + }) + + it('does not re-inject when the live entry already exists in loaded', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a', 'Original')] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: item('flow', 'f/demo/a', 'Original') + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.length).toBe(1) + }) + + it('passes other-kind items through untouched', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')], script: [item('script', 'f/demo/b')] }, + kinds: ['flow', 'script'], + loadingKind: {}, + currentItem: { ...item('flow', 'f/demo/new'), savedPath: 'f/demo/old' } + }) + const script = findBranch(tree, kindKey('script')) + const demo = findBranch(script.children, dirKey('script', 'f/demo')) + expect(demo.children.map((c) => c.key)).toEqual([leafKeyFor('script', 'f/demo/b')]) + }) + }) + + describe('extraItemsByKind (drafts)', () => { + it('merges extras alongside loaded items', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {}, + extraItemsByKind: { flow: [item('flow', 'f/demo/draft')] } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.map((c) => c.key).sort()).toEqual( + [leafKeyFor('flow', 'f/demo/a'), leafKeyFor('flow', 'f/demo/draft')].sort() + ) + }) + + it('drops extras whose path collides with a loaded item (loaded wins)', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a', 'Backend summary')] }, + kinds: ['flow'], + loadingKind: {}, + extraItemsByKind: { flow: [item('flow', 'f/demo/a', 'Draft summary')] } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.length).toBe(1) + const leaf = demo.children[0] + if (!isLeaf(leaf)) throw new Error('expected leaf') + expect(leaf.label).toBe('Backend summary') + }) + + it('extras flow into the cross-kind All branch too', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [], script: [item('script', 'f/demo/b')] }, + kinds: ['flow', 'script'], + loadingKind: {}, + extraItemsByKind: { flow: [item('flow', 'f/demo/draft')] } + }) + const all = findBranch(tree, kindKey('all')) + const demo = findBranch(all.children, dirKey('all', 'f/demo')) + const keys = demo.children.map((c) => c.key) + expect(keys).toContain(leafKeyFor('flow', 'f/demo/draft')) + expect(keys).toContain(leafKeyFor('script', 'f/demo/b')) + }) + + it('is a no-op when extras are absent or empty', () => { + const noOpts = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {} + }) + const emptyExtras = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {}, + extraItemsByKind: { flow: [] } + }) + expect(JSON.stringify(noOpts)).toEqual(JSON.stringify(emptyExtras)) + }) + }) +}) + +describe('legacyScopeToPath', () => { + it('returns [] for undefined scope', () => { + expect(legacyScopeToPath(undefined, ['flow', 'script'])).toEqual([]) + }) + + it('multi-kind: returns [kindKey] for a kind-only scope', () => { + expect(legacyScopeToPath({ kind: 'flow' }, ['flow', 'script'])).toEqual([kindKey('flow')]) + }) + + it('multi-kind: returns [kindKey, dirKey] for a kind+dir scope', () => { + expect(legacyScopeToPath({ kind: 'flow', dir: 'f/demo' }, ['flow', 'script'])).toEqual([ + kindKey('flow'), + dirKey('flow', 'f/demo') + ]) + }) + + it('multi-kind: handles `all` as a kind', () => { + expect(legacyScopeToPath({ kind: 'all', dir: 'f/demo' }, ['flow', 'script'])).toEqual([ + kindKey('all'), + dirKey('all', 'f/demo') + ]) + }) + + it('single-kind: returns [] for a kind-only scope (no kind level in tree)', () => { + expect(legacyScopeToPath({ kind: 'flow' }, ['flow'])).toEqual([]) + }) + + it('single-kind: returns [dirKey] for a kind+dir scope', () => { + expect(legacyScopeToPath({ kind: 'flow', dir: 'f/demo' }, ['flow'])).toEqual([ + dirKey('flow', 'f/demo') + ]) + }) +}) + +describe('relativizeWorkspacePath', () => { + it('returns the absolute path when scope has no dir segment', () => { + expect(relativizeWorkspacePath('f/demo/a', [])).toBe('f/demo/a') + expect(relativizeWorkspacePath('f/demo/a', [kindKey('flow')])).toBe('f/demo/a') + }) + + it('shortens to the path relative to the deepest dir scope', () => { + const scope = [kindKey('flow'), dirKey('flow', 'f/demo')] + expect(relativizeWorkspacePath('f/demo/a', scope)).toBe('a') + }) + + it('uses the DEEPEST dir scope when there are nested ones', () => { + const scope = [kindKey('flow'), dirKey('flow', 'f/demo'), dirKey('flow', 'f/demo/sub')] + expect(relativizeWorkspacePath('f/demo/sub/b', scope)).toBe('b') + }) + + it('falls back to absolute path when the leaf is not under the dir scope', () => { + const scope = [kindKey('flow'), dirKey('flow', 'f/demo')] + expect(relativizeWorkspacePath('f/other/a', scope)).toBe('f/other/a') + }) +}) diff --git a/frontend/src/lib/components/workspaceTree.ts b/frontend/src/lib/components/workspaceTree.ts new file mode 100644 index 0000000000..e7da20b62d --- /dev/null +++ b/frontend/src/lib/components/workspaceTree.ts @@ -0,0 +1,244 @@ +import { Folder, Layers, User } from 'lucide-svelte' +import { + dirKey, + KIND_LABEL, + kindKey, + leafKeyFor, + type WorkspaceItem, + type WorkspaceItemKind +} from './workspacePicker' +import type { DrillBranch, DrillLeaf, DrillNode } from './drillPicker' + +/** Intermediate path-hierarchy node — same shape as the previous + * `buildTreeFromItems` output, kept internal because the DrillPicker + * consumes `DrillNode`s instead. */ +type DirNode = { + fullPath: string + name: string + /** True for the top-level `f/` or `u/` directories. */ + isScope: boolean + children: DirNode[] + leaves: WorkspaceItem[] +} + +/** Build the path-hierarchy from a flat list of workspace items. */ +function buildDirForest(items: WorkspaceItem[]): DirNode[] { + const scopeRoots = new Map() + for (const it of items) { + const parts = it.path.split('/') + if (parts.length < 3) continue + const scopeFp = parts.slice(0, 2).join('/') + let node = scopeRoots.get(scopeFp) + if (!node) { + node = { fullPath: scopeFp, name: scopeFp, isScope: true, children: [], leaves: [] } + scopeRoots.set(scopeFp, node) + } + const slug = parts.slice(2) + let cur = node + for (let i = 0; i < slug.length - 1; i++) { + const seg = slug[i] + const fullPath = cur.fullPath + '/' + seg + let next = cur.children.find((c) => c.name === seg) + if (!next) { + next = { fullPath, name: seg, isScope: false, children: [], leaves: [] } + cur.children.push(next) + } + cur = next + } + cur.leaves.push(it) + } + const scopes = Array.from(scopeRoots.values()).sort((a, b) => { + // `f/` (folder) scopes before `u/` (user) scopes; alphabetical within. + const af = a.fullPath.startsWith('f/') ? 0 : 1 + const bf = b.fullPath.startsWith('f/') ? 0 : 1 + if (af !== bf) return af - bf + return a.fullPath.localeCompare(b.fullPath) + }) + const sortNode = (n: DirNode) => { + n.children.sort((a, b) => a.name.localeCompare(b.name)) + n.leaves.sort((a, b) => a.path.localeCompare(b.path)) + n.children.forEach(sortNode) + } + scopes.forEach(sortNode) + return scopes +} + +/** Inject the currently-edited item at its live path, dropping the saved + * entry when a draft rename is mid-flight. Only applies to items of the + * same kind. */ +function withCurrent( + items: WorkspaceItem[], + k: WorkspaceItemKind, + currentItem: (WorkspaceItem & { savedPath?: string }) | undefined +): WorkspaceItem[] { + if (!currentItem || currentItem.kind !== k) return items + const drafted = + currentItem.savedPath && currentItem.savedPath !== currentItem.path + ? items.filter((it) => it.path !== currentItem.savedPath) + : items + if (drafted.some((it) => it.path === currentItem.path)) return drafted + return [ + ...drafted, + { + path: currentItem.path, + summary: currentItem.summary, + kind: k, + raw_app: currentItem.raw_app + } + ] +} + +function itemToLeaf( + it: WorkspaceItem, + currentItem: (WorkspaceItem & { savedPath?: string }) | undefined +): DrillLeaf { + const isCurrent = !!currentItem && currentItem.kind === it.kind && currentItem.path === it.path + return { + type: 'leaf', + key: leafKeyFor(it.kind, it.path), + label: it.summary || it.path, + secondary: it.summary ? it.path : undefined, + data: it, + current: isCurrent + } +} + +function dirToBranch( + d: DirNode, + scopeKind: WorkspaceItemKind | 'all', + currentItem: (WorkspaceItem & { savedPath?: string }) | undefined +): DrillBranch { + // Top-level user scope (`u/`) gets a person icon. Everything + // else (top-level `f/` or any deeper folder) is a folder. + const isUserScope = d.isScope && d.fullPath.startsWith('u/') + return { + type: 'branch', + key: dirKey(scopeKind, d.fullPath), + label: d.name, + icon: isUserScope ? User : Folder, + children: [ + ...d.children.map((c) => dirToBranch(c, scopeKind, currentItem)), + ...d.leaves.map((l) => itemToLeaf(l, currentItem)) + ] + } +} + +/** Merge AI-created in-memory drafts (or any caller-provided extras) into a + * kind's loaded list. The chat tools / session previews scaffold items via + * `UserDraft` before the user deploys; those should be navigable from the + * picker. Existing items (same path) win so backend metadata (summary etc.) + * isn't clobbered. */ +function withExtras( + items: WorkspaceItem[], + k: WorkspaceItemKind, + extraItemsByKind: Partial> | undefined +): WorkspaceItem[] { + const extras = extraItemsByKind?.[k] + if (!extras || extras.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(extras.filter((d) => !known.has(d.path))) +} + +/** Build the workspace drill tree. + * + * - One branch per kind in `kinds` (`Flows` / `Scripts` / `Apps`), + * each containing the kind's path hierarchy. + * - When `kinds.length > 1`, prepend an `All` branch that merges items + * across kinds. The `All` branch is flagged `omitFromSearch` so its + * leaves don't appear twice in global-search results. + * - When `kinds.length === 1`, return the single kind branch's children + * directly so the user lands on folders without a redundant level. + */ +export function buildWorkspaceTree(opts: { + loaded: Partial> + kinds: WorkspaceItemKind[] + currentItem?: WorkspaceItem & { savedPath?: string } + /** Per-kind spinner flag. Defaults to `{}` — callers that don't track + * loading state (e.g. chat picker, which preloads eagerly) can omit it. */ + loadingKind?: Partial> + /** Per-kind extras to merge into the loaded list before tree-building + * (e.g. AI-created localStorage drafts surfaced by the workspace adapter). + * Extras whose path matches an already-loaded item are dropped. */ + extraItemsByKind?: Partial> +}): DrillNode[] { + const { loaded, kinds, currentItem, extraItemsByKind } = opts + const loadingKind = opts.loadingKind ?? {} + + function kindBranch(k: WorkspaceItemKind): DrillBranch { + const raw = withExtras(loaded[k] ?? [], k, extraItemsByKind) + const items = withCurrent(raw, k, currentItem) + const dirs = items.length > 0 ? buildDirForest(items) : [] + return { + type: 'branch', + key: kindKey(k), + label: KIND_LABEL[k], + children: dirs.map((d) => dirToBranch(d, k, currentItem)), + loading: !loaded[k] && !!loadingKind[k], + // Search results from this kind group under its label (collapses + // the folder hierarchy in the search view). + searchGroup: true + } + } + + if (kinds.length === 0) return [] + + if (kinds.length === 1) { + return kindBranch(kinds[0]).children + } + + // Cross-kind 'all' branch — flagged so search doesn't double-count leaves. + const allItems = kinds.flatMap((k) => + withCurrent(withExtras(loaded[k] ?? [], k, extraItemsByKind), k, currentItem) + ) + const allDirs = allItems.length > 0 ? buildDirForest(allItems) : [] + const allBranch: DrillBranch = { + type: 'branch', + key: kindKey('all'), + label: 'All', + icon: Layers, + children: allDirs.map((d) => dirToBranch(d, 'all', currentItem)), + omitFromSearch: true, + loading: kinds.some((k) => !loaded[k] && !!loadingKind[k]) + } + + return [allBranch, ...kinds.map((k) => kindBranch(k))] +} + +/** Map the legacy `{ kind, dir? }` initial-scope shape used by callers + * (BreadcrumbSegment / EditorHeader) onto the new generic `string[]` path. */ +export function legacyScopeToPath( + scope: { kind: WorkspaceItemKind | 'all'; dir?: string } | undefined, + kinds: WorkspaceItemKind[] +): string[] { + if (!scope) return [] + // Single-kind mode: there's no kind branch at root; scope's `kind` is + // implicit. Only the dir (if any) makes it to the path. + if (kinds.length === 1) { + return scope.dir ? [dirKey(scope.kind, scope.dir)] : [] + } + const path: string[] = [kindKey(scope.kind)] + if (scope.dir) path.push(dirKey(scope.kind, scope.dir)) + return path +} + +/** Return `absolutePath` shortened to its segment relative to the deepest + * `dir::` segment in `scope`. Used to render leaf rows like + * `parquet_etl` instead of `f/examples/parquet_etl` once the user has + * drilled into `f/examples`. Falls back to the absolute path when no dir + * scope matches (e.g. at the kind level, or when the leaf isn't actually + * under the scoped dir). */ +export function relativizeWorkspacePath(absolutePath: string, scope: string[]): string { + for (let i = scope.length - 1; i >= 0; i--) { + const k = scope[i] + if (!k.startsWith('dir:')) continue + const rest = k.slice(4) // ':' + const colon = rest.indexOf(':') + if (colon < 0) continue + const dirPath = rest.slice(colon + 1) + if (absolutePath.startsWith(dirPath + '/')) { + return absolutePath.slice(dirPath.length + 1) + } + return absolutePath + } + return absolutePath +} From 82cb7bf375ed5e8eb07774848f73d4fb7cf2a27c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 8 Jun 2026 16:43:07 +0200 Subject: [PATCH 21/32] whitelabel default timeout + test-job callbacks (#9469) Add a configurable `defaultTimeout` to the script/flow editor whitelabel customUi (replaces the hardcoded 300s default) and an `onTestJob` callback on ScriptBuilder/FlowBuilder that fires with the preview job id when a test run starts. Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/FlowBuilder.svelte | 8 ++++++-- frontend/src/lib/components/FlowPreviewContent.svelte | 4 ++-- frontend/src/lib/components/ScriptBuilder.svelte | 4 +++- frontend/src/lib/components/ScriptEditor.svelte | 11 ++++++++--- frontend/src/lib/components/custom_ui.ts | 6 ++++++ frontend/src/lib/components/flow_builder.ts | 3 +++ .../components/flows/content/FlowModuleTimeout.svelte | 5 ++++- .../components/flows/header/FlowPreviewButtons.svelte | 2 +- frontend/src/lib/components/script_builder.ts | 3 +++ 9 files changed, 36 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 24459967ab..24c0071a5e 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -133,7 +133,8 @@ onSaveDraftError, onSaveDraftOnlyAtNewPath, onHistoryRestore, - onNavigate + onNavigate, + onTestJob }: FlowBuilderProps = $props() let initialPathStore = writable(initialPath) @@ -1267,11 +1268,14 @@ bind:localModuleStates bind:this={flowPreviewButtons} {loading} - onRunPreview={() => { + onRunPreview={(jobId) => { stepsInputArgs.resetManuallyEditedArgs() modulesTestStates.hideJobsInGraph() localModuleStates = {} showJobStatus = true + if (jobId) { + onTestJob?.({ jobId }) + } }} /> {/snippet} diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 49f195cd0d..eb9bbea4e7 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -59,7 +59,7 @@ scrollTop?: number localModuleStates?: Record localDurationStatuses?: Record - onRunPreview?: () => void + onRunPreview?: (jobId?: string) => void render?: boolean onJobDone?: () => void upToId?: string | undefined @@ -200,7 +200,7 @@ savedArgs = $state.snapshot(previewArgs.val) inputSelected = undefined } - onRunPreview?.() + onRunPreview?.(newJobId) } catch (e) { sendUserToast('Could not run preview', true, undefined, e.toString()) isRunning = false diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 325f2753e5..f5f59cec34 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -134,6 +134,7 @@ onSaveDraftError, onSaveDraft, onNavigate, + onTestJob, disableAi, initialTestPanelCollapsed = false, initialPathChosen = false @@ -1565,7 +1566,7 @@ if (script.timeout && script.timeout != undefined) { script.timeout = undefined } else { - script.timeout = 300 + script.timeout = customUi?.defaultTimeout ?? 300 } }} options={{ @@ -2084,6 +2085,7 @@ {disableAi} bind:selectedTab={selectedInputTab} {customUi} + {onTestJob} collabMode edit={initialPath != ''} on:format={() => { diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index a281b266da..c54924caf8 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -160,6 +160,9 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + // Fired whenever a test run is started from this editor, with the + // preview job id. Used by whitelabel embedders to track test jobs. + onTestJob?: (e: { jobId: string }) => void // When true the right-hand test/run pane mounts collapsed. The user // can still expand it via `toggleTestPanel`. Defaults to false so the // regular /scripts/edit route keeps its current open-by-default UX; @@ -199,6 +202,7 @@ modules = $bindable(undefined), editorBarRight, enablePreprocessorSnippet = false, + onTestJob, initialTestPanelCollapsed = false }: Props = $props() @@ -729,6 +733,9 @@ undefined, activeModuleTab !== null ? undefined : modules ) + if (job) { + onTestJob?.({ jobId: job }) + } logPanel?.setFocusToLogs() return job } @@ -1357,9 +1364,7 @@ // width (Svelte wires a ResizeObserver for bind:clientWidth). let splitContainerWidth = $state(0) const TEST_PANE_MIN_PX = 400 - const testPaneMinPercent = $derived( - paneMinPercent(splitContainerWidth, TEST_PANE_MIN_PX) - ) + const testPaneMinPercent = $derived(paneMinPercent(splitContainerWidth, TEST_PANE_MIN_PX)) // Raw user-controlled test size (what the splitter wrote, or what the // toggle set). The size we actually pass to is clamped to the diff --git a/frontend/src/lib/components/custom_ui.ts b/frontend/src/lib/components/custom_ui.ts index 5f017ee950..64c6221aea 100644 --- a/frontend/src/lib/components/custom_ui.ts +++ b/frontend/src/lib/components/custom_ui.ts @@ -44,6 +44,9 @@ export type FlowBuilderWhitelabelCustomUi = { aiSandbox?: boolean suggestIntegration?: boolean suggestScript?: boolean + // Default timeout (in seconds) prefilled when enabling a custom step timeout. + // Defaults to 300 (5 minutes) when unset. + defaultTimeout?: number } export type DisplayResultUi = { @@ -130,4 +133,7 @@ export type ScriptBuilderWhitelabelCustomUi = { editorBar?: EditorBarUi previewPanel?: PreviewPanelUi tagSelectPlaceholder?: string + // Default timeout (in seconds) prefilled when enabling a custom script timeout. + // Defaults to 300 (5 minutes) when unset. + defaultTimeout?: number } diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index e91a06e430..5d2493b930 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -51,4 +51,7 @@ export type FlowBuilderProps = { onDetails?: ({ path }: { path: string }) => void onHistoryRestore?: () => void onNavigate?: (item: WorkspaceItem) => void + // Fired whenever a test run is started from the flow editor, with the + // preview job id. Used by whitelabel embedders to track test jobs. + onTestJob?: (e: { jobId: string }) => void } diff --git a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte index 9987d748cd..39aaaa66f2 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte @@ -13,6 +13,7 @@ import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte' import type { FlowEditorContext } from '../types' import { getStepPropPicker } from '../previousResults' + import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' interface Props { flowModule: FlowModule @@ -24,6 +25,8 @@ const { flowStore, flowStateStore, previewArgs } = getContext('FlowEditorContext') + const customUi = getContext('customUi') + let schema = $state(emptySchema()) schema.properties['timeout'] = { type: 'number' @@ -69,7 +72,7 @@ } else { flowModule.timeout = { type: 'static', - value: 300 + value: customUi?.defaultTimeout ?? 300 } } }} diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index 206c9f199b..2f1c016385 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -14,7 +14,7 @@ interface Props { loading?: boolean - onRunPreview?: () => void + onRunPreview?: (jobId?: string) => void onJobDone?: () => void localModuleStates?: Record suspendStatus: StateStore> diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index e561600181..cac47d4eef 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -48,6 +48,9 @@ export interface ScriptBuilderProps { onSeeDetails?: (e: { path: string }) => void onSaveDraftError?: (e: { path: string; error: any }) => void onNavigate?: (item: WorkspaceItem) => void + // Fired whenever a test run is started from the script editor, with the + // preview job id. Used by whitelabel embedders to track test jobs. + onTestJob?: (e: { jobId: string }) => void // Forwarded to the underlying ScriptEditor. When true, the right-hand // test/run pane opens collapsed. Used by the session preview. initialTestPanelCollapsed?: boolean From 3bc5800197db383ae6f708415701a4bdbe2e3345 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:46:47 +0200 Subject: [PATCH 22/32] feat: allow private MCP server URLs (#9470) * feat: allow private MCP server URLs * docs: remove private MCP server URL doc * fix: apply MCP URL opt-in to OAuth handlers * fix: update EE ref for MCP OAuth redirects * fix: preserve MCP OAuth client timeout * chore: update ee-repo-ref to 481ea7f28dc5af6b72390c82f494f34cb9809546 This commit updates the EE repository reference after PR #608 was merged in windmill-ee-private. Previous ee-repo-ref: 6c7da03fb994be23ed6aca59bece94d257a641b5 New ee-repo-ref: 481ea7f28dc5af6b72390c82f494f34cb9809546 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- backend/THREAT_MODEL.md | 2 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/ssrf.rs | 133 ++++++++++++++++++ backend/windmill-mcp/src/client/mod.rs | 38 ++++- .../windmill-mcp/src/client_registration.rs | 38 ++++- backend/windmill-mcp/src/lib.rs | 59 ++++++++ 6 files changed, 263 insertions(+), 9 deletions(-) diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md index 5a891bda77..8cc71ec71c 100644 --- a/backend/THREAT_MODEL.md +++ b/backend/THREAT_MODEL.md @@ -97,7 +97,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence | |---|---|---|---|---|---|---|---|---|---| | T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b | -| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | +| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | | T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 | | T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 | | T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e | diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 9dad41fdc8..84d5414fa2 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -2c7964460327fab5e3a27c0f74b8d6f26ab7f79a +481ea7f28dc5af6b72390c82f494f34cb9809546 diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 507a773431..2f100d5ae1 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -2,6 +2,8 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use crate::error::Error; +pub const ALLOW_PRIVATE_MCP_SERVER_URLS_ENV: &str = "ALLOW_PRIVATE_MCP_SERVER_URLS"; + /// Why a URL failed SSRF validation. /// /// The distinction matters for callers that gate private endpoints behind a @@ -116,6 +118,49 @@ pub async fn validate_url_for_ssrf(url: &str) -> Result<(), SsrfValidationError> Ok(()) } +pub fn allow_private_mcp_server_urls() -> bool { + std::env::var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV) + .ok() + .is_some_and(|v| v == "true" || v == "1") +} + +pub async fn validate_mcp_server_url(url: &str) -> Result<(), SsrfValidationError> { + let parsed = + url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; + + match parsed.scheme() { + "http" | "https" => {} + scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())), + } + + parsed.host_str().ok_or(SsrfValidationError::MissingHost)?; + + if allow_private_mcp_server_urls() { + return Ok(()); + } + + validate_url_for_ssrf(url).await +} + +pub async fn validate_mcp_server_url_for_bad_request(url: &str, label: &str) -> Result<(), Error> { + validate_mcp_server_url(url).await.map_err(|e| { + Error::BadRequest(format!( + "{label} is not allowed: {}", + mcp_ssrf_error_message(&e) + )) + }) +} + +pub fn mcp_ssrf_error_message(e: &SsrfValidationError) -> String { + match e { + SsrfValidationError::Private { .. } => format!( + "{e}. If you need to use private/internal MCP server URLs, \ + set the {ALLOW_PRIVATE_MCP_SERVER_URLS_ENV}=true environment variable" + ), + _ => e.to_string(), + } +} + fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ipv4) => is_private_ipv4(ipv4), @@ -152,6 +197,32 @@ fn is_private_ipv6(ip: &Ipv6Addr) -> bool { mod tests { use super::*; + static TEST_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + struct PrivateMcpServerUrlsEnvGuard { + previous: Option, + } + + impl PrivateMcpServerUrlsEnvGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV).ok(); + match value { + Some(value) => std::env::set_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV), + } + Self { previous } + } + } + + impl Drop for PrivateMcpServerUrlsEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV), + } + } + } + #[test] fn test_private_ipv4() { assert!(is_private_ipv4(&"127.0.0.1".parse().unwrap())); @@ -227,4 +298,66 @@ mod tests { Err(SsrfValidationError::Private { resolved: false }) )); } + + #[tokio::test] + async fn validate_mcp_server_url_blocks_private_by_default() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateMcpServerUrlsEnvGuard::set(None); + + assert!(matches!( + validate_mcp_server_url("http://127.0.0.1/foo").await, + Err(SsrfValidationError::Private { resolved: false }) + )); + } + + #[tokio::test] + async fn validate_mcp_server_url_allows_private_when_env_is_enabled() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateMcpServerUrlsEnvGuard::set(Some("true")); + + assert!(validate_mcp_server_url("http://127.0.0.1/foo") + .await + .is_ok()); + } + + #[tokio::test] + async fn validate_mcp_server_url_allows_private_when_env_is_one() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateMcpServerUrlsEnvGuard::set(Some("1")); + + assert!(validate_mcp_server_url("http://10.0.0.1/foo").await.is_ok()); + } + + #[tokio::test] + async fn validate_mcp_server_url_keeps_syntax_guards_when_private_urls_are_allowed() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateMcpServerUrlsEnvGuard::set(Some("true")); + + assert!(matches!( + validate_mcp_server_url("localhost:11434/v1").await, + Err(SsrfValidationError::DisallowedScheme(_)) + )); + assert!(matches!( + validate_mcp_server_url("file:///tmp/socket").await, + Err(SsrfValidationError::DisallowedScheme(_)) + )); + } + + #[tokio::test] + async fn private_mcp_error_message_includes_env_hint_only_for_private_urls() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateMcpServerUrlsEnvGuard::set(None); + + let private_error = validate_mcp_server_url("http://127.0.0.1/foo") + .await + .unwrap_err(); + assert!( + mcp_ssrf_error_message(&private_error).contains("ALLOW_PRIVATE_MCP_SERVER_URLS=true") + ); + + let invalid_error = validate_mcp_server_url("localhost:11434/v1") + .await + .unwrap_err(); + assert!(!mcp_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV)); + } } diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index 1a555c141f..34dc141209 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -43,9 +43,14 @@ impl McpClient { // The resource URL is author-controlled and we send a (potentially // secret) bearer token to it, so it must be validated against SSRF // before we connect (e.g. cloud metadata endpoints, internal services). - windmill_common::ssrf::validate_url_for_ssrf(&resource.url) + windmill_common::ssrf::validate_mcp_server_url(&resource.url) .await - .map_err(|e| anyhow::anyhow!("MCP server URL is not allowed: {}", e))?; + .map_err(|e| { + anyhow::anyhow!( + "MCP server URL is not allowed: {}", + windmill_common::ssrf::mcp_ssrf_error_message(&e) + ) + })?; // Build custom reqwest client with headers if provided let mut headers = HeaderMap::new(); @@ -230,6 +235,33 @@ impl McpClient { mod tests { use super::*; + struct PrivateMcpServerUrlsEnvGuard { + previous: Option, + } + + impl PrivateMcpServerUrlsEnvGuard { + fn unset() -> Self { + let previous = + std::env::var(windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV).ok(); + std::env::remove_var(windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV); + Self { previous } + } + } + + impl Drop for PrivateMcpServerUrlsEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var( + windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV, + value, + ), + None => { + std::env::remove_var(windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV) + } + } + } + } + /// Regression test: `from_resource` must refuse to connect to a URL that /// targets a private/internal address (here the AWS /// instance-metadata endpoint), so a resource author cannot use the MCP @@ -237,6 +269,8 @@ mod tests { /// before any connection attempt, so this fails fast without network access. #[tokio::test] async fn from_resource_rejects_ssrf_url() { + let _guard = PrivateMcpServerUrlsEnvGuard::unset(); + let resource = McpResource { name: "evil".to_string(), url: "http://169.254.169.254".to_string(), diff --git a/backend/windmill-mcp/src/client_registration.rs b/backend/windmill-mcp/src/client_registration.rs index eca4ed650f..b0b07cada1 100644 --- a/backend/windmill-mcp/src/client_registration.rs +++ b/backend/windmill-mcp/src/client_registration.rs @@ -17,7 +17,7 @@ use windmill_common::db::DB; use windmill_common::error; use windmill_common::variables::{build_crypt, decrypt, encrypt}; -use crate::oauth::AuthorizationManager; +use crate::oauth::{no_redirect_http_client, AuthorizationManager}; /// MCP client credentials returned by [`get_or_refresh_mcp_client`]. pub struct McpClientCredentials { @@ -77,7 +77,14 @@ async fn register_client( redirect_uri: &str, client_name: &str, ) -> Result { - let client = reqwest::Client::new(); + windmill_common::ssrf::validate_mcp_server_url_for_bad_request( + registration_endpoint, + "MCP server registration endpoint URL", + ) + .await?; + + let client = no_redirect_http_client() + .map_err(|e| error::Error::BadRequest(format!("Failed to build DCR client: {e}")))?; let request = DcrRequest { client_name: client_name.to_string(), redirect_uris: vec![redirect_uri.to_string()], @@ -121,6 +128,12 @@ pub async fn get_or_refresh_mcp_client( let base_url = (**windmill_common::BASE_URL.load()).clone(); let redirect_uri = format!("{}/api/mcp/oauth/callback", base_url); + windmill_common::ssrf::validate_mcp_server_url_for_bad_request( + mcp_server_url, + "MCP server URL", + ) + .await?; + let cached_client: Option = sqlx::query_as("SELECT mcp_server_url, client_id, client_secret, client_secret_expires_at, token_endpoint FROM mcp_oauth_client WHERE mcp_server_url = $1") .bind(mcp_server_url) @@ -131,6 +144,11 @@ pub async fn get_or_refresh_mcp_client( if let Some(client) = cached_client { if !client.is_expired() { tracing::debug!("Using cached MCP client for {}", mcp_server_url); + windmill_common::ssrf::validate_mcp_server_url_for_bad_request( + &client.token_endpoint, + "MCP server token endpoint URL", + ) + .await?; let decrypted_secret = if let Some(ref encrypted_secret) = client.client_secret { Some(decrypt_client_secret(db, encrypted_secret).await?) } else { @@ -145,17 +163,27 @@ pub async fn get_or_refresh_mcp_client( tracing::debug!("Cached MCP client expired, re-registering"); } - windmill_common::ssrf::validate_url_for_ssrf(mcp_server_url).await?; - - let manager = AuthorizationManager::new(mcp_server_url) + let mut manager = AuthorizationManager::new(mcp_server_url) .await .map_err(|e| error::Error::BadRequest(format!("Failed to create auth manager: {e}")))?; + let discovery_client = no_redirect_http_client().map_err(|e| { + error::Error::BadRequest(format!("Failed to build MCP OAuth discovery client: {e}")) + })?; + manager + .with_client(discovery_client) + .map_err(|e| error::Error::BadRequest(format!("Failed to configure auth manager: {e}")))?; let metadata = manager .discover_metadata() .await .map_err(|e| error::Error::BadRequest(format!("OAuth discovery failed: {e}")))?; + windmill_common::ssrf::validate_mcp_server_url_for_bad_request( + &metadata.token_endpoint, + "MCP server token endpoint URL", + ) + .await?; + let supports_dynamic_registration = metadata.registration_endpoint.is_some(); let (client_id, client_secret, expires_at) = if supports_dynamic_registration { diff --git a/backend/windmill-mcp/src/lib.rs b/backend/windmill-mcp/src/lib.rs index 7df6ee9f39..d9cae883de 100644 --- a/backend/windmill-mcp/src/lib.rs +++ b/backend/windmill-mcp/src/lib.rs @@ -38,11 +38,70 @@ pub mod client_registration; pub mod oauth { //! Re-exports of rmcp auth and oauth2 types for MCP OAuth implementations + use std::time::Duration; + pub use rmcp::transport::auth::AuthorizationManager; + const DEFAULT_OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30); + + pub fn no_redirect_http_client() -> Result { + no_redirect_http_client_with_timeout(DEFAULT_OAUTH_HTTP_TIMEOUT) + } + + pub(crate) fn no_redirect_http_client_with_timeout( + timeout: Duration, + ) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() + } + // Re-export oauth2 types needed for MCP OAuth flow pub use oauth2::{ basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenUrl, }; + + #[cfg(test)] + mod tests { + use super::*; + use std::{ + io::Read, + net::TcpListener, + thread, + time::{Duration, Instant}, + }; + + #[tokio::test] + async fn no_redirect_http_client_times_out_stalled_responses() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + let handle = thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let _ = stream.set_read_timeout(Some(Duration::from_millis(200))); + let mut buffer = [0; 1024]; + let _ = stream.read(&mut buffer); + thread::sleep(Duration::from_millis(300)); + } + }); + + let client = no_redirect_http_client_with_timeout(Duration::from_millis(50)).unwrap(); + let started = Instant::now(); + let err = client + .get(format!("http://{addr}/stall")) + .send() + .await + .expect_err("stalled response should time out"); + + assert!(err.is_timeout(), "expected timeout error, got: {err}"); + assert!( + started.elapsed() < Duration::from_secs(2), + "stalled request should fail promptly" + ); + + handle.join().unwrap(); + } + } } From fa86c62b6600e7d47dadf4706d7002706333d919 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 8 Jun 2026 17:42:07 +0200 Subject: [PATCH 23/32] fix(frontend): use ban icon for canceled jobs instead of hourglass (#9478) Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/lib/components/runs/JobStatusIcon.svelte | 13 +++++++++++-- frontend/src/lib/components/runs/RunRow.svelte | 14 +++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/runs/JobStatusIcon.svelte b/frontend/src/lib/components/runs/JobStatusIcon.svelte index 3e4d05a053..863e70f778 100644 --- a/frontend/src/lib/components/runs/JobStatusIcon.svelte +++ b/frontend/src/lib/components/runs/JobStatusIcon.svelte @@ -1,7 +1,16 @@ {#if runtime.savedFlow.val} @@ -152,30 +42,36 @@ isFlow /> {/if} -{#if runtime.loadingFlow && !runtime.loadedPath} -
Loading flow {path}…
-{:else if runtime.notFound && !runtime.loadedPath} - -{:else} - - runtime.scheduleForkComparisonRefresh()} - onDeploy={() => { - // FlowBuilder has no deploy toast and the session stays put, so toast - // here, then sync the preview to deployed (pulls the new locks + version_id). - sendUserToast('Deployed') - runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) - }} - /> -{/if} + runtime.flowStore.val?.path ?? path} +> + {#snippet editor()} + + runtime.scheduleForkComparisonRefresh()} + onDeploy={() => { + // FlowBuilder has no deploy toast and the session stays put, so toast + // here, then sync the preview to deployed (pulls the new locks + version_id). + sendUserToast('Deployed') + runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) + }} + /> + {/snippet} + diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index af77298a87..9ed75f9702 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -2,12 +2,8 @@ import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' - import { untrack } from 'svelte' import type { SessionRuntime } from './sessionRuntime.svelte' - import { UserDraft } from '$lib/userDraft.svelte' - import type { RawAppDraft } from './appDraftCodec' - import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft } from './appDraftCodec' - import SessionItemNotFound from './SessionItemNotFound.svelte' + import SessionEditorTarget from './SessionEditorTarget.svelte' let { runtime, @@ -20,107 +16,17 @@ path: string workspaceId: string onNavigate?: (item: WorkspaceItem) => void - /** - * Only the visible session should claim the workspace's live-editor - * slot — without this, a hidden warm-mounted session can overwrite the - * active session's UserDraft live-editor target (one slot per - * (workspace, kind)), so chat actions like discard / "the open editor" - * resolve to the wrong session. - */ + /** Forwarded to SessionEditorTarget — only the visible session claims the + * workspace's single live-editor slot. */ isActiveSession?: boolean } = $props() let diffDrawer: DiffDrawer | undefined = $state() - $effect(() => { - if (workspaceId && path) { - untrack(() => runtime.loadRawApp(workspaceId, path)) - } - }) - async function restoreFromCurrentTarget() { diffDrawer?.closeDrawer() await runtime.loadRawApp(workspaceId, path) } - - // Mark this editor as the live editor draft for the session's workspace - // so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve - // to this path — same registration the regular /apps_raw/edit page does. - // Gated on `isActiveSession`: warm-but-hidden session editors must not - // claim the workspace's single live-editor slot, else chat actions on the - // visible session resolve to the hidden one's path. - $effect(() => { - if (!workspaceId || !path) return - if (!isActiveSession) return - UserDraft.setLiveEditorDraft({ - workspace: workspaceId, - itemKind: 'raw_app', - storagePath: path, - effectivePath: runtime.rawApp.val?.path ?? path - }) - return () => - UserDraft.clearLiveEditorDraft('raw_app', { workspace: workspaceId, storagePath: path }) - }) - - // Bidirectional sync between this preview and `UserDraft`. - // We hold a *live* handle (useMany) rather than reading via the static - // `UserDraft.get`: the handle materializes UserDraft's shared reactive - // `$state` cell for (workspace, 'raw_app', path), and that cell is what - // lets the chat's writes (UserDraft.save / setDraftAndMeta, from - // write_app_file / patch_app_file / write_app_runnable) reach this preview. - // Without a live entry those writes only touch localStorage and the inbound - // effect below never re-fires. A reactive getter is used (not `use()`) - // because switching open_preview to another app swaps `path` without - // remounting this view, so the handle must re-acquire. - // - // Same one-way-reactive discipline as ScriptEditorView: inbound tracks only - // the handle's draft, outbound tracks only rawApp.val; each side's read of - // the other goes through untrack() to break the keystroke-revert race. - const draftHandles = UserDraft.useMany(() => [ - { itemKind: 'raw_app', path, workspace: workspaceId } - ]) - let lastInboundSig: string | undefined = $state(undefined) - - // Store → editor. Re-runs when the handle's draft changes (chat write, - // other session edit). - $effect(() => { - if (!workspaceId || !path) return - const incoming = draftHandles[0]?.draft - if (!incoming) return - const sig = JSON.stringify(incoming) - untrack(() => { - if (runtime.loadedRawAppPath !== path) return - if (sig === lastInboundSig) return - const current = runtime.rawApp.val - if (!current) return - lastInboundSig = sig - runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming) - }) - }) - - // Editor → store. Debounced 150ms so a typing burst inside a frontend - // file's Monaco editor coalesces into one store write. - let outboundTimer: ReturnType | undefined - $effect(() => { - if (!workspaceId || !path) return - if (runtime.loadedRawAppPath !== path) return - const raw = runtime.rawApp.val - if (!raw) return - const draft = runtimeRawAppToDraft(raw) - const sig = JSON.stringify(draft) - if (sig === lastInboundSig) return - if (outboundTimer) clearTimeout(outboundTimer) - outboundTimer = setTimeout(() => { - untrack(() => { - const current = UserDraft.get('raw_app', path, { workspace: workspaceId }) - if (current && JSON.stringify(current) === sig) return - UserDraft.save('raw_app', path, draft, { workspace: workspaceId }) - }) - }, 150) - return () => { - if (outboundTimer) clearTimeout(outboundTimer) - } - }) {#if runtime.savedRawApp.val} @@ -130,29 +36,37 @@ restoreDraft={restoreFromCurrentTarget} /> {/if} -{#if runtime.loadingRawApp && !runtime.loadedRawAppPath} -
Loading raw app {path}…
-{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath} - -{:else if runtime.rawApp.val} - { - // Sync the preview to deployed (raw apps deploy only from this editor). - runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) - }} - defaultSidebarCollapsed - sidebarStorageKey="raw-app-sidebar-collapsed-preview" - defaultSplitWithPreview={false} - /> -{/if} + runtime.rawApp.val?.path ?? path} +> + {#snippet editor()} + {#if runtime.rawApp.val} + { + // Sync the preview to deployed (raw apps deploy only from this editor). + runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) + }} + defaultSidebarCollapsed + sidebarStorageKey="raw-app-sidebar-collapsed-preview" + defaultSplitWithPreview={false} + /> + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index caadb8a5e0..bebd6d3cce 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -2,11 +2,10 @@ import ScriptBuilder from '$lib/components/ScriptBuilder.svelte' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' - import { untrack } from 'svelte' import type { SessionRuntime } from './sessionRuntime.svelte' import { DraftService, ScriptService, type NewScript } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' - import SessionItemNotFound from './SessionItemNotFound.svelte' + import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' let { @@ -22,24 +21,13 @@ workspaceId: string onNavigate?: (item: WorkspaceItem) => void initialTestPanelCollapsed?: boolean - /** - * Only the visible session should claim the workspace's live-editor - * slot — without this, a hidden warm-mounted session can overwrite the - * active session's UserDraft live-editor target (one slot per - * (workspace, kind)), so chat actions like discard / "the open editor" - * resolve to the wrong session. - */ + /** Forwarded to SessionEditorTarget — only the visible session claims the + * workspace's single live-editor slot. */ isActiveSession?: boolean } = $props() let diffDrawer: DiffDrawer | undefined = $state() - $effect(() => { - if (workspaceId && path) { - untrack(() => runtime.loadScript(workspaceId, path)) - } - }) - // Restore actions for the diff drawer. The previous shared // `loadScript`-based handler was a no-op: loadScript early-returns on the // already-loaded path (and would re-read the local draft anyway). Instead @@ -78,147 +66,65 @@ workspace: workspaceId }) } - - // Mark this editor as the live editor draft for the session's workspace - // so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve - // to this path — same registration the regular /scripts/edit page does. - // Gated on `isActiveSession`: warm-but-hidden session editors must not - // claim the workspace's single live-editor slot, else chat actions on the - // visible session resolve to the hidden one's path. - $effect(() => { - if (!workspaceId || !path) return - if (!isActiveSession) return - UserDraft.setLiveEditorDraft({ - workspace: workspaceId, - itemKind: 'script', - storagePath: path, - effectivePath: runtime.scriptStore.val?.path ?? path - }) - return () => - UserDraft.clearLiveEditorDraft('script', { workspace: workspaceId, storagePath: path }) - }) - - // Bidirectional sync between this preview and `UserDraft`. - // The same path under the same workspace is shared with the session's - // chat (read_workspace_item / write_script / edit_script) and any other - // open editor on the same workspace. - // - // We hold a *live* handle (useMany) instead of reading via the static - // `UserDraft.get`. The handle materializes UserDraft's shared reactive - // `$state` cell for (workspace, 'script', path) — and that cell is what - // lets the chat's writes (UserDraft.save, from write_script / edit_script) - // reach this preview. Without a live entry those writes only touch - // localStorage and the inbound effect below never re-fires. A reactive - // getter is used (not `use()`) because switching open_preview to another - // script swaps `path` without remounting this view, so the handle must - // re-acquire. - // - // One-way-reactive discipline: inbound tracks ONLY the handle's `draft` - // (and reads `script.content` via untrack); outbound tracks ONLY - // `script.content` (and reads UserDraft via untrack). Without that - // asymmetry, a user keystroke would re-fire the inbound effect with the - // pre-keystroke stored value and revert the edit. - const draftHandles = UserDraft.useMany(() => [ - { itemKind: 'script', path, workspace: workspaceId } - ]) - let lastInboundContent: string | undefined = $state(undefined) - - // Store → editor. Re-runs when the handle's draft changes (chat write, - // other session edit, …). `script.content` is read inside untrack so user - // keystrokes don't refire this effect. - $effect(() => { - if (!workspaceId || !path) return - const draft = draftHandles[0]?.draft - if (!draft || typeof draft.content !== 'string') return - const incoming = draft.content - untrack(() => { - if (runtime.loadedScriptPath !== path) return - const script = runtime.scriptStore.val - if (!script) return - if (incoming === script.content) return - lastInboundContent = incoming - script.content = incoming - if (draft.language) script.language = draft.language - if (draft.summary !== undefined) script.summary = draft.summary - }) - }) - - // Editor → store. Re-runs on `script.content` mutation (user typing - // or inbound write). UserDraft is read inside untrack so writing here - // doesn't ping-pong the inbound effect. `UserDraft.save` persists - // immediately and, now that the entry is live, updates the same cell the - // inbound effect reads (the content guard there makes it a no-op). - $effect(() => { - if (!workspaceId || !path) return - if (runtime.loadedScriptPath !== path) return - const script = runtime.scriptStore.val - if (!script) return - const content = script.content - if (content === lastInboundContent) return - untrack(() => { - const current = UserDraft.get('script', path, { workspace: workspaceId }) - if (current && current.content === content) return - UserDraft.save( - 'script', - path, - { ...(current ?? script), ...script }, - { - workspace: workspaceId - } - ) - }) - }) {#if runtime.savedScript.val} {/if} -{#if runtime.loadingScript && !runtime.loadedScriptPath} -
Loading script {path}…
-{:else if runtime.notFoundScript && !runtime.loadedScriptPath} - -{:else if runtime.scriptStore.val} - - { - runtime.scheduleForkComparisonRefresh() - // Re-pin parent_hash to the latest version so the next Deploy's conflict - // check (which runs before deploy, while the session stays mounted) - // doesn't misfire. - try { - const latest = await ScriptService.getScriptLatestVersion({ - workspace: workspaceId, - path: e.path - }) - const cur = runtime.scriptStore.val - if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash - } catch (err) { - console.error('Failed to sync parent_hash after save draft', err) - } - }} - onDeploy={(e) => { - // Fires on every deploy (primary, "Deploy & Stay here", and lib — we - // ignore e.stay since the session always stays). Toast, then sync the - // preview to the deployed version. - sendUserToast('Deployed') - runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) - }} - /> -{/if} + runtime.scriptStore.val?.path ?? path} +> + {#snippet editor()} + {#if runtime.scriptStore.val} + + { + runtime.scheduleForkComparisonRefresh() + // Re-pin parent_hash to the latest version so the next Deploy's conflict + // check (which runs before deploy, while the session stays mounted) + // doesn't misfire. + try { + const latest = await ScriptService.getScriptLatestVersion({ + workspace: workspaceId, + path: e.path + }) + const cur = runtime.scriptStore.val + if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash + } catch (err) { + console.error('Failed to sync parent_hash after save draft', err) + } + }} + onDeploy={(e) => { + // Fires on every deploy (primary, "Deploy & Stay here", and lib — we + // ignore e.stay since the session always stays). Toast, then sync the + // preview to the deployed version. + sendUserToast('Deployed') + runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) + }} + /> + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte new file mode 100644 index 0000000000..55bd697bb9 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte @@ -0,0 +1,128 @@ + + +{#snippet loadingOverlay(asOverlay: boolean)} +
+ +
+{/snippet} + +{#if slot.notFound && slot.loadedPath !== path} + + +{:else if slot.loadedPath === undefined} + + {@render loadingOverlay(false)} +{:else} + + {#key slot.loadedPath} + {@render editor()} + {/key} + {#if showOverlay} + {@render loadingOverlay(true)} + {/if} +{/if} diff --git a/frontend/src/lib/components/sessions/sessionDraftCodecs.ts b/frontend/src/lib/components/sessions/sessionDraftCodecs.ts new file mode 100644 index 0000000000..900b6dc2e4 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionDraftCodecs.ts @@ -0,0 +1,76 @@ +import type { Flow, NewScript } from '$lib/gen' +import { initFlowState } from '$lib/components/flows/flowState' +import { flowDraftSig } from './flowDraftSig' +import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft, type RawAppDraft } from './appDraftCodec' +import type { SessionRuntime } from './sessionRuntime.svelte' +import type { DraftSyncCodec } from './useUserDraftSync.svelte' + +// Outbound debounce, uniform across kinds (script was previously immediate; +// unified to 150ms so a typing burst coalesces into one persist like flow/raw_app). +const DEBOUNCE_MS = 150 + +export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec { + return { + itemKind: 'flow', + sig: flowDraftSig, + debounceMs: DEBOUNCE_MS, + applyDraftToStore(incoming) { + const current = runtime.flowStore.val + if (!current) return + runtime.flowStore.val = { + ...current, + value: incoming.value, + schema: incoming.schema ?? current.schema, + summary: incoming.summary ?? current.summary + } + // flowStateStore is keyed by module_id; after an AI write the set of + // module ids may differ, so rebuild the UI state. This wipes per-module + // test args / preview output — a known v1 trade-off. + void initFlowState(runtime.flowStore.val, runtime.flowStateStore) + }, + storeToDraft() { + return runtime.flowStore.val + } + } +} + +export function makeScriptCodec(runtime: SessionRuntime): DraftSyncCodec { + return { + itemKind: 'script', + sig: (d) => d.content ?? '', + debounceMs: DEBOUNCE_MS, + applyDraftToStore(incoming) { + const script = runtime.scriptStore.val + if (!script) return + if (typeof incoming.content !== 'string') return + script.content = incoming.content + if (incoming.language) script.language = incoming.language + if (incoming.summary !== undefined) script.summary = incoming.summary + }, + storeToDraft(current) { + const script = runtime.scriptStore.val + if (!script) return undefined + // Merge over the existing entry so fields the preview doesn't edit + // (set by the chat) survive a content-only save. + return { ...(current ?? script), ...script } + } + } +} + +export function makeRawAppCodec(runtime: SessionRuntime): DraftSyncCodec { + return { + itemKind: 'raw_app', + sig: (d) => JSON.stringify(d), + debounceMs: DEBOUNCE_MS, + applyDraftToStore(incoming) { + const current = runtime.rawApp.val + if (!current) return + runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming) + }, + storeToDraft() { + const raw = runtime.rawApp.val + if (!raw) return undefined + return runtimeRawAppToDraft(raw) + } + } +} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index d805c562d7..8bfe6dda30 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -39,23 +39,34 @@ import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib' import type { DisplayMessage } from '$lib/components/copilot/chat/shared' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' +// Per-kind load state for a session's editor target. Pure state container the +// load methods write into; the editor-target gate reads it to decide between +// the loading overlay, the not-found state, and a remount of the heavy editor. +// `loadedPath` flips to the requested path only once the load settles (data +// ready), which is what lets the gate remount on data-ready rather than on the +// (synchronous) target swap. +export interface LoadSlot { + loadedPath: string | undefined + loading: boolean + notFound: boolean +} + +export type SessionTargetKind = 'flow' | 'script' | 'raw_app' + export interface SessionRuntime { readonly sessionId: string readonly manager: AIChatManager + // Kind-agnostic accessor over the per-kind load slots, for consumers (the + // editor-target gate) that only need load state and not the typed store. + slot(kind: SessionTargetKind): LoadSlot // Flow target state readonly flowStore: StateStore readonly flowStateStore: { val: Record } readonly savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } - readonly loadingFlow: boolean - readonly notFound: boolean - readonly loadedPath: string | undefined loadFlow(workspace: string, path: string, force?: boolean): Promise // Script target state (parallel to flow, populated only for script-targeted sessions) readonly scriptStore: { val: NewScript | undefined } readonly savedScript: { val: NewScriptWithDraft | undefined } - readonly loadingScript: boolean - readonly notFoundScript: boolean - readonly loadedScriptPath: string | undefined loadScript(workspace: string, path: string, force?: boolean): Promise // Note: legacy drag-and-drop apps are intentionally NOT hosted in the // session preview pane (only code-based raw apps are), so there's no @@ -90,9 +101,6 @@ export interface SessionRuntime { } | undefined } - readonly loadingRawApp: boolean - readonly notFoundRawApp: boolean - readonly loadedRawAppPath: string | undefined loadRawApp(workspace: string, path: string, force?: boolean): Promise // Discard the local draft + refresh the fork diff + force-reload the editor, // so the preview matches the deployed version. Used by editor onDeploy + the @@ -173,7 +181,9 @@ function normalizeGeneratedSummary(summary: string | undefined): string | undefi return title.slice(0, GENERATED_SUMMARY_MAX_LENGTH).trim() } -async function generateSessionSummary(displayMessages: DisplayMessage[]): Promise { +async function generateSessionSummary( + displayMessages: DisplayMessage[] +): Promise { const transcript = buildSummaryTranscript(displayMessages) if (!transcript) return undefined const abortController = new AbortController() @@ -251,21 +261,15 @@ function createRuntime(session: Session): SessionRuntime { val: undefined }) - let loadingFlow = $state(false) - let notFound = $state(false) - let loadedPath = $state(undefined) + const flowSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false }) const scriptStore: { val: NewScript | undefined } = $state({ val: undefined }) const savedScript: { val: NewScriptWithDraft | undefined } = $state({ val: undefined }) - let loadingScript = $state(false) - let notFoundScript = $state(false) - let loadedScriptPath = $state(undefined) + const scriptSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false }) const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined }) const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined }) - let loadingRawApp = $state(false) - let notFoundRawApp = $state(false) - let loadedRawAppPath = $state(undefined) + const rawAppSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false }) const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined }) let loadingForkComparison = $state(false) @@ -298,25 +302,19 @@ function createRuntime(session: Session): SessionRuntime { return { sessionId: session.id, manager, + slot(kind: SessionTargetKind): LoadSlot { + return kind === 'flow' ? flowSlot : kind === 'script' ? scriptSlot : rawAppSlot + }, flowStore, flowStateStore, savedFlow, - get loadingFlow() { - return loadingFlow - }, - get notFound() { - return notFound - }, - get loadedPath() { - return loadedPath - }, async loadFlow(workspace: string, path: string, force = false) { - if (loadedPath === path && !force) return + if (flowSlot.loadedPath === path && !force) return // See loadScript: forced reload remounts via the render gate. - if (force) loadedPath = undefined - loadingFlow = true - notFound = false + if (force) flowSlot.loadedPath = undefined + flowSlot.loading = true + flowSlot.notFound = false try { // Draft first. UserDraft is the shared authoritative content // source — the chat (write_flow / patch_flow_json / @@ -348,7 +346,7 @@ function createRuntime(session: Session): SessionRuntime { await initFlow(aiDraft, flowStore, flowStateStore) if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId - loadedPath = path + flowSlot.loadedPath = path return } @@ -360,35 +358,27 @@ function createRuntime(session: Session): SessionRuntime { UserDraft.save('flow', path, flow, { workspace }) await initFlow(flow, flowStore, flowStateStore) if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId - loadedPath = path + flowSlot.loadedPath = path } catch (err) { console.error('Failed to load flow', err) - notFound = true + flowSlot.notFound = true } finally { - loadingFlow = false + flowSlot.loading = false } }, scriptStore, savedScript, - get loadingScript() { - return loadingScript - }, - get notFoundScript() { - return notFoundScript - }, - get loadedScriptPath() { - return loadedScriptPath - }, async loadScript(workspace: string, path: string, force = false) { - if (loadedScriptPath === path && !force) return - // Forced reload: clearing loadedScriptPath drops us into the - // `{#if loading && !loadedScriptPath}` gate, which unmounts then remounts - // the editor — avoids the Monaco init race a synchronous {#key} would hit. - if (force) loadedScriptPath = undefined - loadingScript = true - notFoundScript = false + if (scriptSlot.loadedPath === path && !force) return + // Forced reload: clearing the slot's loadedPath drops us into + // SessionEditorTarget's `{:else if slot.loadedPath === undefined}` gate, + // which unmounts then remounts the editor — avoids the Monaco init race a + // synchronous {#key} would hit. + if (force) scriptSlot.loadedPath = undefined + scriptSlot.loading = true + scriptSlot.notFound = false try { // Draft first. UserDraft is the shared authoritative content // source — the chat (write_script / edit_script) and the @@ -433,7 +423,7 @@ function createRuntime(session: Session): SessionRuntime { if (aiDraft.language) baseline.language = aiDraft.language if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary scriptStore.val = baseline - loadedScriptPath = path + scriptSlot.loadedPath = path return } @@ -449,33 +439,24 @@ function createRuntime(session: Session): SessionRuntime { baseline.parent_hash = result.hash UserDraft.save('script', path, baseline, { workspace }) scriptStore.val = baseline - loadedScriptPath = path + scriptSlot.loadedPath = path } catch (err) { console.error('Failed to load script', err) - notFoundScript = true + scriptSlot.notFound = true } finally { - loadingScript = false + scriptSlot.loading = false } }, rawApp, savedRawApp, - get loadingRawApp() { - return loadingRawApp - }, - get notFoundRawApp() { - return notFoundRawApp - }, - get loadedRawAppPath() { - return loadedRawAppPath - }, async loadRawApp(workspace: string, path: string, force = false) { - if (loadedRawAppPath === path && !force) return + if (rawAppSlot.loadedPath === path && !force) return // See loadScript: forced reload remounts via the render gate. - if (force) loadedRawAppPath = undefined - loadingRawApp = true - notFoundRawApp = false + if (force) rawAppSlot.loadedPath = undefined + rawAppSlot.loading = true + rawAppSlot.notFound = false try { // Draft first. UserDraft is the shared authoritative content // source — the chat (init_app / write_app_file / ...) and the @@ -513,7 +494,7 @@ function createRuntime(session: Session): SessionRuntime { }, aiDraft ) - loadedRawAppPath = path + rawAppSlot.loadedPath = path return } @@ -558,12 +539,12 @@ function createRuntime(session: Session): SessionRuntime { } UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace }) rawApp.val = runtimeValue - loadedRawAppPath = path + rawAppSlot.loadedPath = path } catch (err) { console.error('Failed to load raw app', err) - notFoundRawApp = true + rawAppSlot.notFound = true } finally { - loadingRawApp = false + rawAppSlot.loading = false } }, @@ -751,11 +732,7 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => { const session = sessionState.sessions.find((s) => s.id === sessionId) const runtime = runtimes.get(sessionId) if (!session?.workspace_id || !runtime) return - const open = - (kind === 'script' && runtime.loadedScriptPath === path) || - (kind === 'flow' && runtime.loadedPath === path) || - (kind === 'raw_app' && runtime.loadedRawAppPath === path) - if (!open) return + if (runtime.slot(kind).loadedPath !== path) return runtime.syncPreviewWithDeployed(session.workspace_id, kind, path) }) diff --git a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts new file mode 100644 index 0000000000..80075e2936 --- /dev/null +++ b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts @@ -0,0 +1,138 @@ +import { untrack } from 'svelte' +import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte' + +/** + * Per-kind projection between a `UserDraft` draft and a session editor's + * runtime store. Carries the kind's behavioral quirks (flow's `initFlowState` + * rebuild, script's merge-save) so {@link useUserDraftSync} stays generic. + */ +export interface DraftSyncCodec { + itemKind: UserDraftItemKind + /** + * Inbound: write an incoming draft into the runtime store (and run any + * side effects, e.g. flow's `initFlowState`). Reads the store internally; + * a no-op when the store isn't populated. + */ + applyDraftToStore(draft: Draft): void + /** + * Outbound: derive the draft to persist from the current store, or + * `undefined` when the store isn't populated. `current` is the existing + * UserDraft entry (script's merge-save needs it; flow/raw_app ignore it). + */ + storeToDraft(current: Draft | undefined): Draft | undefined + /** Signature over a draft, comparable across both directions; drives de-dup. */ + sig(draft: Draft): string + /** Outbound debounce; coalesces a typing burst into one persist. */ + debounceMs: number +} + +export interface UserDraftSyncOptions { + /** Reactive editor path (the target being edited). */ + path: () => string + /** Reactive workspace id (the session's, possibly forked, workspace). */ + workspace: () => string | undefined + /** + * Reactive inert-gate: both effects no-op unless the runtime has settled on + * this exact path (`slot.loadedPath === path`). Replaces the old per-view + * `loadedX !== path` guards. + */ + ready: () => boolean + codec: DraftSyncCodec +} + +/** + * Bidirectional sync between a session editor's runtime store and the shared + * `UserDraft` cell for `(workspace, kind, path)`. Holding a *live* handle + * (`useMany`) is what lets the chat's writes (`write_script`, `patch_flow_json`, + * …) reach the open preview — a plain `UserDraft.get` would only see localStorage. + * + * - **inbound** (`handle.draft → store`): reflects external writes into the editor. + * - **outbound** (`store → handle`, debounced): persists editor edits. + * + * One-way-reactive discipline: inbound tracks ONLY the handle's draft (reading + * the store via `untrack`); outbound tracks ONLY the store (reading UserDraft via + * `untrack`). Without that asymmetry a keystroke would re-fire the inbound effect + * with the pre-keystroke value and revert the edit. `lastInboundSig` de-dups the + * echo so an outbound save doesn't bounce back through inbound. + * + * Must be called once during component init (registers `useMany` + two `$effect`s). + */ +export function useUserDraftSync(opts: UserDraftSyncOptions): void { + const { codec } = opts + const handles = UserDraft.useMany(() => { + const p = opts.path() + const ws = opts.workspace() + return p && ws ? [{ itemKind: codec.itemKind, path: p, workspace: ws }] : [] + }) + let lastInboundSig: string | undefined = $state(undefined) + + // inbound: handle.draft → store. Re-runs when the handle's draft changes + // (chat write / another session's edit). The store read happens inside + // applyDraftToStore under untrack so the editor's own mutations don't refire. + $effect(() => { + const incoming = handles[0]?.draft + if (incoming == null) return + const sig = codec.sig(incoming) + untrack(() => { + if (!opts.ready()) return + // Centralized sig-based echo de-dup for all kinds. The flow/raw_app + // originals already de-duped on `lastInboundSig`; the script original + // instead compared `incoming === script.content` (store equality), and + // `lastInboundSig` is intentionally not reset on a target swap. Both are + // observably equivalent here: `applyDraftToStore` is idempotent (assigning + // an unchanged value is a no-op under Svelte reactivity), so a redundant + // re-apply only advances the sig, never reverts an edit or fires a save. + if (sig === lastInboundSig) return + lastInboundSig = sig + codec.applyDraftToStore(incoming) + }) + }) + + // outbound: store → handle (debounced). Re-runs on any tracked store + // mutation. `lastInboundSig` (read tracked here) makes the echo from an + // inbound apply a no-op, terminating the loop. + let outboundTimer: ReturnType | undefined + // The latest scheduled-but-unwritten save (captures its own path/workspace), + // so a target swap or unmount can flush it instead of dropping the last + // `debounceMs` of edits. See the flush effect below. + let pendingFlush: (() => void) | undefined + $effect(() => { + if (!opts.ready()) return + const draft = codec.storeToDraft(undefined) + if (draft == null) return + const sig = codec.sig(draft) + if (sig === lastInboundSig) return + const path = opts.path() + const workspace = opts.workspace() + if (!path || !workspace) return + const save = () => { + if (outboundTimer) { + clearTimeout(outboundTimer) + outboundTimer = undefined + } + pendingFlush = undefined + untrack(() => { + const current = UserDraft.get(codec.itemKind, path, { workspace }) + if (current && codec.sig(current) === sig) return + const toSave = codec.storeToDraft(current) ?? draft + UserDraft.save(codec.itemKind, path, toSave, { workspace }) + }) + } + pendingFlush = save + if (outboundTimer) clearTimeout(outboundTimer) + outboundTimer = setTimeout(save, codec.debounceMs) + }) + + // Flush a pending debounced write when the target path/workspace changes + // (breadcrumb swap) or on unmount. Tracks ONLY path/workspace — a normal + // typing burst (store mutation) re-runs the outbound effect above, not this + // one, so it never flushes mid-burst and the debounce is preserved. Without + // this, switching within the debounce window would silently drop the last + // edits (scripts previously saved immediately, so this is a regression guard + // for the new uniform debounce as well as a fix for flow/raw_app). + $effect(() => { + opts.path() + opts.workspace() + return () => pendingFlush?.() + }) +} From 5d0ef7dfd91b3021d125a1b34f81f0788f173786 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 8 Jun 2026 18:19:54 +0200 Subject: [PATCH 27/32] fix: center auth0/okta icons and respect currentColor (#9457) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Ruben Fiszel --- .../src/lib/components/icons/Auth0Icon.svelte | 30 ++++++++++--- .../src/lib/components/icons/OktaIcon.svelte | 44 ++++++++++++++++--- .../lib/components/icons/brands/Auth0.svelte | 20 +++------ 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/frontend/src/lib/components/icons/Auth0Icon.svelte b/frontend/src/lib/components/icons/Auth0Icon.svelte index 2c9919f037..fbf419e3cf 100644 --- a/frontend/src/lib/components/icons/Auth0Icon.svelte +++ b/frontend/src/lib/components/icons/Auth0Icon.svelte @@ -1,16 +1,36 @@ + auth0-svg diff --git a/frontend/src/lib/components/icons/OktaIcon.svelte b/frontend/src/lib/components/icons/OktaIcon.svelte index c001e63f20..01c5d0715c 100644 --- a/frontend/src/lib/components/icons/OktaIcon.svelte +++ b/frontend/src/lib/components/icons/OktaIcon.svelte @@ -1,7 +1,37 @@ - - oktaddd-svg - - - \ No newline at end of file + + + + okta-svg + + diff --git a/frontend/src/lib/components/icons/brands/Auth0.svelte b/frontend/src/lib/components/icons/brands/Auth0.svelte index 8d6696411c..d3d102fc02 100644 --- a/frontend/src/lib/components/icons/brands/Auth0.svelte +++ b/frontend/src/lib/components/icons/brands/Auth0.svelte @@ -1,30 +1,24 @@ - auth0ddd-svg - + auth0-svg From e8e0701a360d0614c4c5a74f6410ba6ac0638caa Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 8 Jun 2026 19:33:31 +0200 Subject: [PATCH 28/32] feat(api): add endpoint to update token label (#9474) * feat(api): add endpoint to update token label Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): prevent renaming the session token label Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): restrict token-label edits to user tokens, not just session Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): edit token label in the edit modal instead of inline Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): reject relabeling tokens to reserved system-token names Centralize the is_user_token classifier in windmill-common and reuse it to reject labels colliding with system-token namespaces (ephemeral*, debugger-token, mcp-oauth-*), not just session. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): match ephemeral label case-insensitively and cap label length Align the canonical is_user_token, the SQL guard and the frontend mirror on a case-insensitive `ephemeral` match (so a token can't be relabeled to a casing the backend allows but the UI hides), reject labels over the VARCHAR(1000) column limit with a 400, and add unit tests for is_user_token. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...fdb7b4cc11a648460f175e4f57d080a0005a5.json | 24 +++++ backend/src/monitor.rs | 18 +--- backend/windmill-api-auth/src/lib.rs | 13 +-- backend/windmill-api-users/src/users.rs | 87 +++++++++++++++++++ backend/windmill-api/openapi.yaml | 31 +++++++ backend/windmill-common/src/auth.rs | 57 ++++++++++++ .../settings/EditTokenScopesModal.svelte | 85 +++++++++++++++--- .../components/settings/TokensTable.svelte | 20 ++++- 8 files changed, 289 insertions(+), 46 deletions(-) create mode 100644 backend/.sqlx/query-c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5.json diff --git a/backend/.sqlx/query-c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5.json b/backend/.sqlx/query-c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5.json new file mode 100644 index 0000000000..774b47f825 --- /dev/null +++ b/backend/.sqlx/query-c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_prefix", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5" +} diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 789706e7f8..0c61d8d973 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -1104,24 +1104,8 @@ struct TokenRow { workspace_id: Option, } -/// When updating this filter, also update: -/// - `register_token_expiry_notification` in windmill-api-auth/src/lib.rs -/// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte -fn is_user_token(label: Option<&str>) -> bool { - match label { - None => true, - Some(l) => { - l != "session" - && !l.starts_with("ephemeral") - && !l.starts_with("Ephemeral") - && l != "debugger-token" - && !l.starts_with("mcp-oauth-") - } - } -} - async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) { - if !is_user_token(token.label.as_deref()) { + if !windmill_common::auth::is_user_token(token.label.as_deref()) { return; } let prefix = token.token_prefix.as_deref().unwrap_or("??????????"); diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 734753a0e0..b9e6a748d4 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -871,9 +871,6 @@ pub async fn create_token_internal( /// Insert a pending expiry notification row for user tokens that have an expiration. /// Stores the token_hash so the join in check_expiring_tokens works even when /// the plaintext token column is NULL (after hash migration). -/// When updating this filter, also update: -/// - `is_user_token` in src/monitor.rs -/// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte pub async fn register_token_expiry_notification( tx: &mut sqlx::PgConnection, token_hash: &str, @@ -881,14 +878,8 @@ pub async fn register_token_expiry_notification( expiration: Option>, ) { let Some(expiration) = expiration else { return }; - if label == Some("session") - || label.is_some_and(|l| { - l.starts_with("ephemeral") - || l.starts_with("Ephemeral") - || l == "debugger-token" - || l.starts_with("mcp-oauth-") - }) - { + // System tokens don't get expiry notifications. + if !windmill_common::auth::is_user_token(label) { return; } if let Err(e) = sqlx::query!( diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 95b9527a8e..db9ecb118d 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -139,6 +139,10 @@ pub fn global_service() -> Router { "/tokens/update_scopes/{token_prefix}", post(update_token_scopes), ) + .route( + "/tokens/update_label/{token_prefix}", + post(update_token_label), + ) .route("/tokens/list", get(list_tokens)) .route("/tokens/impersonate", post(impersonate)) .route("/usage", get(get_usage)) @@ -2408,6 +2412,89 @@ async fn update_token_scopes( Ok(format!("updated scopes for token {prefix}")) } +#[derive(Deserialize)] +struct UpdateTokenLabelRequest { + label: Option, +} + +async fn update_token_label( + Extension(db): Extension, + authed: ApiAuthed, + Path(token_prefix): Path, + Json(req): Json, +) -> Result { + // The new label must not collide with a system-token namespace (`session`, + // `ephemeral*`, `debugger-token`, `mcp-oauth-*`): those labels are + // load-bearing, and a user-set collision would orphan the token — hidden + // from the UI (`isUserToken`) and rejected by the editability guard below — + // while it still authenticates. (`is_user_token(None)` is true, so clearing + // the label is allowed.) + if !windmill_common::auth::is_user_token(req.label.as_deref()) { + return Err(Error::BadRequest( + "label collides with a reserved system-token namespace".to_string(), + )); + } + + // Matches the `token.label VARCHAR(1000)` column — reject overlong labels with + // a 400 rather than letting Postgres raise a 500. + const MAX_TOKEN_LABEL_LEN: usize = 1000; + if req + .label + .as_deref() + .is_some_and(|l| l.chars().count() > MAX_TOKEN_LABEL_LEN) + { + return Err(Error::BadRequest(format!( + "label must be at most {MAX_TOKEN_LABEL_LEN} characters" + ))); + } + + let mut tx = db.begin().await?; + + // Only user-created tokens may be relabeled — system tokens carry the + // load-bearing labels described above. This SQL mirrors the canonical + // `windmill_common::auth::is_user_token`; keep the two in sync (note the + // case-insensitive `ephemeral` match). + let updated: Option = sqlx::query_scalar!( + "UPDATE token SET label = $1 + WHERE email = $2 AND token_prefix = $3 + AND (label IS NULL OR ( + label <> 'session' + AND lower(label) NOT LIKE 'ephemeral%' + AND label <> 'debugger-token' + AND label NOT LIKE 'mcp-oauth-%' + )) + RETURNING token_prefix", + req.label.as_deref(), + &authed.email, + &token_prefix, + ) + .fetch_optional(&mut *tx) + .await?; + + let prefix = updated.ok_or_else(|| { + Error::NotFound(format!( + "token {token_prefix} not found, not owned by user, or not editable" + )) + })?; + + audit_log( + &mut *tx, + &authed, + "users.token.update_label", + ActionKind::Update, + &"global", + Some(&prefix), + Some([("label", req.label.as_deref().unwrap_or(""))].into()), + ) + .await?; + + tx.commit().await?; + + windmill_api_auth::invalidate_token_from_cache(&prefix); + + Ok(format!("updated label for token {prefix}")) +} + async fn leave_workspace( Extension(db): Extension, Path(w_id): Path, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7a8c349178..1bcb796158 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5120,6 +5120,37 @@ paths: schema: type: string + /users/tokens/update_label/{token_prefix}: + post: + summary: update label of an existing token (owner only) + operationId: updateTokenLabel + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new label (null or omitted = no label) + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + nullable: true + responses: + "200": + description: label updated + content: + text/plain: + schema: + type: string + /users/tokens/list: get: summary: list token diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 9872950ffb..7fc6a0825a 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -18,6 +18,31 @@ use crate::{ DB, }; +/// Whether `label` denotes a user-created token rather than a system token +/// (`session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token +/// labels are load-bearing — session cleanup, super_admin propagation, expiry +/// notifications and username overrides all key off them — so they must not be +/// user-editable. `None` (no label) is treated as a user token. +/// +/// This is the canonical copy. When updating it, also update its mirrors: +/// - the `update_token_label` editability guard (SQL `WHERE`) in +/// windmill-api-users/src/users.rs +/// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte +pub fn is_user_token(label: Option<&str>) -> bool { + match label { + None => true, + Some(l) => { + // `ephemeral` is matched case-insensitively to agree exactly with the + // frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and + // the SQL `lower(label) NOT LIKE 'ephemeral%'` guard. + l != "session" + && !l.to_lowercase().starts_with("ephemeral") + && l != "debugger-token" + && !l.starts_with("mcp-oauth-") + } + } +} + /// Hash a raw token using SHA-256 (hex-encoded, 64 chars). /// Used to store and look up tokens without keeping plaintext in the DB. pub fn hash_token(token: &str) -> String { @@ -641,3 +666,35 @@ pub mod aws { Ok(assume_role_with_web_identity_fluent_builder) } } + +#[cfg(test)] +mod tests { + use super::is_user_token; + + #[test] + fn user_tokens_are_editable() { + assert!(is_user_token(None)); // no label + assert!(is_user_token(Some(""))); + assert!(is_user_token(Some("my-ci-token"))); + assert!(is_user_token(Some("webhook-foo"))); // username-override prefix, not a system kind here + } + + #[test] + fn system_tokens_are_not_editable() { + assert!(!is_user_token(Some("session"))); + assert!(!is_user_token(Some("ephemeral-script"))); + assert!(!is_user_token(Some("ephemeral-webhook-x"))); + assert!(!is_user_token(Some("Ephemeral lsp token"))); + assert!(!is_user_token(Some("debugger-token"))); + assert!(!is_user_token(Some("mcp-oauth-client"))); + } + + #[test] + fn ephemeral_match_is_case_insensitive() { + // Must agree with the frontend mirror (`toLowerCase().startsWith('ephemeral')`) + // so a token can't be relabeled to a casing the backend allows but the UI hides. + assert!(!is_user_token(Some("Ephemeral-test"))); + assert!(!is_user_token(Some("ePhemeral-test"))); + assert!(!is_user_token(Some("EPHEMERAL-test"))); + } +} diff --git a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte index 480e4dfe3b..ffea14f4c0 100644 --- a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte +++ b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte @@ -1,14 +1,19 @@ - +
Token {tokenPrefix}****
- {#key tokenPrefix} - + Label + - {/key} + {#if !labelEditable} + System token labels can't be changed. + {/if} +
+ +
+ Scopes + {#key tokenPrefix} + + {/key} +
{#snippet actions()} diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index 5907c808a6..f2e2ca235d 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -35,7 +35,13 @@ let tokenPage = $state(1) let newTokenLabel = $state(untrack(() => defaultNewTokenLabel)) let editingToken = $state< - { prefix: string; scopes: string[] | undefined; workspaceId: string | undefined } | undefined + | { + prefix: string + label: string | undefined + scopes: string[] | undefined + workspaceId: string | undefined + } + | undefined >(undefined) let editModalOpen = $state(false) @@ -43,9 +49,9 @@ listTokens() }) - // When updating this filter, also update: - // - `is_user_token` in backend/src/monitor.rs - // - `register_token_expiry_notification` in backend/windmill-api-auth/src/lib.rs + // Mirror of the canonical `is_user_token` in backend/windmill-common/src/auth.rs. + // When updating this filter, also update that function and the SQL `WHERE` + // mirror in `update_token_label` (backend/windmill-api-users/src/users.rs). function isUserToken(label: string | undefined): boolean { if (!label) return true return ( @@ -104,11 +110,13 @@ function handleEditClick( tokenPrefix: string, + tokenLabel: string | undefined, tokenScopes: string[] | undefined, tokenWorkspaceId: string | undefined ) { editingToken = { prefix: tokenPrefix, + label: tokenLabel, scopes: tokenScopes, workspaceId: tokenWorkspaceId } @@ -198,9 +206,11 @@
in the undeployed-drafts alert with a design-system Button (variant=subtle). - Drop the stray Prettier reflow in sessionRuntime (restore to match main). Co-Authored-By: Claude Opus 4.8 * feat(compare): warn on fork items with a pending draft In the fork compare list, items that are deployed *and* have a pending draft (has_draft) now: - show a yellow "+Draft" badge (AlertTriangle), rendered before the New/status badges, with a per-direction tooltip explaining that deploying/updating moves the deployed version, not the draft; - are excluded from the default selection (still manually selectable); - trigger a confirmation modal if explicitly selected and deployed/updated, listing the affected paths. The signal comes from the page's existing fork drafts resource (a kind:path Set passed down) — no new fetch, no backend change. Also rename the undeployed-drafts alert CTA from "Deploy drafts" to "See drafts". Co-Authored-By: Claude Opus 4.8 * chore(compare): rename page to "Compare & Deploy" Update both the page heading (PageHeader) and the browser-tab title. Co-Authored-By: Claude Opus 4.8 * feat(compare): multi-select rows by default (no modifier) In the shared WorkspaceDeployLayout (fork + draft lists), a plain row click now toggles the item in/out of the selection instead of replacing the whole selection with it. Removed the modifier-based selection entirely: the now-dead onSelectOnly path and its two call sites, plus shift+click range selection (and its anchor/isPickable helpers). Co-Authored-By: Claude Opus 4.8 * fix(table): don't toggle row selection on keyboard child activation Row's onkeydown selection handler lacked the interactive-child guard that handleRowClick already had, so pressing Enter/Space on a checkbox, action button, or title link both activated the child and toggled the row's selection. Extract a shared fromInteractiveChild() guard and apply it in handleRowKeydown, mirroring the click path. Co-Authored-By: Claude Opus 4.8 * feat(fork-banner): show draft CTA when fork is up to date When a fork has no changes vs its parent ("Everything is up to date") but has pending drafts, the banner now mirrors the non-fork drafts banner: the status text becomes "This workspace has N draft(s)" and the button becomes "Review & deploy drafts", linking to the compare page in draft mode. When the fork has real ahead/behind diffs, the existing status and buttons are unchanged. Co-Authored-By: Claude Opus 4.8 * fix(compare): honor renamed draft paths + raw-app draft fixes Address the Codex review: - Draft deploy now uses the draft payload's path for scripts, flows and raw apps (keeping the URL path as the existing item key), so a rename in a draft deploys to the new path instead of silently staying at the old one. - DraftDiffDrawer maps raw apps to the `raw_app` kind so their row edit links open the raw-app editor, not the legacy app editor. - ScriptEditorView.restoreDeployed invalidates the workspace drafts after deleting the draft, so the session draft-bar count drops immediately. Co-Authored-By: Claude Opus 4.8 * fix(compare): guard showDiff race, tree label, mode fallback Address the cubic review: - CompareDrafts.showDiff uses a monotonic request token so two quick "Show diff" clicks can't let a slow earlier fetch overwrite a faster later one. - WorkspaceDiffDrawer.buildTree labels a 2-segment path with its leaf name (parts[1]) instead of the full scope key. - The compare page only resolves ?mode=draft immediately; ?mode=fork (and an absent mode) defer to the isFork-aware effect, which falls back to draft for non-fork workspaces instead of stranding them on the fork UI. Co-Authored-By: Claude Opus 4.8 * chore: remove CONTEXT.md from the PR Drop the root CONTEXT.md domain glossary and the lone comment pointer to it in workspaceDrafts.svelte.ts. Co-Authored-By: Claude Opus 4.8 * fix(compare): send custom_path on raw-app draft deploy A raw-app draft that changes or clears its custom route was silently dropped on deploy from the compare page: updateAppRaw omitted custom_path, so the backend preserved the old route. Send the draft's custom_path on update — matching the fork deploy path (which spreads the full app, custom_path included) and the createAppRaw branch. Co-Authored-By: Claude Opus 4.8 * fix(sessions): refresh draft count on raw-app session save-draft The script/flow session editors invalidate the workspace drafts on save-draft, but the raw-app editor only did so on deploy. Thread an onSaveDraft callback through RawAppEditor → RawAppEditorHeader and call invalidateWorkspaceDrafts from RawAppEditorView, so saving a raw-app draft in an AI session updates the SessionDraftBar count immediately (and the bar appears when the count was zero). Co-Authored-By: Claude Opus 4.8 * fix(compare): honor renamed paths, draft triggers & paginate inventory Address the Codex review: - Draft deploy honors the draft's renamed path for scripts/flows/raw apps (keeping the URL path as the existing item key). - Script/flow draft deploy now deploys draft_triggers via the shared deployTriggers, instead of silently dropping them with the draft. - rawAppDeploy sends custom_path admin-gated on update (admin: value/'' to clear; non-admin: undefined) so non-admins don't hit RequireAdmin. - getDraftItems pages through listScripts/listFlows/listApps so drafts past the first page are included in the count, banners, drawer and deploy list. Co-Authored-By: Claude Opus 4.8 * fix(compare): admin-gate custom_path on visual-app draft deploy The visual-app branch of deployDraft sent custom_path unconditionally on updateApp, so a non-admin deploying an app draft for an app with a custom route hit RequireAdmin. Mirror AppEditorHeader and the raw-app path: admins send the draft's custom_path ('' clears), non-admins send undefined so the backend preserves the existing route. Co-Authored-By: Claude Opus 4.8 * fix(raw-app): save initial draft directly when path is known In the AI-session preview, a never-deployed raw app has newApp=true but a known path, so saveDraft opened the "Initial draft save" path-picker drawer — which is gated on `appPath == ''` and therefore never rendered, making Save draft silently do nothing. Branch the new-app case on appPath: pick a path via the drawer only when none is chosen yet; otherwise call saveInitialDraft() directly. saveInitialDraft now also toasts and fires onSaveDraft so the session draft-bar count refreshes. Co-Authored-By: Claude Opus 4.8 * fix(compare): preserve deployed custom_path on visual-app draft deploy The visual-app draft value usually omits custom_path, so the admin branch's `d.custom_path ?? ''` sent an empty string, which the backend treats as "clear the route" — an admin deploying a content-only draft would wipe the app's existing custom route. Fall back to the deployed route (`r.custom_path`) when the draft omits it; an explicit '' still clears. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../src/lib/components/CompareDrafts.svelte | 399 +++++++++ .../lib/components/CompareModeToggle.svelte | 61 ++ .../lib/components/CompareWorkspaces.svelte | 317 ++++++-- .../lib/components/ExternalEditLink.svelte | 34 + .../lib/components/ForkWorkspaceBanner.svelte | 40 +- .../WorkspaceDeployItemSummary.svelte | 43 + .../components/WorkspaceDeployLayout.svelte | 14 +- .../components/WorkspaceDraftsBanner.svelte | 48 ++ .../lib/components/common/table/Row.svelte | 36 + .../components/raw_apps/RawAppEditor.svelte | 10 +- .../raw_apps/RawAppEditorHeader.svelte | 20 +- .../sessions/DraftDiffDrawer.svelte | 82 ++ .../components/sessions/FlowEditorView.svelte | 9 +- .../components/sessions/ForkDiffDrawer.svelte | 765 ++---------------- .../sessions/RawAppEditorView.svelte | 8 + .../sessions/ScriptEditorView.svelte | 9 + .../sessions/SessionDiffButton.svelte | 24 + .../sessions/SessionDraftBar.svelte | 70 ++ .../components/sessions/SessionForkBar.svelte | 23 +- .../components/sessions/SessionWrapper.svelte | 22 +- .../sessions/WorkspaceDiffDrawer.svelte | 638 +++++++++++++++ frontend/src/lib/rawAppDeploy.ts | 122 +++ frontend/src/lib/utils_draft_deploy.ts | 219 +++++ frontend/src/lib/workspaceDrafts.svelte.ts | 143 ++++ .../src/routes/(root)/(logged)/+page.svelte | 2 + .../(root)/(logged)/forks/compare/+page.js | 2 +- .../(logged)/forks/compare/+page.svelte | 141 +++- 27 files changed, 2476 insertions(+), 825 deletions(-) create mode 100644 frontend/src/lib/components/CompareDrafts.svelte create mode 100644 frontend/src/lib/components/CompareModeToggle.svelte create mode 100644 frontend/src/lib/components/ExternalEditLink.svelte create mode 100644 frontend/src/lib/components/WorkspaceDeployItemSummary.svelte create mode 100644 frontend/src/lib/components/WorkspaceDraftsBanner.svelte create mode 100644 frontend/src/lib/components/sessions/DraftDiffDrawer.svelte create mode 100644 frontend/src/lib/components/sessions/SessionDiffButton.svelte create mode 100644 frontend/src/lib/components/sessions/SessionDraftBar.svelte create mode 100644 frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte create mode 100644 frontend/src/lib/rawAppDeploy.ts create mode 100644 frontend/src/lib/utils_draft_deploy.ts create mode 100644 frontend/src/lib/workspaceDrafts.svelte.ts diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte new file mode 100644 index 0000000000..386007317c --- /dev/null +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -0,0 +1,399 @@ + + +
+
+ deploymentStatus[item.key]?.status !== 'deployed'} + onToggleItem={toggleItem} + onSelectAll={selectAll} + onDeselectAll={deselectAll} + emptyMessage={draftsLoading ? 'Loading drafts…' : 'No drafts in this workspace'} + > + {#snippet header()} + {#if isFork} +
+ onModeSelected?.(v)} + /> + +
+ + deploy: + + draft + + + + into: + + {currentWorkspaceId} + +
+
+ {/if} + {/snippet} + + {#snippet itemSummary(item)} + {@const draftItem = item as unknown as Row} + {@const editUrl = draftEditUrl(draftItem)} + {@const cache = summaryCache[draftItem.key]} + {@const oldSummary = cache?.deployed ?? draftItem.summary} + {@const newSummary = cache?.draft ?? draftItem.summary} + + {/snippet} + + {#snippet itemActions(item)} + {@const draftItem = item as unknown as Row} + {#if draftItem.draft_only} + New + {/if} + {#if deploymentStatus[draftItem.key]?.status !== 'deployed'} + + + {/if} + {/snippet} + + {#snippet footer()} +
+ +
+ {/snippet} +
+
+ + +
+ + (discardTarget = undefined)} +> + {#if discardTarget?.draft_only} +

+ {discardTarget?.path} exists only as a + draft. Discarding it will permanently delete the item. This cannot be undone. +

+ {:else} +

+ Discard the draft of + {discardTarget?.path}? The deployed + version is unaffected. +

+ {/if} +
diff --git a/frontend/src/lib/components/CompareModeToggle.svelte b/frontend/src/lib/components/CompareModeToggle.svelte new file mode 100644 index 0000000000..c7728ba090 --- /dev/null +++ b/frontend/src/lib/components/CompareModeToggle.svelte @@ -0,0 +1,61 @@ + + + + + onSelected(v as CompareMode)} noWFull> + {#snippet children({ item })} + {#if isFork} + + + {/if} + + {/snippet} + diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 95d36c4551..06d787095e 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -1,10 +1,8 @@ + +
e.stopPropagation()} + class="group inline-flex items-center gap-1 max-w-full hover:underline {klass}" +> + {@render children()} + + diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index 7ce36b8e11..56418c1988 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -6,6 +6,7 @@ import { AlertTriangle, GitFork, CircleCheck, CircleX, Loader2 } from 'lucide-svelte' import { goto } from '$app/navigation' import { onMount, untrack } from 'svelte' + import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let loading = $state(false) let comparison: WorkspaceComparison | undefined = $state(undefined) @@ -16,6 +17,23 @@ let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id) let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId)) + // Drafts in this fork. When the fork is otherwise in sync with its parent, a + // user with only pending drafts should still get the draft CTA (mirrors the + // non-fork WorkspaceDraftsBanner). Pass undefined when not a fork so it doesn't + // fetch. + const drafts = useWorkspaceDrafts(() => (isFork ? ($workspaceStore ?? undefined) : undefined)) + const draftCount = $derived(drafts.count) + + // Fork is fully in sync with its parent (comparison ran, no ahead/behind diffs). + // Typed helper avoids the $state `never`-inference quirk on `comparison` in $derived. + function isUpToDate(c: WorkspaceComparison | undefined): boolean { + return !!c && !c.skipped_comparison && c.summary.total_diffs === 0 + } + let upToDate = $derived(isUpToDate(comparison)) + // Up to date with the parent but local drafts are pending — show the draft + // state (same text + CTA as the draft banner) instead of "Everything is up to date". + let showDraftsOnly = $derived(upToDate && draftCount > 0) + $effect(() => { ;[$workspaceStore, parentWorkspaceId] untrack(() => { @@ -68,6 +86,14 @@ } } + function openDraftCompare() { + if ($workspaceStore) { + goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore) + '&mode=draft', { + replaceState: true + }) + } + } + let ciTestPassing = $state(0) let ciTestFailing = $state(0) let ciTestRunning = $state(0) @@ -270,6 +296,10 @@ This fork was created before the addition of certain windmill features, and therefore the changes with its parent workspace cannot be displayed. + {:else if showDraftsOnly} + + This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} + {:else} Everything is up to date {/if} @@ -278,8 +308,14 @@
- +
+
+
+{/if} diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 939519522f..b90fb385d8 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -14,6 +14,10 @@ disabled?: boolean canFavorite?: boolean isSelectable?: boolean + /** When true, clicking anywhere on the row card (except interactive + * children — checkbox, buttons, links) toggles selection. Opt-in so + * existing tables that don't want it are unaffected. */ + selectOnRowClick?: boolean alignWithSelectable?: boolean errorHandlerMuted?: boolean aiId?: string | undefined @@ -62,6 +66,7 @@ disabled = false, canFavorite = true, isSelectable = false, + selectOnRowClick = false, alignWithSelectable = false, errorHandlerMuted = false, aiId = undefined, @@ -92,6 +97,32 @@ rowEl?.scrollIntoView({ block: 'nearest' }) } }) + + const clickToSelect = $derived(selectOnRowClick && isSelectable && !disabled) + + // Interactive children that handle their own activation — selecting the row on + // top of them would double-fire (mouse) or hijack their keyboard activation. + function fromInteractiveChild(e: Event): boolean { + return !!(e.target as HTMLElement | null)?.closest('a, button, input, [data-row-actions]') + } + + function handleRowClick(e: MouseEvent) { + if (!clickToSelect) return + // Don't double-toggle when the click originated from an interactive child + // (the checkbox itself, action buttons, or the title link). + if (fromInteractiveChild(e)) return + onSelect?.(e as unknown as Event & { currentTarget: EventTarget & HTMLInputElement }) + } + + function handleRowKeydown(e: KeyboardEvent) { + if (!clickToSelect) return + if (e.key !== 'Enter' && e.key !== ' ') return + // Same guard as the click path: activating a child (checkbox / action button + // / title link) via Enter/Space must not also toggle the row's selection. + if (fromInteractiveChild(e)) return + e.preventDefault() + onSelect?.(e as unknown as Event & { currentTarget: EventTarget & HTMLInputElement }) + } {#if href} @@ -112,9 +143,14 @@ 'w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0', depth > 0 ? '!rounded-none' : '', disabled ? 'opacity-25' : 'hover:bg-surface-hover', + clickToSelect ? 'cursor-pointer select-none' : '', selected ? 'bg-surface-accent-selected' : keyboardSelected ? 'bg-gray-200 dark:bg-gray-700' : '' )} style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''} + role={clickToSelect ? 'button' : undefined} + tabindex={clickToSelect ? 0 : undefined} + onclick={handleRowClick} + onkeydown={clickToSelect ? handleRowKeydown : undefined} > {#if isSelectable} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8121ca8b70..63f7a42453 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -69,6 +69,9 @@ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** Fired after a successful deploy; the session preview reloads on it. */ onDeploy?: (e: { path: string }) => void + /** Fired after a successful server-draft save; the session refreshes its + * draft-bar count on it (parity with the script/flow editors). */ + onSaveDraft?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -101,6 +104,7 @@ diffDrawer = undefined, onNavigate, onDeploy = undefined, + onSaveDraft = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', liveEditorDraftStoragePath = undefined, @@ -1371,6 +1375,7 @@ {getBundle} {onNavigate} {onDeploy} + {onSaveDraft} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1637,8 +1642,9 @@ title="Build failed" class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']" > -
{buildError}
+
{buildError}
{/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index dedb7b5d11..46dec152f7 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -136,6 +136,9 @@ liveEditorDraftStoragePath?: string // Fired after a successful deploy; lets the session preview reload. onDeploy?: (e: { path: string }) => void + // Fired after a successful server-draft save; lets the session refresh the + // draft-bar count (the script/flow editors do the same on save-draft). + onSaveDraft?: (e: { path: string }) => void } let { @@ -162,7 +165,8 @@ onToggleSidebar = undefined, onNavigate = undefined, liveEditorDraftStoragePath = undefined, - onDeploy = undefined + onDeploy = undefined, + onSaveDraft = undefined }: Props = $props() let newEditedPath = $state( @@ -517,6 +521,8 @@ // a future "+ App" click opens on a clean slate. if (!inSessionPane) UserDraft.remove('raw_app', appPath) dispatch('savedNewAppPath', newEditedPath) + sendUserToast('Draft saved') + onSaveDraft?.({ path: newEditedPath }) } catch (e) { sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true) } @@ -529,8 +535,15 @@ return } if (newApp) { - // initial draft - draftDrawerOpen = true + if (appPath === '') { + // Standalone "+ App" with no path chosen yet — pick one via the drawer. + draftDrawerOpen = true + return + } + // Path already known (e.g. an AI-created raw app in the session preview). + // The path-picker drawer is gated on `appPath == ''`, so opening it here + // renders nothing — save the initial draft directly instead. + await saveInitialDraft() return } if (!savedApp) { @@ -621,6 +634,7 @@ if (newApp || savedApp.draft_only) { dispatch('savedNewAppPath', newEditedPath || path) } + onSaveDraft?.({ path: newEditedPath || path }) } catch (e) { loading.saveDraft = false throw e diff --git a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte new file mode 100644 index 0000000000..9fa83be7fc --- /dev/null +++ b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte @@ -0,0 +1,82 @@ + + + buildEditUrl(d as unknown as WorkspaceItemDiff, workspaceId)} +> + {#snippet titleExtra()} +
+ + {ws?.name ?? workspaceId} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index dfa928f08c..9613b4c25a 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -5,6 +5,7 @@ import type { SessionRuntime } from './sessionRuntime.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -65,12 +66,18 @@ {diffDrawer} {onNavigate} customUi={{ topBar: { aiBuilder: false } }} - onSaveDraft={() => runtime.scheduleForkComparisonRefresh()} + onSaveDraft={() => { + runtime.scheduleForkComparisonRefresh() + // Saving a draft adds/keeps a pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) + }} onDeploy={() => { // FlowBuilder has no deploy toast and the session stays put, so toast // here, then sync the preview to deployed (pulls the new locks + version_id). sendUserToast('Deployed') runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) + // Deploying clears the item's pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) }} /> {/snippet} diff --git a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte index acfac1f03f..e03fc5a49c 100644 --- a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte @@ -1,84 +1,71 @@ - searchableText(d)} -/> - -{#snippet renderTreeNode(node: TreeNode, depth: number)} - {#if node.type === 'folder'} - {@const isUserScope = node.isScope && node.name.startsWith('u/')} - {@const fkey = folderKey(node)} - {@const open = isFolderOpen(fkey)} - {@const isHl = fkey === highlightedKey} -
(folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)} - class="select-none" - > - setHoverHighlight(fkey)} - class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl - ? 'bg-surface-hover' - : ''}" - style="padding-left: {depth * 12 + 8}px" - > - - - {#if isUserScope} - - {:else} - - {/if} - {node.name} - -
- {#each node.children as child} - {@render renderTreeNode(child, depth + 1)} - {/each} -
-
- {:else} - {@const status = statusOf(node.diff)} - {@const key = itemKey(node.diff)} - { - highlightedKey = key - scrollToDiff(node.diff) - }} - onmouseenter={() => setHoverHighlight(key)} - > - {#snippet extras()} - - {/snippet} - - {/if} -{/snippet} - - - drawer?.closeDrawer()} - documentationLink={undefined} - noPadding - overflow_y={false} - > - {#snippet titleExtra()} -
- - {forkWs?.name ?? forkWorkspaceId} - - {parentWs?.name ?? parentWorkspaceId} - {#if comparison} - - {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} + buildEditUrl(d as unknown as WorkspaceItemDiff, forkWorkspaceId)} +> + {#snippet titleExtra()} +
+ + {forkWs?.name ?? forkWorkspaceId} + + {parentWs?.name ?? parentWorkspaceId} + {#if comparison} + + {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} + + {#if comparison.summary.conflicts > 0} + + + {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} - {#if comparison.summary.conflicts > 0} - - - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} - - {/if} {/if} -
- {/snippet} - {#snippet actions()} - - {#snippet children({ item })} - - - {/snippet} - - - {/snippet} -
- {#if comparison && comparison.diffs.length > 0} - {/if} -
-
- {#if loading && !comparison} -
- - Loading comparison... -
- {:else if error} -
{error}
- {:else if comparison?.skipped_comparison} -
- This fork was created before change tracking was added — diffs are not available. -
- {:else if comparison && comparison.diffs.length === 0} -
No changes between this fork and its parent.
- {:else if comparison && filteredDiffs.length === 0} -
No files match "{searchQuery}".
- {:else if comparison} -
- {#each filteredDiffs as d (itemKey(d))} - {@const key = itemKey(d)} - {@const status = statusOf(d)} - {@const StatusIcon = statusIcons[status]} - {@const loaded = loadedDiffs[key]} - {@const editUrl = editUrlFor(d)} -
onDetailsToggle(d, e)} - > - - - - -
- {#if d.ahead > 0} - {d.ahead} ahead - {/if} - {#if d.behind > 0} - {d.behind} behind - {/if} - - - {status} - -
-
-
- {#if !loaded || loaded.state === 'loading'} -
- - Loading diff… -
- {:else if loaded.state === 'error'} -
{loaded.error}
- {:else if loaded.state === 'ready'} - - {/if} -
-
- {/each} -
- {/if} -
- - - - +
+ {/snippet} + diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 9ed75f9702..683d32a31f 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -4,6 +4,7 @@ import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionRuntime } from './sessionRuntime.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -62,6 +63,13 @@ onDeploy={(e) => { // Sync the preview to deployed (raw apps deploy only from this editor). runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) + // Deploying clears the item's pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) + }} + onSaveDraft={() => { + // Saving a server draft adds/updates a draft — refresh the Draft Count so + // the session draft bar appears/updates immediately (parity with script/flow). + invalidateWorkspaceDrafts(workspaceId) }} defaultSidebarCollapsed sidebarStorageKey="raw-app-sidebar-collapsed-preview" diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index bebd6d3cce..2e6fc4210f 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -7,6 +7,7 @@ import { UserDraft } from '$lib/userDraft.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -45,6 +46,9 @@ try { await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path }) saved.draft = undefined + // Server draft gone — refresh the session draft-bar count immediately + // instead of waiting for an AI turn-end / tab-refocus signal. + invalidateWorkspaceDrafts(workspaceId) } catch (e: any) { sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true) return @@ -103,6 +107,8 @@ {initialTestPanelCollapsed} onSaveDraft={async (e) => { runtime.scheduleForkComparisonRefresh() + // Saving a draft adds/keeps a pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) // Re-pin parent_hash to the latest version so the next Deploy's conflict // check (which runs before deploy, while the session stays mounted) // doesn't misfire. @@ -123,6 +129,9 @@ // preview to the deployed version. sendUserToast('Deployed') runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) + // Deploying clears the item's pending draft — refresh the workspace + // Draft Count so the session bar / compare page drop it immediately. + invalidateWorkspaceDrafts(workspaceId) }} /> {/if} diff --git a/frontend/src/lib/components/sessions/SessionDiffButton.svelte b/frontend/src/lib/components/sessions/SessionDiffButton.svelte new file mode 100644 index 0000000000..77dd16d156 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionDiffButton.svelte @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/lib/components/sessions/SessionDraftBar.svelte b/frontend/src/lib/components/sessions/SessionDraftBar.svelte new file mode 100644 index 0000000000..dcc5640f2c --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionDraftBar.svelte @@ -0,0 +1,70 @@ + + +{#if committedId && count > 0} +
+
+ + {count} draft{count === 1 ? '' : 's'} +
+
+ drawer?.open()} /> + +
+
+ + +{/if} diff --git a/frontend/src/lib/components/sessions/SessionForkBar.svelte b/frontend/src/lib/components/sessions/SessionForkBar.svelte index 22b41a7a00..b1e74b43eb 100644 --- a/frontend/src/lib/components/sessions/SessionForkBar.svelte +++ b/frontend/src/lib/components/sessions/SessionForkBar.svelte @@ -2,10 +2,8 @@ import { Archive, ArrowRight, - Diff, GitCompareArrows, GitFork, - GitMerge, GitPullRequestArrow, GitPullRequestClosed, MoveRight, @@ -20,6 +18,7 @@ import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte' import { getRuntime } from './sessionRuntime.svelte' import ForkDiffDrawer from './ForkDiffDrawer.svelte' + import SessionDiffButton from './SessionDiffButton.svelte' let { session, @@ -189,24 +188,12 @@
- - + /> +
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 81b6f06aa0..830cf1d2bd 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -29,6 +29,7 @@ import RawAppEditorView from './RawAppEditorView.svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import SessionForkBar from './SessionForkBar.svelte' + import SessionDraftBar from './SessionDraftBar.svelte' import { createSession, getEffectiveWorkspaceId, @@ -274,13 +275,20 @@ {#if !hasFirstUserMessage} {/if} - moveAndActivate(workspaceId)} - onCreateForkAndMove={(fork) => createForkAndMove(fork)} - onArchive={() => archiveAndReset()} - onDelete={() => (deleteConfirmOpen = true)} - /> + +
+ moveAndActivate(workspaceId)} + onCreateForkAndMove={(fork) => createForkAndMove(fork)} + onArchive={() => archiveAndReset()} + onDelete={() => (deleteConfirmOpen = true)} + /> + +
{/snippet} + +
+
+ {#if tree.children.length > 0} + {#each tree.children as child} + {@render renderTreeNode(child, 0)} + {/each} + {:else} +
No matches
+ {/if} +
+ + {/if} +
+
+ {#if loading && diffs.length === 0} +
+ + Loading comparison... +
+ {:else if error} +
{error}
+ {:else if notice} +
{notice}
+ {:else if diffs.length === 0} +
{emptyMessage}
+ {:else if filteredDiffs.length === 0} +
No files match "{searchQuery}".
+ {:else} +
+ {#each filteredDiffs as d (itemKey(d))} + {@const key = itemKey(d)} + {@const status = d.status} + {@const StatusIcon = statusIcons[status]} + {@const loaded = loadedDiffs[key]} + {@const editUrl = editUrlFor?.(d)} +
onDetailsToggle(d, e)} + > + + + +
+ {#if editUrl} + + {d.path} + + {:else} +
+ {d.path} +
+ {/if} +
+
+ {#if d.ahead && d.ahead > 0} + {d.ahead} ahead + {/if} + {#if d.behind && d.behind > 0} + {d.behind} behind + {/if} + + + {status} + +
+
+
+ {#if !loaded || loaded.state === 'loading'} +
+ + Loading diff… +
+ {:else if loaded.state === 'error'} +
{loaded.error}
+ {:else if loaded.state === 'ready'} + + {/if} +
+
+ {/each} +
+ {/if} +
+ + + + + diff --git a/frontend/src/lib/rawAppDeploy.ts b/frontend/src/lib/rawAppDeploy.ts new file mode 100644 index 0000000000..0ed87a1b09 --- /dev/null +++ b/frontend/src/lib/rawAppDeploy.ts @@ -0,0 +1,122 @@ +/** + * Deploy a raw app (code-based app) from its server-side draft. Raw apps can't + * be deployed through the normal AppService.updateApp/createApp path: their + * source `files` must be bundled to js/css and saved via the raw-app endpoints. + * + * This mirrors how the global AI chat deploys raw apps + * (`copilot/chat/global/core.ts` → deployDraft, case 'app'): read the item with + * its draft, normalise to an AppDraftValue, recompute the policy, bundle the + * files, then createAppRaw/updateAppRaw. The two pure transforms + * (appSourceToDraftValue / normalizeRawAppData) are re-implemented here to avoid + * importing the heavy chat module. + */ +import { get } from 'svelte/store' +import { AppService } from '$lib/gen' +import type { Policy } from '$lib/gen' +import { userStore } from '$lib/stores' +import { bundleRawAppDraft } from '$lib/components/copilot/chat/global/rawAppBundlerBridge' +import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems' +import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' + +function normalizeRawAppData(value: Record): AppDraftValue['data'] { + if (value.data?.creation) { + return { + tables: value.data.tables ?? [], + datatable: value.data.creation.datatable, + schema: value.data.creation.schema + } + } + if (value.data) return value.data + if (value.datatables) return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables } + if (value.dataTableRefs) return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs } + return { ...DEFAULT_RAW_APP_DATA } +} + +function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { + const value = (app.value ?? {}) as Record + return { + summary: app.summary ?? '', + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: normalizeRawAppData(value), + policy: app.policy ?? fallback?.policy, + custom_path: app.custom_path ?? fallback?.custom_path + } +} + +/** + * Promote a raw app's draft to deployed. Throws on failure (caller wraps into a + * DeployResult). The matching draft row is deleted server-side by the raw-app + * create/update handler, like the other deploy paths. + */ +export async function deployRawAppDraft( + workspace: string, + path: string, + deploymentMessage?: string +): Promise { + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + const draft = (app as any).draft + // Honor a renamed draft path; the URL `path` below stays the existing item key. + const targetPath = draft?.path ?? path + const value = appSourceToDraftValue(draft ?? app, app) + + const policy = (await updateRawAppPolicy( + value.runnables as any, + value.policy as any + )) as NonNullable & Policy + if (!policy.execution_mode) { + policy.execution_mode = 'publisher' + } + + const bundle = await bundleRawAppDraft({ workspace, files: value.files }) + + const rawAppValue = { + files: value.files, + runnables: value.runnables, + data: value.data ?? { ...DEFAULT_RAW_APP_DATA } + } + const summary = value.summary ?? '' + + if (await AppService.existsApp({ workspace, path })) { + // custom_path changes require admin. Mirror RawAppEditorHeader's update path: + // admins send the draft's value (`''` to clear), non-admins send undefined so + // the backend ignores it and preserves the existing route — otherwise a + // non-admin deploying a draft for an app that has a custom route would hit + // RequireAdmin (the deployed custom_path is sent via the appSourceToDraftValue + // fallback even when unchanged). + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + await AppService.updateAppRaw({ + workspace, + path, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: isAdmin ? (value.custom_path ?? '') : undefined + }, + js: bundle.js, + css: bundle.css + } + }) + } else { + await AppService.createAppRaw({ + workspace, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: value.custom_path + }, + js: bundle.js, + css: bundle.css + } + }) + } +} diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts new file mode 100644 index 0000000000..8ce4624fc1 --- /dev/null +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -0,0 +1,219 @@ +/** + * Draft deploy/discard orchestration for the compare page's "draft" mode. + * + * Drafts only exist for scripts, flows and apps (the `draft_type` enum). A draft + * is the editor's serialized state stored in the `draft` table; deploying it is + * the same create/update call the editor makes on "Deploy", which auto-deletes + * the matching draft server-side (unless `skip_draft_deletion`) — so we never + * call `deleteDraft` after a successful deploy. The lock/dependency job runs + * async, exactly as in the editor. + * + * Discarding branches on `draft_only`: a `draft_only` item exists only as a + * draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`); + * a draft on an already-deployed item just deletes the draft row. + */ +import { get, writable } from 'svelte/store' +import { ScriptService, FlowService, AppService, DraftService } from '$lib/gen' +import type { DeployResult } from '$lib/utils_workspace_deploy' +import { deployRawAppDraft } from '$lib/rawAppDeploy' +import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' +import { userStore } from '$lib/stores' +import { deployTriggers, type Trigger } from '$lib/components/triggers/utils' + +export type DraftKind = 'script' | 'flow' | 'app' + +export interface DraftDiffValues { + deployed: unknown + draft: unknown +} + +// Empty-but-valid "deployed" shapes for draft_only items (which have never been +// deployed). Using a fully-empty `{}` breaks the flow graph diff (it needs +// `value.modules`) and leaves the drawer spinning — so each kind gets a minimal +// valid shape, making the whole draft show as "all new". +const EMPTY_DEPLOYED: Record unknown> = { + script: (draft) => ({ content: '', language: draft?.language, schema: {} }), + flow: () => ({ summary: '', value: { modules: [] }, schema: {} }), + app: () => ({ summary: '', value: {}, policy: {} }) +} + +/** + * Fetch the deployed value and the draft value for an item, for the DiffDrawer + * (`mode: 'simple'`, original = deployed, current = draft). For a `draft_only` + * item there is no real deployed value, so the deployed side is a minimal + * empty-but-valid shape and the draft shows as entirely new. DiffDrawer cleans + * both sides via `cleanValueProperties`, so raw objects are fine here. + */ +export async function getDraftDiffValues( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false +): Promise { + // A `draft_only` item can keep its content in the row itself with no separate + // draft-table row (e.g. a flow created via createFlow(draft_only: true), like + // `u/admin/new`). There `draft` is null, so the draft side must fall back to + // the row's own value — otherwise the diff "after" is empty and nothing shows. + if (kind === 'script') { + const r = (await ScriptService.getScriptByPathWithDraft({ workspace, path })) as any + const { draft, draft_created_at: _c, hash: _h, ...deployed } = r + const draftValue = draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.script(draftValue) : deployed, draft: draftValue } + } else if (kind === 'flow') { + const r = (await FlowService.getFlowByPathWithDraft({ workspace, path })) as any + const { draft, draft_created_at: _c, ...deployed } = r + const draftValue = draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.flow(draftValue) : deployed, draft: draftValue } + } else { + const r = (await AppService.getAppByPathWithDraft({ workspace, path })) as any + const deployed = { + summary: r.summary, + value: r.value, + policy: r.policy, + path: r.path, + custom_path: r.custom_path + } + const draftValue = r.draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.app(draftValue) : deployed, draft: draftValue } + } +} + +/** + * Deploy a script/flow draft's trigger changes the same way the editors do. + * Scripts and flows can carry `draft_triggers`; the create/update call below + * deletes the draft row, so without this the saved trigger edits would be + * silently lost. Uses the shared `deployTriggers` (a throwaway `usedTriggerKinds` + * store is fine — it only tracks kinds for the editor UI). `isNew` forces each + * trigger's `script_path` to the deployed path (matches the editors' new path). + */ +async function deployDraftTriggers( + draftTriggers: Trigger[] | undefined, + workspace: string, + path: string, + isNew: boolean +): Promise { + const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig) + if (triggers.length === 0) return + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + await deployTriggers(triggers, workspace, isAdmin, writable([]), path, isNew) +} + +/** + * Promote a draft to deployed by replaying the editor's create/update call with + * the stored draft value. The matching draft row is deleted server-side by the + * create/update handler. Returns the same `{ success, error? }` shape as the + * fork-merge `deployItem`, so callers can reuse the `deploymentStatus` pattern. + */ +export async function deployDraft( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false, + rawApp = false +): Promise { + try { + if (kind === 'app' && rawApp) { + // Raw apps bundle their source files to js/css and deploy via the + // raw-app endpoints — same as the global AI chat's deploy. + await deployRawAppDraft(workspace, path) + } else if (kind === 'script') { + const r = (await ScriptService.getScriptByPathWithDraft({ workspace, path })) as any + const d = r.draft ?? r + // Drop editor-only / server-managed keys; deploy as a real (non-draft) version. + const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d + const scriptPath = d.path ?? path + // Deploy at the draft's path so a rename in the draft is honored (same as + // the editor: createScript at the new path with parent_hash links lineage). + await ScriptService.createScript({ + workspace, + requestBody: { ...rest, path: scriptPath, parent_hash: r.hash } + }) + // Then deploy any draft trigger edits, so they aren't dropped with the draft. + await deployDraftTriggers(draftTriggers, workspace, scriptPath, true) + } else if (kind === 'flow') { + const r = (await FlowService.getFlowByPathWithDraft({ workspace, path })) as any + const d = r.draft ?? r + const requestBody = { + // Honor a renamed draft path; the URL `path` stays the existing item key. + path: d.path ?? path, + summary: d.summary ?? '', + description: d.description ?? '', + value: d.value, + schema: d.schema, + tag: d.tag, + dedicated_worker: d.dedicated_worker, + ws_error_handler_muted: d.ws_error_handler_muted, + visible_to_runner_only: d.visible_to_runner_only, + on_behalf_of_email: d.on_behalf_of_email, + labels: d.labels + } + // A draft (draft_only or on a deployed flow) always has a flow row, so + // updateFlow is correct in both cases — it promotes a draft_only flow to + // a real deployed version (clearing the flag). createFlow would 400 + // "Flow already exists". + await FlowService.updateFlow({ workspace, path, requestBody }) + // Then deploy any draft trigger edits, so they aren't dropped with the draft. + await deployDraftTriggers(d.draft_triggers, workspace, d.path ?? path, draftOnly) + } else { + const r = (await AppService.getAppByPathWithDraft({ workspace, path })) as any + const d = r.draft ?? { + value: r.value, + summary: r.summary, + policy: r.policy, + path: r.path, + custom_path: r.custom_path + } + // custom_path requires admin on app update. Non-admins send undefined so + // the backend preserves the existing route (no RequireAdmin 403). For + // admins, fall back to the *deployed* route (`r.custom_path`) when the + // draft doesn't carry one — the visual-app draft value usually omits + // custom_path, and sending `''` would clear the existing route. An + // explicit '' in the draft still clears (`'' ?? x === ''`). + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const requestBody = { + value: d.value, + summary: d.summary ?? '', + policy: d.policy, + path: d.path ?? path, + custom_path: isAdmin ? (d.custom_path ?? r.custom_path) : undefined + } + // Same as flows: a draft always has an app row, so updateApp promotes a + // draft_only app (clearing the flag); createApp would 400 "already exists". + await AppService.updateApp({ workspace, path, requestBody }) + } + // Mutated the workspace's Server Drafts — refresh every mounted reader. + invalidateWorkspaceDrafts(workspace) + return { success: true } + } catch (e: any) { + return { success: false, error: e?.body ?? e?.message ?? String(e) } + } +} + +/** + * Discard a draft. For `draft_only` items the item exists only as a draft, so + * delete the whole item; otherwise delete just the draft row. + */ +export async function discardDraft( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false +): Promise { + try { + if (draftOnly) { + if (kind === 'script') { + await ScriptService.deleteScriptByPath({ workspace, path }) + } else if (kind === 'flow') { + await FlowService.deleteFlowByPath({ workspace, path }) + } else { + await AppService.deleteApp({ workspace, path }) + } + } else { + await DraftService.deleteDraft({ workspace, path, kind }) + } + invalidateWorkspaceDrafts(workspace) + return { success: true } + } catch (e: any) { + return { success: false, error: e?.body ?? e?.message ?? String(e) } + } +} diff --git a/frontend/src/lib/workspaceDrafts.svelte.ts b/frontend/src/lib/workspaceDrafts.svelte.ts new file mode 100644 index 0000000000..475fbb9894 --- /dev/null +++ b/frontend/src/lib/workspaceDrafts.svelte.ts @@ -0,0 +1,143 @@ +/** + * Workspace Drafts — the single source of truth for "which Server Drafts exist + * in a workspace". Lists the deployable Draft Items once; the Draft Count is + * simply that list's length — never a separate query. This is what makes the + * count reliable: count ≡ list, by construction. + * + * Behind this seam the list is currently assembled from the three version-aware + * list endpoints (scripts/flows/apps with `include_draft_only`). A single + * `GET /w/{ws}/drafts/items` endpoint can replace `getDraftItems` later without + * touching any consumer. + * + * Reactivity: `useWorkspaceDrafts(() => ws)` is a component-scoped `runed` + * resource — it fetches on mount and when `ws` changes, and is disposed on + * unmount, so a re-opened view always shows a fresh count (no persistent cache + * to go stale). `invalidateWorkspaceDrafts(ws)` bumps a per-workspace version so + * every *mounted* consumer re-fetches after a Server-Draft mutation. + */ +import { resource } from 'runed' +import { ScriptService, FlowService, AppService } from '$lib/gen' + +export type DraftKind = 'script' | 'flow' | 'app' + +export interface DraftItem { + kind: DraftKind + path: string + summary?: string + /** Never deployed — exists only as a draft. */ + draft_only: boolean + /** App is a raw app (deploys via the raw-app endpoints). Always false for non-apps. */ + raw_app: boolean +} + +/** The one place the "is this a deployable Draft Item?" rule lives on the + * frontend: a pending draft on a deployed item (`has_draft`) OR a never-deployed + * `draft_only` item. Mirrors the backend `count_drafts` predicate. */ +/** The list-endpoint fields this module reads. Kept as a narrow local interface + * (rather than `any`) so the count predicate isn't typed against `any`. NOTE: + * `openapi.yaml`'s `ListableApp` still omits `has_draft`/`draft_only` (the backend + * struct returns them) — the proper fix is to add them to the spec and regenerate + * the client; until then this interface documents the contract relied on. */ +interface DraftListEntry { + path: string + summary?: string + has_draft?: boolean + draft_only?: boolean + raw_app?: boolean +} + +// The list endpoints are paginated; without paging, drafts past the first page +// would be silently missing from the count/list (and "Deploy all"). Page through +// with a generous page size until a short page signals the end. +const DRAFT_LIST_PER_PAGE = 100 + +async function listAllPages( + fetchPage: (page: number, perPage: number) => Promise +): Promise { + const all: DraftListEntry[] = [] + for (let page = 1; ; page++) { + const batch = await fetchPage(page, DRAFT_LIST_PER_PAGE) + all.push(...batch) + if (batch.length < DRAFT_LIST_PER_PAGE) break + } + return all +} + +export async function getDraftItems(workspace: string): Promise { + const [scripts, flows, apps] = await Promise.all([ + listAllPages((page, perPage) => + ScriptService.listScripts({ workspace, includeDraftOnly: true, page, perPage }) + ), + listAllPages((page, perPage) => + FlowService.listFlows({ workspace, includeDraftOnly: true, page, perPage }) + ), + listAllPages((page, perPage) => + AppService.listApps({ workspace, includeDraftOnly: true, page, perPage }) + ) + ]) + const items: DraftItem[] = [] + const push = (kind: DraftKind, list: DraftListEntry[]) => { + for (const it of list) { + if (it.has_draft || it.draft_only) { + items.push({ + kind, + path: it.path, + summary: it.summary, + draft_only: !!it.draft_only, + raw_app: !!it.raw_app + }) + } + } + } + push('script', scripts) + push('flow', flows) + push('app', apps) + items.sort((a, b) => a.path.localeCompare(b.path)) + return items +} + +// Per-workspace invalidation version. Bumping it changes the resource key for +// that workspace, so mounted consumers re-fetch. Plain $state record. +const versions: Record = $state({}) + +export function invalidateWorkspaceDrafts(workspace: string | undefined): void { + if (!workspace) return + versions[workspace] = (versions[workspace] ?? 0) + 1 +} + +export interface WorkspaceDraftsHandle { + readonly items: DraftItem[] + readonly count: number + readonly loading: boolean + /** Imperative re-fetch (e.g. right after a mutation in the same component). */ + refresh: () => void +} + +/** + * Reactive Workspace Drafts for the given workspace. Call at component init. + * Re-fetches on mount, when `workspace` changes, and when + * `invalidateWorkspaceDrafts(workspace)` is called while mounted. + */ +export function useWorkspaceDrafts(workspace: () => string | undefined): WorkspaceDraftsHandle { + const res = resource( + () => { + const ws = workspace() + return { ws, v: ws ? (versions[ws] ?? 0) : 0 } + }, + async ({ ws }) => (ws ? getDraftItems(ws) : []) + ) + return { + get items() { + return res.current ?? [] + }, + get count() { + return (res.current ?? []).length + }, + get loading() { + return res.loading + }, + refresh() { + void res.refetch() + } + } +} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index e15719694a..b070bd5fef 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -37,6 +37,7 @@ import { page } from '$app/state' import { goto, replaceState } from '$app/navigation' import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte' + import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' import { onMount, setContext } from 'svelte' import { tutorialsToDo } from '$lib/stores' @@ -278,6 +279,7 @@ style="scrollbar-gutter: stable both-edges;" > +
{#if $workspaceStore == 'admins'}
diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.js b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js index 712d677d68..2e9ca0e335 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.js +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js @@ -1,5 +1,5 @@ export function load() { return { - stuff: { title: 'Compare / Deploy to main workspace' } + stuff: { title: 'Compare & Deploy' } } } diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index a6be7b1044..7c32b6a6af 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -1,9 +1,11 @@ - - {#if isFork} -
+ +
+ + {#if isFork} -
- {/if} + {/if} +
- {#if currentWorkspaceId && parentWorkspaceId} - - {/if} {#if !currentWorkspaceId} No workspace selected - {:else if !parentWorkspaceId} + {:else if mode === 'draft'} + + {:else if parentWorkspaceId} + + {:else} workspace {currentWorkspaceId} has no parent workspace {/if}
From 5f41ddd3a592bcd504f94fc99060ca5d79c36190 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 00:26:19 +0200 Subject: [PATCH 31/32] fix: require auth to view approval details when user_auth_required (#9482) Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api/src/jobs.rs | 252 +++++++++++++++--- .../approve/[workspace]/[job]/+page.svelte | 97 ++++--- 2 files changed, 275 insertions(+), 74 deletions(-) diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index f8c4849e92..895dbed886 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -1236,6 +1236,59 @@ async fn require_job_update_read_access( require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await } +/// Whether a validated approval token should grant the job-read bypass. The token alone +/// is sufficient unless the current approval step has `user_auth_required`, in which case +/// only an authorized approver may read the job (and thus its args/flow inputs). +async fn approval_token_grants_view( + db: &DB, + w_id: &str, + flow_id: Uuid, + opt_authed: &Option, +) -> error::Result { + #[derive(sqlx::FromRow)] + struct FlowAuthRow { + script_path: Option, + email: String, + flow_status: Option, + } + let row = sqlx::query_as::<_, FlowAuthRow>( + "SELECT j.runnable_path as script_path, j.permissioned_as_email as email, s.flow_status + FROM v2_job j + LEFT JOIN v2_job_status s ON s.id = j.id + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(flow_id) + .bind(w_id) + .fetch_optional(db) + .await?; + let Some(row) = row else { + return Ok(false); + }; + + // approval_conditions are stored at the top of flow_status for both classic and WAC flows. + let approval_conditions = row + .flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions").cloned()) + .and_then(|v| serde_json::from_value::(v).ok()); + + let user_auth_required = approval_conditions + .as_ref() + .map(|ac| ac.user_auth_required) + .unwrap_or(false); + + if !user_auth_required { + return Ok(true); + } + + Ok(can_approve_step( + opt_authed, + &approval_conditions, + row.script_path.as_deref(), + row.email.as_str(), + )) +} + async fn get_job( OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, @@ -1254,17 +1307,31 @@ async fn get_job( // so the approval page can render job metadata without login. The approval // URL usually carries the flow id directly — try that first and only // resolve the parent flow if the direct check fails. - let has_valid_approval_token = if let Some(ref token) = approval_token { + let approved_flow_id: Option = if let Some(ref token) = approval_token { if validate_approval_token(&db, token, id, &w_id).await.is_ok() { - true + Some(id) } else if let Ok(flow_id) = get_flow_id_for_job(&db, id).await { - flow_id != id + if flow_id != id && validate_approval_token(&db, token, flow_id, &w_id) .await .is_ok() + { + Some(flow_id) + } else { + None + } } else { - false + None } + } else { + None + }; + + // A valid token grants the read bypass, but only to an authorized approver when the + // current approval step requires auth — otherwise the token (which lives in the shareable + // approval URL) would leak the flow inputs to anyone holding the link. + let has_valid_approval_token = if let Some(flow_id) = approved_flow_id { + approval_token_grants_view(&db, &w_id, flow_id, &opt_authed).await? } else { false }; @@ -3132,6 +3199,43 @@ struct ApprovalInfo { approvers: Vec, } +/// Whether `opt_authed` is allowed to approve — and therefore view — this approval step. +/// Mirrors the authorization performed at the resume boundary: workspace admins and owners +/// of the runnable always qualify; otherwise the approval conditions (user_auth_required / +/// user_groups_required / self_approval_disabled) decide. When the step does not require auth, +/// an anonymous (token-only) caller qualifies. +fn can_approve_step( + opt_authed: &Option, + approval_conditions: &Option, + script_path: Option<&str>, + trigger_email: &str, +) -> bool { + match opt_authed { + Some(authed) => { + if authed.is_admin { + return true; + } + let is_owner = script_path + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + if is_owner { + return true; + } + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + ) + .is_ok() + } + // Not logged in — only acceptable when the step does not require auth. + None => !approval_conditions + .as_ref() + .map(|ac| ac.user_auth_required) + .unwrap_or(false), + } +} + async fn get_approval_info( OptAuthed(opt_authed): OptAuthed, Extension(db): Extension, @@ -3290,32 +3394,33 @@ async fn get_approval_info( .map(|ac| ac.user_auth_required) .unwrap_or(false); - // Determine if current user can approve - let can_approve = if let Some(ref authed) = opt_authed { - if authed.is_admin { - true - } else { - let is_owner = row - .script_path - .as_deref() - .map(|p| require_owner_of_path(authed, p).is_ok()) - .unwrap_or(false); - if is_owner { - true - } else { - let trigger_email = row.email.as_str(); - conditionally_require_authed_user( - Some(authed.clone()), - approval_conditions.clone(), - trigger_email, - ) - .is_ok() - } - } - } else { - // Not logged in — can approve only if no auth required - !user_auth_required - }; + // Determine if current user can approve this step. + let can_approve = can_approve_step( + &opt_authed, + &approval_conditions, + row.script_path.as_deref(), + row.email.as_str(), + ); + + // When the step requires auth, the approval details must be revealed only to authorized + // approvers — a valid token alone is not sufficient. Return a stripped response that lets + // the frontend render the sign-in / not-authorized state without leaking the form, + // description, prefilled args, or other approvers' identities. + let can_view = !user_auth_required || can_approve; + if !can_view { + return Ok(Json(ApprovalInfo { + flow_id: row.id, + form_schema: None, + description: None, + default_args: None, + enums: None, + approval_conditions, + can_approve: false, + user_auth_required, + hide_cancel: None, + approvers: vec![], + })); + } // Get existing approvers let approvers: Vec = sqlx::query_as::<_, (i32, Option)>( @@ -9103,3 +9208,90 @@ async fn get_otel_traces( Ok(Json(traces)) } + +#[cfg(test)] +mod approval_view_gate_tests { + use super::*; + + fn authed(username: &str, is_admin: bool, groups: Vec) -> ApiAuthed { + ApiAuthed { + email: format!("{username}@example.com"), + username: username.to_string(), + is_admin, + is_operator: false, + groups, + folders: vec![], + scopes: None, + username_override: None, + token_prefix: None, + read_only: false, + } + } + + fn conds(user_auth_required: bool, groups: Vec) -> ApprovalConditions { + ApprovalConditions { + user_auth_required, + user_groups_required: groups, + self_approval_disabled: false, + } + } + + // Mirrors the view gate applied in get_approval_info and get_job: + // details are revealed only when the step doesn't require auth OR the caller + // is an authorized approver. + fn can_view( + opt_authed: &Option, + approval_conditions: &Option, + script_path: Option<&str>, + trigger_email: &str, + ) -> bool { + let user_auth_required = approval_conditions + .as_ref() + .map(|c| c.user_auth_required) + .unwrap_or(false); + !user_auth_required + || can_approve_step(opt_authed, approval_conditions, script_path, trigger_email) + } + + #[test] + fn anonymous_cannot_view_when_auth_required() { + // The regression: an unauthenticated holder of the approval token must see nothing. + let c = Some(conds(true, vec![])); + assert!(!can_view(&None, &c, Some("f/team/flow"), "trigger@example.com")); + } + + #[test] + fn anonymous_can_view_when_no_auth_required() { + // Unchanged behaviour: token alone is sufficient when auth isn't required. + let c = Some(conds(false, vec![])); + assert!(can_view(&None, &c, Some("f/team/flow"), "trigger@example.com")); + // No approval conditions at all also allows token-only view. + assert!(can_view(&None, &None, Some("f/team/flow"), "trigger@example.com")); + } + + #[test] + fn admin_can_view_when_auth_required() { + let c = Some(conds(true, vec!["approvers".to_string()])); + let a = Some(authed("alice", true, vec![])); + assert!(can_view(&a, &c, Some("f/team/flow"), "trigger@example.com")); + } + + #[test] + fn owner_can_view_when_auth_required() { + let c = Some(conds(true, vec!["approvers".to_string()])); + let a = Some(authed("bob", false, vec![])); + // bob owns u/bob/flow regardless of group membership. + assert!(can_view(&a, &c, Some("u/bob/flow"), "trigger@example.com")); + } + + #[cfg(feature = "enterprise")] + #[test] + fn group_membership_decides_view_when_auth_required() { + let c = Some(conds(true, vec!["approvers".to_string()])); + let member = Some(authed("carol", false, vec!["approvers".to_string()])); + let outsider = Some(authed("dave", false, vec!["other".to_string()])); + // Use a non-owned folder path so ownership doesn't short-circuit the check. + assert!(can_view(&member, &c, Some("f/team/flow"), "trigger@example.com")); + assert!(!can_view(&outsider, &c, Some("f/team/flow"), "trigger@example.com")); + } +} diff --git a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte index 9ecdcaebb5..9f07bc8d47 100644 --- a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte +++ b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte @@ -142,6 +142,10 @@ } } + // When the step requires auth and the caller isn't an authorized approver, the backend + // strips the approval details (form, description, args, approvers) and getJob is denied. + // Hide the empty detail scaffolding and show only the sign-in / not-authorized state. + let isLocked = $derived(!!approvalInfo?.user_auth_required && !approvalInfo?.can_approve) let isWac = $derived(!!(job as any)?.workflow_as_code_status) let filteredArgs = $derived.by(() => { if (!job?.args) return job?.args @@ -219,44 +223,47 @@ {/if}
{:else if approvalInfo} -
-
-

Approvers

-
- {#if approvalInfo.approvers?.length > 0} -
    - {#each approvalInfo.approvers as a} -
  • -

    - {a.approver} - Unique id of approval: {a.resume_id} -

    -
  • - {/each} -
- {:else} -

- No current approvers for this step (approval steps can require more than one approval) -

+ {#if !isLocked} +
+
+

Approvers

+
+ {#if approvalInfo.approvers?.length > 0} +
    + {#each approvalInfo.approvers as a} +
  • +

    + {a.approver} + Unique id of approval: {a.resume_id} +

    +
  • + {/each} +
+ {:else} +

+ No current approvers for this step (approval steps can require more than one + approval) +

+ {/if} +
+
+
+ {#if job && job.raw_flow} + {/if}
-
- {#if job && job.raw_flow} - - {/if} -
-
- {#if !completed} -

- {isWac ? 'Workflow' : 'Flow'} arguments -

- + {#if !completed} +

+ {isWac ? 'Workflow' : 'Flow'} arguments +

+ + {/if} {/if}
@@ -337,16 +344,18 @@ {/if}
- + {#if !isLocked} + + {/if} {#if job && job.raw_flow && !completed}

Flow details

From 92c21bbe6586f3c285796a98692e976515a629d5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 00:45:18 +0200 Subject: [PATCH 32/32] fix: drop archived items from fork compare (spurious 'not visible' warning) (#9481) Co-authored-by: Claude Opus 4.8 (1M context) --- ...9e89cc4a702158899f7fdc66d0922e8fb9b29.json | 23 ++++ ...839f316fe5412f99f3d3bbbdc0c65f55ab794.json | 20 ++++ ...1267af8e42a3b6aa382f1dd483bec7219c67c.json | 12 ++ ...34db7b001d740d524075f19b91bae6fdb41b9.json | 23 ++++ ...0c3a44de6e32651a9a71c1aef49da2696a04f.json | 12 ++ .../tests/workspace_comparison.rs | 63 +++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 104 +++++++++++++++++- 7 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 backend/.sqlx/query-0a568f630e069118fe302099a709e89cc4a702158899f7fdc66d0922e8fb9b29.json create mode 100644 backend/.sqlx/query-4c81384b579bad74b64c72ca053839f316fe5412f99f3d3bbbdc0c65f55ab794.json create mode 100644 backend/.sqlx/query-a60306f2bae0702363787c4cf7c1267af8e42a3b6aa382f1dd483bec7219c67c.json create mode 100644 backend/.sqlx/query-c4966cf071a8504f578eed5518134db7b001d740d524075f19b91bae6fdb41b9.json create mode 100644 backend/.sqlx/query-e9c2e8c50fc45576453885340800c3a44de6e32651a9a71c1aef49da2696a04f.json diff --git a/backend/.sqlx/query-0a568f630e069118fe302099a709e89cc4a702158899f7fdc66d0922e8fb9b29.json b/backend/.sqlx/query-0a568f630e069118fe302099a709e89cc4a702158899f7fdc66d0922e8fb9b29.json new file mode 100644 index 0000000000..c407574e15 --- /dev/null +++ b/backend/.sqlx/query-0a568f630e069118fe302099a709e89cc4a702158899f7fdc66d0922e8fb9b29.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0a568f630e069118fe302099a709e89cc4a702158899f7fdc66d0922e8fb9b29" +} diff --git a/backend/.sqlx/query-4c81384b579bad74b64c72ca053839f316fe5412f99f3d3bbbdc0c65f55ab794.json b/backend/.sqlx/query-4c81384b579bad74b64c72ca053839f316fe5412f99f3d3bbbdc0c65f55ab794.json new file mode 100644 index 0000000000..9d8e1e15cc --- /dev/null +++ b/backend/.sqlx/query-4c81384b579bad74b64c72ca053839f316fe5412f99f3d3bbbdc0c65f55ab794.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT has_changes FROM workspace_diff\n WHERE path = 'f/shared/renamed_away' AND kind = 'script' AND source_workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_changes", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true + ] + }, + "hash": "4c81384b579bad74b64c72ca053839f316fe5412f99f3d3bbbdc0c65f55ab794" +} diff --git a/backend/.sqlx/query-a60306f2bae0702363787c4cf7c1267af8e42a3b6aa382f1dd483bec7219c67c.json b/backend/.sqlx/query-a60306f2bae0702363787c4cf7c1267af8e42a3b6aa382f1dd483bec7219c67c.json new file mode 100644 index 0000000000..c726a6df9c --- /dev/null +++ b/backend/.sqlx/query-a60306f2bae0702363787c4cf7c1267af8e42a3b6aa382f1dd483bec7219c67c.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted)\n VALUES ('wm-fork-test-workspace', 'f/shared/renamed_away', 67890, 'def main(): return 1', '', '', 'python3', 'test@windmill.dev', NOW(), true, false, false, false)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a60306f2bae0702363787c4cf7c1267af8e42a3b6aa382f1dd483bec7219c67c" +} diff --git a/backend/.sqlx/query-c4966cf071a8504f578eed5518134db7b001d740d524075f19b91bae6fdb41b9.json b/backend/.sqlx/query-c4966cf071a8504f578eed5518134db7b001d740d524075f19b91bae6fdb41b9.json new file mode 100644 index 0000000000..2a720785aa --- /dev/null +++ b/backend/.sqlx/query-c4966cf071a8504f578eed5518134db7b001d740d524075f19b91bae6fdb41b9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c4966cf071a8504f578eed5518134db7b001d740d524075f19b91bae6fdb41b9" +} diff --git a/backend/.sqlx/query-e9c2e8c50fc45576453885340800c3a44de6e32651a9a71c1aef49da2696a04f.json b/backend/.sqlx/query-e9c2e8c50fc45576453885340800c3a44de6e32651a9a71c1aef49da2696a04f.json new file mode 100644 index 0000000000..738c6a473f --- /dev/null +++ b/backend/.sqlx/query-e9c2e8c50fc45576453885340800c3a44de6e32651a9a71c1aef49da2696a04f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)\n VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/renamed_away', 'script', 1, 0, true, false, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "e9c2e8c50fc45576453885340800c3a44de6e32651a9a71c1aef49da2696a04f" +} diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index dccc4048e4..82447daee2 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -634,6 +634,69 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re "Non-existent item should be deleted from workspace_diff" ); + // ============================================================== + // Stale Archived Cache Test (regression) + // ============================================================== + // + // Unlike the lazy_test above (has_changes = NULL → always re-evaluated), a + // cached `has_changes = true` row is trusted without re-running the per-kind + // comparison. It can go stale: after a rename the old path keeps only + // archived versions, and for lock-gen languages the `has_changes = NULL` + // reset is deferred to the dependency job — so until that runs the archived + // old path lingers as a live "ahead" change carrying `exists_in_fork = true`. + // The visibility check treats archived as non-existent and finds nothing, so + // even this superadmin used to get `all_ahead_items_visible = false`. The fix + // re-validates such rows and drops the archived (== non-existent) item. + sqlx::query!( + "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted) + VALUES ('wm-fork-test-workspace', 'f/shared/renamed_away', 67890, 'def main(): return 1', '', '', 'python3', 'test@windmill.dev', NOW(), true, false, false, false)" + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork) + VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/renamed_away', 'script', 1, 0, true, false, true)" + ) + .execute(&db) + .await?; + + let comparison3: serde_json::Value = client + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace" + )) + .send() + .await? + .json() + .await?; + + // The archived item must be dropped (not surfaced) and must not trip the + // "changes not visible to your user" warning for a superadmin. + assert_eq!( + comparison3["all_ahead_items_visible"].as_bool(), + Some(true), + "archived (renamed-away) item must not trip the 'changes not visible' warning: {comparison3}" + ); + assert!( + !comparison3["diffs"] + .as_array() + .unwrap() + .iter() + .any(|d| d["path"] == "f/shared/renamed_away"), + "archived item should be dropped, not surfaced as a diff: {comparison3}" + ); + let stale_archived = sqlx::query!( + "SELECT has_changes FROM workspace_diff + WHERE path = 'f/shared/renamed_away' AND kind = 'script' AND source_workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert!( + stale_archived.is_none(), + "stale archived diff row should be re-evaluated and deleted" + ); + Ok(()) } diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 00cebc32ff..27ce0a5592 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6290,13 +6290,67 @@ async fn compare_workspaces( .fetch_all(&db) .await?; + // A cached `has_changes = true` row is trusted without re-running the + // per-kind comparison, but that verdict can go stale: an item archived or + // deleted after it was cached still carries `exists_in_*=true` here. The + // common offender is the old path after a rename — for lock-gen languages + // (Python/TS/…) the `has_changes=NULL` reset is deferred to the dependency + // job, so until that runs (or if it fails) the archived old path looks like + // a live ahead change. Treat archived as non-existent: re-validate such rows + // against the live tables and, if the item no longer exists on a side the + // cache claims, re-evaluate it below so it gets corrected or removed. + // + // Only scripts/flows can hit this (they have `archived`; other kinds reset + // synchronously on delete). Probe both sides in one batched query per kind + // (mirroring `query_visible_items`) rather than per row, to keep the hot + // compare path off an O(number of cached diffs) sequence of round trips. + let (live_source, live_fork) = { + let mut cached_source: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut cached_fork: HashMap<&str, Vec<&str>> = HashMap::new(); + for item in &diff_items { + if item.has_changes == Some(true) && (item.kind == "script" || item.kind == "flow") { + if item.exists_in_source.unwrap_or(false) { + cached_source + .entry(item.kind.as_str()) + .or_default() + .push(item.path.as_str()); + } + if item.exists_in_fork.unwrap_or(false) { + cached_fork + .entry(item.kind.as_str()) + .or_default() + .push(item.path.as_str()); + } + } + } + ( + existing_runnables(&db, &source_workspace_id, &cached_source).await?, + existing_runnables(&db, &fork_workspace_id, &cached_fork).await?, + ) + }; + let mut confirmed_diffs = vec![]; for item in diff_items { if let Some(has_changes) = item.has_changes { - if has_changes { - confirmed_diffs.push(item); + if !has_changes { + // Defensive: rows that compared equal are normally deleted, so + // this is rarely hit. Not a diff — skip. + continue; } - continue; + // Stale only applies to script/flow (others aren't in the probed + // sets); a row whose claimed-existing side has no live version is + // stale and falls through to re-evaluation. + let key = (item.kind.clone(), item.path.clone()); + let fork_stale = item.exists_in_fork.unwrap_or(false) && !live_fork.contains(&key); + let source_stale = + item.exists_in_source.unwrap_or(false) && !live_source.contains(&key); + let probed = item.kind == "script" || item.kind == "flow"; + if !(probed && (fork_stale || source_stale)) { + // Cache is still valid (or not a probed kind) — trust it. + confirmed_diffs.push(item); + continue; + } + // Stale cache: fall through to re-evaluate (and correct/delete) below. } let item_comparison = match item.kind.as_str() { @@ -6721,6 +6775,50 @@ async fn query_visible_items<'c>( Ok(visible) } +/// Batched existence probe used to detect stale `workspace_diff` cache rows. +/// +/// Given candidate paths grouped by kind, returns the set of `(kind, path)` +/// that currently have a *deployable* (non-archived) version in the workspace, +/// mirroring the existence semantics of `compare_two_scripts` / +/// `compare_two_flows`. Only scripts and flows are probed — they're the only +/// kinds with `archived`, and the only ones whose diff-row reset can lag behind +/// the actual change (deferred dependency job for lock-gen languages); other +/// kinds reset synchronously on delete, so their cache is trusted (and they're +/// never passed in). One query per kind keeps the compare path off a per-row +/// sequence of round trips. Runs on `&db` (no RLS) — this is a pure existence +/// check; authorization stays in `filter_visible_diffs` / `query_visible_items`. +async fn existing_runnables( + db: &DB, + workspace_id: &str, + items_by_kind: &HashMap<&str, Vec<&str>>, +) -> Result> { + let mut existing = HashSet::new(); + for (kind, paths) in items_by_kind { + let paths_vec: Vec = paths.iter().map(|s| s.to_string()).collect(); + let found: Vec = match *kind { + "script" => sqlx::query_scalar!( + "SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(db) + .await?, + "flow" => sqlx::query_scalar!( + "SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(db) + .await?, + _ => vec![], + }; + for path in found { + existing.insert((kind.to_string(), path)); + } + } + Ok(existing) +} + #[derive(Debug)] struct ItemComparison { has_changes: bool,