From 4d3ff0299fed656c0e3a498d463375047de47d39 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 26 Jul 2026 09:36:27 +0200 Subject: [PATCH 001/400] feat: mark failed jobs as resolved so handled failures stop showing red (#10319) * feat: mark failed jobs as resolved so handled failures stop showing red Co-Authored-By: Claude Opus 5 (1M context) * fix: constrain auto-resolve to the proven retry chain and honor resolved filter everywhere Co-Authored-By: Claude Opus 5 (1M context) * fix: apply resolved filter to queue-union, concurrency and delete paths, bound note Co-Authored-By: Claude Opus 5 (1M context) * fix: sweep resolutions on workspace delete, verify helper args, enforce UI limits Co-Authored-By: Claude Opus 5 (1M context) * fix: count resolution note in characters on both sides of the API Co-Authored-By: Claude Opus 5 (1M context) * fix: skip the queue lookup for cancel-all under the resolved-only filter Co-Authored-By: Claude Opus 5 (1M context) * fix: converge retry auto-resolution from either commit order, keep notes on re-resolve Co-Authored-By: Claude Opus 5 (1M context) * docs: correct the idempotency claim on the retry auto-resolve sweep Co-Authored-By: Claude Opus 5 (1M context) * feat: gate resolution notes and attribution behind enterprise, add note popover Co-Authored-By: Claude Opus 5 (1M context) * fix: hide resolution from operators, exclude flow steps, enforce EE licence at runtime Co-Authored-By: Claude Opus 5 (1M context) * docs: add job_resolution.automatic to the summarized schema Co-Authored-By: Claude Opus 5 (1M context) * fix: preserve stored attribution when re-resolving without a valid licence Co-Authored-By: Claude Opus 5 (1M context) * docs: condense the attribution-preservation comment to four lines Co-Authored-By: Claude Opus 5 (1M context) * fix: validate resolution notes by code point instead of a UTF-16 maxlength Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the resolution popover open when a note is rejected Co-Authored-By: Claude Opus 5 (1M context) * feat: offer to resolve the original failure after a successful re-run Co-Authored-By: Claude Opus 5 (1M context) * fix: verify supersession server-side and stop re-runs overwriting notes Co-Authored-By: Claude Opus 5 (1M context) * fix: apply tag scope to the superseding run Co-Authored-By: Claude Opus 5 (1M context) * fix: exclude obscured cross-workspace runs from resolution actions Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ...d98e0e8f42e9e4fd70f00ee5f120fc3b04ed9.json | 15 + ...b3e707644fa3b9833328e75cf599df11e22b2.json | 12 + ...ec0859463b1c153950f894a830a4b5ec33fcc.json | 28 ++ ...46409057a16a2723916d973eac5c70c2dc2c7.json | 24 ++ ...b01013e27017765b3fdade4322ebdf1fe1408.json | 14 + ...c4465bc94bf7845f1d38f6d0fd20f1154ff9e.json | 14 + ...cfb9b0772d5d3d239273fc5cea322cae4e258.json | 16 + .../20260724221457_job_resolution.down.sql | 1 + .../20260724221457_job_resolution.up.sql | 26 ++ backend/src/monitor.rs | 11 + backend/summarized_schema.txt | 1 + .../tests/jobs_authed.rs | 384 ++++++++++++++++++ .../src/concurrency_groups.rs | 7 +- backend/windmill-api-jobs/src/jobs_export.rs | 12 + backend/windmill-api-jobs/src/query.rs | 36 ++ backend/windmill-api-jobs/src/types.rs | 15 + .../src/workspaces_extra.rs | 1 + backend/windmill-api/openapi.yaml | 133 ++++++ backend/windmill-api/src/jobs.rs | 229 ++++++++++- backend/windmill-common/src/jobs.rs | 3 + backend/windmill-queue/src/jobs.rs | 115 +++++- .../windmill-queue/tests/native_retry_test.rs | 153 +++++++ backend/windmill-types/src/jobs.rs | 24 ++ frontend/src/lib/components/JobStatus.svelte | 17 + frontend/src/lib/components/RunsPage.svelte | 131 +++++- .../components/runs/JobDetailHeader.svelte | 126 +++++- .../lib/components/runs/JobRunsPreview.svelte | 4 +- .../lib/components/runs/JobStatusIcon.svelte | 10 + .../src/lib/components/runs/RunRow.svelte | 5 +- .../src/lib/components/runs/RunsTable.svelte | 46 ++- .../components/runs/rerunResolution.svelte.ts | 64 +++ .../components/runs/rerunResolution.test.ts | 29 ++ .../src/lib/components/runs/runsFilter.ts | 15 +- .../components/runs/useJobsLoader.svelte.ts | 13 + frontend/src/lib/utils.test.ts | 25 ++ frontend/src/lib/utils.ts | 16 +- .../(root)/(logged)/run/[...run]/+page.svelte | 28 ++ 37 files changed, 1777 insertions(+), 26 deletions(-) create mode 100644 backend/.sqlx/query-07bcd445061f34a5d370398ee56d98e0e8f42e9e4fd70f00ee5f120fc3b04ed9.json create mode 100644 backend/.sqlx/query-0dafd0882f28604872a9187a5a9b3e707644fa3b9833328e75cf599df11e22b2.json create mode 100644 backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json create mode 100644 backend/.sqlx/query-41468bb803b63c190728938616a46409057a16a2723916d973eac5c70c2dc2c7.json create mode 100644 backend/.sqlx/query-58bef783a5d85eb114293bd0e43b01013e27017765b3fdade4322ebdf1fe1408.json create mode 100644 backend/.sqlx/query-c65ce3580a6648a7168c0003d9ec4465bc94bf7845f1d38f6d0fd20f1154ff9e.json create mode 100644 backend/.sqlx/query-e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258.json create mode 100644 backend/migrations/20260724221457_job_resolution.down.sql create mode 100644 backend/migrations/20260724221457_job_resolution.up.sql create mode 100644 frontend/src/lib/components/runs/rerunResolution.svelte.ts create mode 100644 frontend/src/lib/components/runs/rerunResolution.test.ts diff --git a/backend/.sqlx/query-07bcd445061f34a5d370398ee56d98e0e8f42e9e4fd70f00ee5f120fc3b04ed9.json b/backend/.sqlx/query-07bcd445061f34a5d370398ee56d98e0e8f42e9e4fd70f00ee5f120fc3b04ed9.json new file mode 100644 index 0000000000..7bfbd5aae3 --- /dev/null +++ b/backend/.sqlx/query-07bcd445061f34a5d370398ee56d98e0e8f42e9e4fd70f00ee5f120fc3b04ed9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM job_resolution WHERE workspace_id = $1 AND job_id = ANY($2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "07bcd445061f34a5d370398ee56d98e0e8f42e9e4fd70f00ee5f120fc3b04ed9" +} diff --git a/backend/.sqlx/query-0dafd0882f28604872a9187a5a9b3e707644fa3b9833328e75cf599df11e22b2.json b/backend/.sqlx/query-0dafd0882f28604872a9187a5a9b3e707644fa3b9833328e75cf599df11e22b2.json new file mode 100644 index 0000000000..8c21761ab0 --- /dev/null +++ b/backend/.sqlx/query-0dafd0882f28604872a9187a5a9b3e707644fa3b9833328e75cf599df11e22b2.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM job_resolution jr WHERE NOT EXISTS (SELECT 1 FROM v2_job WHERE id = jr.job_id)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "0dafd0882f28604872a9187a5a9b3e707644fa3b9833328e75cf599df11e22b2" +} diff --git a/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json b/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json new file mode 100644 index 0000000000..9cdddc5121 --- /dev/null +++ b/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n AND c.status = 'failure'\n -- Resolution is a top-level triage state: a step resolved on its own\n -- would render orange inside a flow whose status is still red.\n AND j.flow_step_id IS NULL\n -- A supersession claim has to be proven, not trusted: a later success of the\n -- same identified runnable, itself visible to the caller. An unproven claim\n -- resolves nothing, so the caller learns it was rejected instead of having\n -- the fiction recorded as provenance.\n AND ($6::UUID IS NULL OR EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n WHERE sc.id = $6\n AND sc.workspace_id = $2\n -- Tag scope is a read restriction enforced outside RLS, so it has\n -- to bind the evidence as well: otherwise the result reveals\n -- whether an out-of-scope run succeeded.\n AND ($3::TEXT[] IS NULL OR sj.tag = ANY($3))\n AND sc.status = 'success'\n AND sc.completed_at >= c.completed_at\n AND (j.runnable_id IS NOT NULL OR j.runnable_path IS NOT NULL)\n AND sj.runnable_id IS NOT DISTINCT FROM j.runnable_id\n AND sj.runnable_path IS NOT DISTINCT FROM j.runnable_path\n ))\n ON CONFLICT (job_id) DO UPDATE SET\n resolved_at = now(),\n -- Both COALESCEd: `resolution_attribution` returns NULLs outside EE and once the\n -- licence lapses, and bulk selections routinely include already-resolved rows,\n -- so overwriting would erase metadata recorded while it was valid. Clear either\n -- by unresolving first.\n resolved_by = COALESCE($4, job_resolution.resolved_by),\n -- A person's explanation replaces what was there; machine provenance only fills\n -- a blank, so re-running an already-explained failure never erases their words.\n note = COALESCE($5, job_resolution.note, $7),\n -- A human taking over an automatic resolution makes it no longer automatic.\n automatic = false\n RETURNING job_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text", + "TextArray", + "Varchar", + "Text", + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc" +} diff --git a/backend/.sqlx/query-41468bb803b63c190728938616a46409057a16a2723916d973eac5c70c2dc2c7.json b/backend/.sqlx/query-41468bb803b63c190728938616a46409057a16a2723916d973eac5c70c2dc2c7.json new file mode 100644 index 0000000000..a8ddb02aea --- /dev/null +++ b/backend/.sqlx/query-41468bb803b63c190728938616a46409057a16a2723916d973eac5c70c2dc2c7.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM job_resolution r\n USING v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE r.job_id = c.id\n AND c.id = ANY($1)\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n RETURNING r.job_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "41468bb803b63c190728938616a46409057a16a2723916d973eac5c70c2dc2c7" +} diff --git a/backend/.sqlx/query-58bef783a5d85eb114293bd0e43b01013e27017765b3fdade4322ebdf1fe1408.json b/backend/.sqlx/query-58bef783a5d85eb114293bd0e43b01013e27017765b3fdade4322ebdf1fe1408.json new file mode 100644 index 0000000000..a18f3730e6 --- /dev/null +++ b/backend/.sqlx/query-58bef783a5d85eb114293bd0e43b01013e27017765b3fdade4322ebdf1fe1408.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM job_resolution WHERE job_id = ANY($1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "58bef783a5d85eb114293bd0e43b01013e27017765b3fdade4322ebdf1fe1408" +} diff --git a/backend/.sqlx/query-c65ce3580a6648a7168c0003d9ec4465bc94bf7845f1d38f6d0fd20f1154ff9e.json b/backend/.sqlx/query-c65ce3580a6648a7168c0003d9ec4465bc94bf7845f1d38f6d0fd20f1154ff9e.json new file mode 100644 index 0000000000..d8cb1aff4b --- /dev/null +++ b/backend/.sqlx/query-c65ce3580a6648a7168c0003d9ec4465bc94bf7845f1d38f6d0fd20f1154ff9e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH ids AS (SELECT id FROM v2_job WHERE workspace_id = $1),\n _de AS (DELETE FROM dispatch_event WHERE workspace_id = $1),\n _jr AS (DELETE FROM job_resolution WHERE workspace_id = $1),\n _fc AS (DELETE FROM flow_conversation_message WHERE job_id IN (SELECT id FROM ids))\n DELETE FROM zombie_job_counter WHERE job_id IN (SELECT id FROM ids)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "c65ce3580a6648a7168c0003d9ec4465bc94bf7845f1d38f6d0fd20f1154ff9e" +} diff --git a/backend/.sqlx/query-e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258.json b/backend/.sqlx/query-e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258.json new file mode 100644 index 0000000000..4ebad828df --- /dev/null +++ b/backend/.sqlx/query-e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, NULL, NULL, true\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.status = 'failure'\n AND c.workspace_id = $2\n AND j.flow_step_id IS NULL\n AND j.runnable_id IS NOT DISTINCT FROM $3\n AND EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n JOIN native_retry_attempt nra ON nra.job_id = sc.id\n WHERE sc.status = 'success'\n AND sc.workspace_id = $2\n AND sj.parent_job = $1\n AND sj.runnable_id IS NOT DISTINCT FROM $3\n )\n AND (\n (j.parent_job = $1\n AND EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n OR (c.id = $1 AND j.parent_job IS NULL\n AND NOT EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n )\n ON CONFLICT (job_id) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258" +} diff --git a/backend/migrations/20260724221457_job_resolution.down.sql b/backend/migrations/20260724221457_job_resolution.down.sql new file mode 100644 index 0000000000..2288634ec3 --- /dev/null +++ b/backend/migrations/20260724221457_job_resolution.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS job_resolution; diff --git a/backend/migrations/20260724221457_job_resolution.up.sql b/backend/migrations/20260724221457_job_resolution.up.sql new file mode 100644 index 0000000000..2c87a0e7c4 --- /dev/null +++ b/backend/migrations/20260724221457_job_resolution.up.sql @@ -0,0 +1,26 @@ +-- Sparse annotation: one row per failed job whose failure has been handled, either +-- because a later attempt succeeded or because someone explained it. Resolution is +-- orthogonal to `v2_job_completed.status`, which stays 'failure' so failure rates, +-- error handlers and critical alerts keep counting the true failure; only the human +-- triage surfaces (runs list, run detail) render a resolved failure differently. +-- Sparse: only failures that someone or something resolved produce rows. +-- Lifecycle: removed with its job by delete_jobs (no FK, to keep the bulk job delete +-- cheap), with an orphan sweep in the monitor as a backstop. +CREATE TABLE IF NOT EXISTS job_resolution ( + job_id UUID PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL, + resolved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Who and why are enterprise-only, so both are NULL on a manual resolution in CE. + -- That is why "resolved automatically" is `automatic`, not `resolved_by IS NULL`: + -- overloading attribution would make every CE resolution look automatic. + resolved_by VARCHAR(255), + note TEXT, + automatic BOOLEAN NOT NULL DEFAULT false +); + +ALTER TABLE job_resolution ADD COLUMN IF NOT EXISTS automatic BOOLEAN NOT NULL DEFAULT false; + +-- windmill_user needs write access: the resolve endpoint runs under user_db so that +-- v2_job's row-level security decides which runs the caller may annotate. +GRANT ALL ON job_resolution TO windmill_admin; +GRANT ALL ON job_resolution TO windmill_user; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index adf859d139..ccff5c2c7b 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -1396,6 +1396,17 @@ pub async fn delete_expired_items(db: &DB) -> () { tracing::error!("Error reaping orphaned native retry markers: {:?}", e); } + // Same story for job_resolution: no FK, so a job deleted outside delete_jobs + // would leave its resolution behind. + if let Err(e) = sqlx::query!( + "DELETE FROM job_resolution jr WHERE NOT EXISTS (SELECT 1 FROM v2_job WHERE id = jr.job_id)" + ) + .execute(db) + .await + { + tracing::error!("Error reaping orphaned job resolutions: {:?}", e); + } + if let Err(e) = windmill_queue::cascade::reap_stale_join_slots(db).await { tracing::error!("Error reaping stale join_pending_inputs slots: {:?}", e); } diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index e8bf76d763..aeffbc78a1 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -106,6 +106,7 @@ input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_t instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char) job_logs: job_id(uuid), workspace_id(char), created_at(ts), logs(text), log_offset(int), log_file_index(text[]) job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char) +job_resolution: job_id(uuid), workspace_id(char), resolved_at(ts), resolved_by(char), note(text), automatic(bool) job_result_stream: job_id(uuid), workspace_id(text), stream(text) job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) job_settings: job_id(uuid), runnable_settings(bigint) diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index 766c300d3d..cd60275afc 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -307,3 +307,387 @@ async fn test_jobs_authed_reachability(db: Pool) -> anyhow::Result<()> Ok(()) } + +/// Resolving is authorized by row-level security on `v2_job` alone: `v2_job_completed` +/// has RLS disabled, so a regression here silently lets any member annotate any run. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_resolve_completed_jobs_scoping(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/jobs"); + + async fn seed(db: &Pool, owner: &str, status: &str, script: &str) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, tag, runnable_path, visible_to_owner) + VALUES ($1, 'test-workspace', $2, $3, $4, 'script', 'deno', $5, true)", + ) + .bind(id) + .bind(owner) + .bind(format!("u/{owner}")) + .bind(format!("{owner}@windmill.dev")) + .bind(format!("u/{owner}/{script}")) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '42'::jsonb, $2::job_status)", + ) + .bind(id) + .bind(status) + .execute(db) + .await + .unwrap(); + id + } + + let mine = seed(&db, "test-user-2", "failure", "some_script").await; + let theirs = seed(&db, "test-user", "failure", "some_script").await; + let mine_succeeded = seed(&db, "test-user-2", "success", "some_script").await; + let unrelated_success = seed(&db, "test-user-2", "success", "other_script").await; + + // test-user-2 is a plain non-admin, non-operator member of the workspace. + let member = |b: reqwest::RequestBuilder| b.header("Authorization", "Bearer SECRET_TOKEN_2"); + + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine, theirs, mine_succeeded], "note": "expected" })) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /jobs/completed/resolve"); + let resolved: Vec = serde_json::from_str(&body)?; + assert_eq!( + resolved, + vec![mine], + "only the caller's own failed run may be resolved" + ); + + let row: (String, Option, Option) = sqlx::query_as( + "SELECT c.status::text, r.resolved_by, r.note + FROM v2_job_completed c JOIN job_resolution r ON r.job_id = c.id + WHERE c.id = $1", + ) + .bind(mine) + .fetch_one(&db) + .await?; + assert_eq!(row.0, "failure", "resolving must not change job status"); + // Who resolved it and why are enterprise-only; CE records only that it was handled. + #[cfg(feature = "enterprise")] + { + assert_eq!(row.1.as_deref(), Some("test-user-2")); + assert_eq!(row.2.as_deref(), Some("expected")); + } + #[cfg(not(feature = "enterprise"))] + { + assert_eq!(row.1, None, "attribution is an EE feature"); + assert_eq!(row.2, None, "notes are an EE feature"); + } + + // Re-resolving without a note must keep the existing one: a bulk selection routinely + // includes already-resolved failures, and silently blanking their notes loses the only + // record of why they were handled. + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine] })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "re-resolve without a note", + ); + let kept: Option = + sqlx::query_scalar("SELECT note FROM job_resolution WHERE job_id = $1") + .bind(mine) + .fetch_one(&db) + .await?; + #[cfg(feature = "enterprise")] + assert_eq!(kept.as_deref(), Some("expected")); + #[cfg(not(feature = "enterprise"))] + assert_eq!( + kept, None, + "no note is stored outside EE, so none can be lost" + ); + + let resp = member(client().post(format!("{base}/completed/unresolve"))) + .json(&json!({ "job_ids": [mine, theirs] })) + .send() + .await?; + let body = resp.text().await?; + let unresolved: Vec = serde_json::from_str(&body)?; + assert_eq!(unresolved, vec![mine]); + + let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM job_resolution") + .fetch_one(&db) + .await?; + assert_eq!(remaining, 0); + + // A supersession the server cannot prove must resolve nothing: `unrelated_success` is + // visible and successful, so only the runnable check stands between an arbitrary caller and + // a failure stamped with provenance that never happened. + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine], "superseded_by": unrelated_success })) + .send() + .await?; + let body = resp.text().await?; + let ids: Vec = serde_json::from_str(&body)?; + assert!( + ids.is_empty(), + "a supersession by an unrelated run must be rejected, got {body}" + ); + let none: Option = + sqlx::query_scalar("SELECT job_id FROM job_resolution WHERE job_id = $1") + .bind(mine) + .fetch_optional(&db) + .await?; + assert_eq!(none, None, "a rejected claim must not resolve the failure"); + + // Provenance the server established itself is not accountability, so it is recorded even + // where a typed note is not. This is the one thing that must differ from `note` in CE. + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine], "superseded_by": mine_succeeded })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "resolve with a verified supersession", + ); + let system_note: Option = + sqlx::query_scalar("SELECT note FROM job_resolution WHERE job_id = $1") + .bind(mine) + .fetch_one(&db) + .await?; + assert_eq!( + system_note.as_deref(), + Some("Superseded by a successful re-run"), + "a verified supersession must be recorded regardless of licence" + ); + + // Machine provenance only fills a blank: re-running a failure someone already explained + // must not replace their words with the generic supersession wording. + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine], "note": "known upstream outage" })) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "typed note"); + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine], "superseded_by": mine_succeeded })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "supersede an explained failure", + ); + let after: Option = + sqlx::query_scalar("SELECT note FROM job_resolution WHERE job_id = $1") + .bind(mine) + .fetch_one(&db) + .await?; + #[cfg(feature = "enterprise")] + assert_eq!( + after.as_deref(), + Some("known upstream outage"), + "a person's explanation must survive a later supersession" + ); + #[cfg(not(feature = "enterprise"))] + assert_eq!( + after.as_deref(), + Some("Superseded by a successful re-run"), + "no typed note is stored outside EE, so provenance fills the blank" + ); + + sqlx::query("DELETE FROM job_resolution WHERE job_id = $1") + .bind(mine) + .execute(&db) + .await?; + + // Tag scope restricts reads outside RLS, so it has to bind the evidence too: this run is a + // genuine later success of the same runnable and is rejected only by its tag. Without that + // predicate the response would disclose whether a run the token cannot read succeeded. + let out_of_scope_success = seed(&db, "test-user-2", "success", "some_script").await; + sqlx::query("UPDATE v2_job SET tag = 'restricted' WHERE id = $1") + .bind(out_of_scope_success) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) + VALUES (encode(sha256('TAG_SCOPED_2'::bytea), 'hex'), 'TAG_SCOP', 'TAG_SCOPED_2', + 'test2@windmill.dev', 'tag scoped', false, ARRAY['if_jobs:filter_tags:deno'])", + ) + .execute(&db) + .await?; + let resp = client() + .post(format!("{base}/completed/resolve")) + .header("Authorization", "Bearer TAG_SCOPED_2") + .json(&json!({ "job_ids": [mine], "superseded_by": out_of_scope_success })) + .send() + .await?; + let body = resp.text().await?; + let ids: Vec = serde_json::from_str(&body)?; + assert!( + ids.is_empty(), + "an out-of-scope run must not serve as evidence, got {body}" + ); + sqlx::query("DELETE FROM job_resolution WHERE job_id = $1") + .bind(mine) + .execute(&db) + .await?; + + // A flow step is a failure too, but resolving one would render it orange inside a flow + // whose own status is still red, so the endpoint must skip it. + let step = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, tag, runnable_path, visible_to_owner, flow_step_id) + VALUES ($1, 'test-workspace', 'test-user-2', 'u/test-user-2', 'test2@windmill.dev', + 'script', 'deno', 'u/test-user-2/some_script', true, 'a')", + ) + .bind(step) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'failure'::job_status)", + ) + .bind(step) + .execute(&db) + .await?; + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [step] })) + .send() + .await?; + let ids: Vec = serde_json::from_str(&resp.text().await?)?; + assert!(ids.is_empty(), "a flow step must not be resolvable"); + + // Re-resolving when attribution is unavailable must not erase attribution already + // recorded. CE reaches the identical code path as an EE instance whose runtime licence + // lapsed: `resolution_attribution` returns NULLs in both cases, and without the COALESCE + // the conflict update would overwrite a stored resolver with NULL. + #[cfg(not(feature = "enterprise"))] + { + sqlx::query( + "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note) + VALUES ($1, 'test-workspace', 'earlier-admin', 'recorded under a valid licence') + ON CONFLICT (job_id) DO UPDATE SET resolved_by = EXCLUDED.resolved_by, + note = EXCLUDED.note", + ) + .bind(mine) + .execute(&db) + .await?; + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine] })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "re-resolve without attribution", + ); + let kept: (Option, Option) = + sqlx::query_as("SELECT resolved_by, note FROM job_resolution WHERE job_id = $1") + .bind(mine) + .fetch_one(&db) + .await?; + assert_eq!( + kept.0.as_deref(), + Some("earlier-admin"), + "attribution recorded earlier must survive a re-resolve that cannot supply it" + ); + assert_eq!(kept.1.as_deref(), Some("recorded under a valid licence")); + sqlx::query("DELETE FROM job_resolution WHERE job_id = $1") + .bind(mine) + .execute(&db) + .await?; + } + + // Operators are read-only on runs; the endpoint must refuse them outright. + sqlx::query("UPDATE usr SET operator = true WHERE username = 'test-user-3'") + .execute(&db) + .await?; + let resp = client() + .post(format!("{base}/completed/resolve")) + .header("Authorization", "Bearer SECRET_TOKEN_3") + .json(&json!({ "job_ids": [mine] })) + .send() + .await?; + // Error::NotAuthorized maps to 401 (403 is RequireAdmin/PermissionDenied). + assert_eq!( + resp.status().as_u16(), + 401, + "operators must be refused: {}", + resp.text().await? + ); + + // The note is copied onto every resolved row, so an unbounded one multiplies by the + // batch size; the cap must reject before any row is written. + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine], "note": "x".repeat(2001) })) + .send() + .await?; + assert_eq!(resp.status().as_u16(), 400, "{}", resp.text().await?); + let after: i64 = sqlx::query_scalar("SELECT count(*) FROM job_resolution") + .fetch_one(&db) + .await?; + assert_eq!(after, 0, "a rejected note must not write any row"); + + // The limit is characters, not bytes, so a multi-byte note the client accepted must + // not fail server-side: 1000 4-byte chars is well over 2000 bytes but under the cap. + let resp = member(client().post(format!("{base}/completed/resolve"))) + .json(&json!({ "job_ids": [mine], "note": "😀".repeat(1000) })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "resolve with a multi-byte note", + ); + + Ok(()) +} + +/// "Resolved only" is a completed-jobs concept, so the bulk-action id list must not union +/// the queue: re-running "all jobs matching filters" under it would hit live jobs. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_list_filtered_job_uuids_resolved_excludes_queue( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let queued = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, tag, runnable_path, visible_to_owner) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'test@windmill.dev', + 'script', 'deno', 'u/test-user/queued', true)", + ) + .bind(queued) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + VALUES ($1, 'test-workspace', now(), 'deno')", + ) + .bind(queued) + .execute(&db) + .await?; + + let url = format!( + "http://localhost:{port}/api/w/test-workspace/jobs/list_filtered_uuids?resolved=true" + ); + let resp = authed(client().get(&url)).send().await?; + let body = resp.text().await?; + let ids: Vec = serde_json::from_str(&body)?; + assert!( + !ids.contains(&queued), + "a queued job must not match resolved=true, got: {body}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-jobs/src/concurrency_groups.rs b/backend/windmill-api-jobs/src/concurrency_groups.rs index 3f559b4c25..0cf3786d3e 100644 --- a/backend/windmill-api-jobs/src/concurrency_groups.rs +++ b/backend/windmill-api-jobs/src/concurrency_groups.rs @@ -196,6 +196,7 @@ async fn get_concurrent_intervals( running: None, parent_job: None, is_skipped: None | Some(false), + resolved: None, suspended: None, schedule_path: None, args: None, @@ -266,7 +267,7 @@ async fn get_concurrent_intervals( // This first transaction uses the user_db to know which uuids are // accessible to the user. let mut tx = user_db.begin(&authed).await?; - let running_jobs_user: Vec = if lq.success.is_none() { + let running_jobs_user: Vec = if lq.success.is_none() && lq.resolved != Some(true) { sqlx::query_scalar(&sql_q_user).fetch_all(&mut *tx).await? } else { vec![] @@ -281,7 +282,7 @@ async fn get_concurrent_intervals( // This second transaction uses the db, so it will fetch information // potentially forbidden to the user. It must be obscured before // returning it - let running_jobs_db: Vec = if lq.success.is_none() { + let running_jobs_db: Vec = if lq.success.is_none() && lq.resolved != Some(true) { sqlx::query_as(&sql_q).fetch_all(&db).await? } else { vec![] @@ -330,7 +331,7 @@ async fn get_concurrent_intervals( let sql_c = sqlb_c.query()?; let mut tx = user_db.begin(&authed).await?; - let running_jobs: Vec = if lq.success.is_none() { + let running_jobs: Vec = if lq.success.is_none() && lq.resolved != Some(true) { sqlx::query_as(&sql_q).fetch_all(&mut *tx).await? } else { vec![] diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index 4f481fbd54..05f5a51bbc 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -703,6 +703,17 @@ pub async fn delete_jobs( .await? .rows_affected(); + // Resolutions are not exported, so a delete-then-reimport of the same UUID would + // otherwise resurrect the old annotation on a job that never carried one. + let resolution_deleted = sqlx::query!( + "DELETE FROM job_resolution WHERE workspace_id = $1 AND job_id = ANY($2)", + &w_id, + &job_ids + ) + .execute(&mut *tx) + .await? + .rows_affected(); + let jobs_deleted = sqlx::query!( "DELETE FROM v2_job WHERE workspace_id = $1 AND id = ANY($2)", &w_id, @@ -726,6 +737,7 @@ pub async fn delete_jobs( + zombie_deleted + dispatch_event_deleted + conversation_message_deleted + + resolution_deleted + jobs_deleted; tracing::info!( diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs index a95a12ee6a..835c4c7e34 100644 --- a/backend/windmill-api-jobs/src/query.rs +++ b/backend/windmill-api-jobs/src/query.rs @@ -490,6 +490,14 @@ pub fn filter_list_completed_query( sqlb.and_where_ne("status", "'skipped'"); } } + if let Some(r) = &lq.resolved { + let exists = "EXISTS (SELECT 1 FROM job_resolution WHERE job_id = v2_job_completed.id)"; + if *r { + sqlb.and_where(exists); + } else { + sqlb.and_where(format!("NOT {exists}")); + } + } if let Some(fs) = &lq.is_flow_step { if *fs { sqlb.and_where_is_not_null("flow_step_id"); @@ -673,6 +681,7 @@ mod tests { order_desc: None, job_kinds: None, is_skipped: None, + resolved: None, is_flow_step: None, suspended: None, schedule_path: None, @@ -942,6 +951,33 @@ mod tests { assert!(sql.contains("'failure'")); } + #[test] + fn test_completed_filter_resolved() { + // Hiding resolved failures must be an anti-join, not a positive one: getting the + // polarity backwards would silently show only the failures meant to be hidden. + for (resolved, expected) in [ + (true, "EXISTS (SELECT 1 FROM job_resolution"), + (false, "NOT EXISTS (SELECT 1 FROM job_resolution"), + ] { + let lq = ListCompletedQuery { resolved: Some(resolved), ..empty_completed_query() }; + let sqlb = filter_list_completed_query( + SqlBuilder::select_from("v2_job_completed").clone(), + &lq, + "ws", + false, + ); + let sql = build_sql(sqlb); + assert!(sql.contains(expected), "expected {expected:?}, got: {sql}"); + if !resolved { + continue; + } + assert!( + !sql.contains("NOT EXISTS (SELECT 1 FROM job_resolution"), + "resolved=true must not negate the anti-join, got: {sql}" + ); + } + } + #[test] fn test_completed_filter_status_canceled() { let lq = ListCompletedQuery { diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index c2175753e6..0d96a66750 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -149,6 +149,10 @@ pub struct ListCompletedQuery { pub order_desc: Option, pub job_kinds: Option>, pub is_skipped: Option, + // Whether the failure has been marked handled. Completed-jobs only: a queued job + // has no resolution, which is why `resolved = false` must not reach the queue side + // of the runs-list union. + pub resolved: Option, pub is_flow_step: Option, pub suspended: Option, pub schedule_path: Option, @@ -304,6 +308,7 @@ pub struct UnifiedJob { pub worker: Option, pub runnable_settings_handle: Option, pub is_retry: Option, + pub resolved: Option, } const CJ_FIELDS: &[&str] = &[ @@ -346,6 +351,7 @@ const CJ_FIELDS: &[&str] = &[ "v2_job_completed.worker", "null as runnable_settings_handle", "EXISTS(SELECT 1 FROM native_retry_attempt WHERE job_id = v2_job.id) as is_retry", + "EXISTS(SELECT 1 FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved", ]; const QJ_FIELDS: &[&str] = &[ @@ -388,6 +394,8 @@ const QJ_FIELDS: &[&str] = &[ "v2_job_queue.worker", "v2_job_queue.runnable_settings_handle", "EXISTS(SELECT 1 FROM native_retry_attempt WHERE job_id = v2_job.id) as is_retry", + // A job still in the queue has not failed, so it can never be resolved. + "false as resolved", ]; impl UnifiedJob { @@ -442,6 +450,11 @@ impl From for Job { labels: uj.labels, preprocessed: uj.preprocessed, is_retry: uj.is_retry, + resolved: uj.resolved, + resolved_by: None, + resolved_at: None, + resolution_note: None, + resolved_automatically: None, }, )), "QueuedJob" => Job::QueuedJob(JobExtended::new( @@ -696,6 +709,7 @@ mod tests { "flow".to_string(), ])), is_skipped: None, + resolved: None, is_flow_step: None, suspended: None, schedule_path: None, @@ -766,6 +780,7 @@ mod tests { order_desc: None, job_kinds: None, is_skipped: None, + resolved: None, is_flow_step: None, suspended: None, schedule_path: None, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 7998a769f9..19494c41fc 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -960,6 +960,7 @@ pub(crate) async fn delete_workspace( sqlx::query!( "WITH ids AS (SELECT id FROM v2_job WHERE workspace_id = $1), _de AS (DELETE FROM dispatch_event WHERE workspace_id = $1), + _jr AS (DELETE FROM job_resolution WHERE workspace_id = $1), _fc AS (DELETE FROM flow_conversation_message WHERE job_id IN (SELECT id FROM ids)) DELETE FROM zombie_job_counter WHERE job_id IN (SELECT id FROM ids)", &w_id diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c4f030516f..1d0fc94904 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -13395,6 +13395,13 @@ paths: in: query schema: type: boolean + - name: resolved + description: >- + filter on whether a failure has been marked as handled. true keeps only + resolved failures, false hides them + in: query + schema: + type: boolean - name: is_flow_step description: is the job a flow step in: query @@ -13677,6 +13684,13 @@ paths: in: query schema: type: boolean + - name: resolved + description: >- + filter on whether a failure has been marked as handled. true keeps only + resolved failures, false hides them + in: query + schema: + type: boolean - name: is_flow_step description: is the job a flow step in: query @@ -13857,6 +13871,13 @@ paths: in: query schema: type: boolean + - name: resolved + description: >- + filter on whether a failure has been marked as handled. true keeps only + resolved failures, false hides them + in: query + schema: + type: boolean - name: is_flow_step description: is the job a flow step in: query @@ -14576,6 +14597,92 @@ paths: schema: $ref: "#/components/schemas/CompletedJob" + /w/{workspace}/jobs/completed/resolve: + post: + summary: mark failed jobs as resolved + description: >- + Marks failed jobs as handled so triage surfaces stop showing them as failures. + The job status itself is unchanged. Returns the ids actually resolved: ids that + are not visible to the caller or did not fail are silently skipped. + operationId: resolveCompletedJobs + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + job_ids: + type: array + maxItems: 1000 + items: + type: string + format: uuid + note: + type: string + maxLength: 2000 + description: >- + a person's explanation of why the failure is considered handled. + Enterprise-only: ignored outside enterprise + superseded_by: + type: string + format: uuid + description: >- + id of a later successful run of the same runnable that supersedes the + failure. Verified server-side, and the resulting note is the server's own + wording, so it is recorded regardless of licence. A claim that cannot be + verified resolves nothing + required: + - job_ids + responses: + "200": + description: ids of the jobs that were resolved + content: + application/json: + schema: + type: array + items: + type: string + format: uuid + + /w/{workspace}/jobs/completed/unresolve: + post: + summary: remove the resolution of failed jobs + operationId: unresolveCompletedJobs + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + job_ids: + type: array + maxItems: 1000 + items: + type: string + format: uuid + required: + - job_ids + responses: + "200": + description: ids of the jobs that were unresolved + content: + application/json: + schema: + type: array + items: + type: string + format: uuid + /w/{workspace}/jobs_u/queue/cancel/{id}: post: summary: cancel queued or running job @@ -21976,6 +22083,13 @@ paths: in: query schema: type: boolean + - name: resolved + description: >- + filter on whether a failure has been marked as handled. true keeps only + resolved failures, false hides them + in: query + schema: + type: boolean - name: is_flow_step description: is the job a flow step in: query @@ -25515,6 +25629,25 @@ components: type: boolean is_retry: type: boolean + resolved: + type: boolean + description: whether this failure has been marked as handled + resolved_by: + type: string + description: >- + who resolved the failure. Enterprise-only, so also absent for a manual resolution + outside enterprise; use resolved_automatically to tell the two apart + resolved_at: + type: string + format: date-time + resolution_note: + type: string + resolved_automatically: + type: boolean + description: >- + true when a succeeding retry resolved this rather than a person. Explicit rather + than inferred from an absent resolved_by, which is also absent for a manual + resolution outside enterprise worker: type: string required: diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 2413ef47ff..4ee13226c5 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -324,6 +324,14 @@ pub fn workspaced_service() -> Router { "/completed/delete/{id}", post(delete_completed_job).layer(cors.clone()), ) + .route( + "/completed/resolve", + post(resolve_completed_jobs).layer(cors.clone()), + ) + .route( + "/completed/unresolve", + post(unresolve_completed_jobs).layer(cors.clone()), + ) .route( "/flow/resume/{id}", post(resume_suspended_flow_as_owner).layer(cors.clone()), @@ -1428,6 +1436,11 @@ macro_rules! get_job_query { "v2_job_completed.duration_ms, v2_job_completed.completed_at, CASE WHEN status = 'success' OR status = 'skipped' THEN true ELSE false END as success, result_columns, deleted, status = 'skipped' as is_skipped, \ v2_job.labels, \ EXISTS(SELECT 1 FROM native_retry_attempt WHERE job_id = v2_job.id) as is_retry, \ + EXISTS(SELECT 1 FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved, \ + (SELECT resolved_by FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved_by, \ + (SELECT resolved_at FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved_at, \ + (SELECT note FROM job_resolution WHERE job_id = v2_job_completed.id) as resolution_note, \ + (SELECT automatic FROM job_resolution WHERE job_id = v2_job_completed.id) as resolved_automatically, \ CASE WHEN result is null or pg_column_size(result) < 90000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result", "", ) @@ -2787,7 +2800,10 @@ async fn list_filtered_job_uuids( false, get_scope_tags(&authed), ); - let query = if lq.status.is_some() { + // Same reasoning as the runs-list union gate: "resolved only" is a completed-jobs + // concept, so unioning the queue would feed queued jobs into bulk actions taken + // under that filter. "hide resolved" must still keep them. + let query = if lq.status.is_some() || lq.resolved == Some(true) { sqlb.subquery()? } else { let sqlb2 = list_queue_jobs_query( @@ -3016,6 +3032,10 @@ async fn list_jobs( && lq.label.is_none() && lq.result.is_none() && !lq.is_skipped.unwrap_or(false) + // Only "resolved = true" forces completed-only: queued jobs would otherwise leak + // into a resolved-only view. "resolved = false" (hide resolved) must keep them, + // since a running job has no resolution to hide. + && lq.resolved != Some(true) && lq.created_before.is_none() && lq.started_before.is_none() && lq.created_or_started_before.is_none() @@ -9829,6 +9849,213 @@ async fn delete_completed_job<'a>( .await; } +#[derive(Deserialize)] +struct ResolveJobsRequest { + job_ids: Vec, + note: Option, + /// Id of a later successful run of the same runnable, when the caller claims the failure + /// was superseded. Evidence rather than an assertion: the claim is proven in SQL below and + /// the wording it produces belongs to the server, so this cannot attach arbitrary text to a + /// failure nor stamp provenance onto one that was never re-run. + superseded_by: Option, +} + +/// Provenance Windmill established itself, as opposed to a person's explanation, which is why +/// this is recorded outside enterprise while a typed note is not. +const SUPERSEDED_NOTE: &str = "Superseded by a successful re-run"; + +/// Bounded so the per-id audit rows written below stay bounded too. +const MAX_RESOLUTION_BATCH: usize = 1000; +/// The note is copied onto every row the request resolves, so its size multiplies by the +/// batch size. Bounded to keep a single call from writing an outsized amount of TOAST/WAL. +/// Counted in characters, not bytes, so the limit matches what the client and the OpenAPI +/// `maxLength` count and a non-ASCII note never fails a check it appeared to pass. +const MAX_RESOLUTION_NOTE_LEN: usize = 2000; + +/// Who resolved a failure and why is enterprise-only, mirroring `audit_log` being a no-op +/// outside EE: CE records *that* a failure was handled, EE records the accountability. The +/// resolution itself, the filter and the automatic retry sweep are unaffected, so gating +/// stays on this write and never reaches the runs-list read path. +/// The runtime license check matters as much as the feature gate: an EE binary keeps the +/// `enterprise` feature when its key expires, so without this a direct API client could keep +/// persisting attribution the UI has already stopped offering. +#[cfg(feature = "enterprise")] +fn resolution_attribution<'a>( + authed: &'a ApiAuthed, + note: Option<&'a str>, +) -> (Option<&'a str>, Option<&'a str>) { + if !windmill_common::ee_oss::LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed) { + return (None, None); + } + (Some(authed.username.as_str()), note) +} + +#[cfg(not(feature = "enterprise"))] +fn resolution_attribution<'a>( + _authed: &'a ApiAuthed, + _note: Option<&'a str>, +) -> (Option<&'a str>, Option<&'a str>) { + (None, None) +} + +fn check_resolution_request( + authed: &ApiAuthed, + job_ids: &[Uuid], + note: Option<&str>, +) -> error::Result<()> { + if authed.is_operator { + return Err(error::Error::NotAuthorized( + "Operators cannot resolve jobs".to_string(), + )); + } + if job_ids.len() > MAX_RESOLUTION_BATCH { + return Err(error::Error::BadRequest(format!( + "Cannot resolve more than {MAX_RESOLUTION_BATCH} jobs at once, got {}", + job_ids.len() + ))); + } + if let Some(note) = note { + let len = note.chars().count(); + if len > MAX_RESOLUTION_NOTE_LEN { + return Err(error::Error::BadRequest(format!( + "Resolution note cannot exceed {MAX_RESOLUTION_NOTE_LEN} characters, got {len}" + ))); + } + } + Ok(()) +} + +/// Marks failed jobs as handled. Returns the ids actually affected: an id that is not +/// visible to the caller, carries an out-of-scope tag, or did not fail is silently +/// absent rather than an error, so a bulk selection never fails as a whole. +async fn resolve_completed_jobs( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> error::JsonResult> { + check_resolution_request(&authed, &req.job_ids, req.note.as_deref())?; + let mut tx = user_db.begin(&authed).await?; + let tags = get_scope_tags(&authed); + let (resolved_by, typed_note) = resolution_attribution(&authed, req.note.as_deref()); + let system_note = req.superseded_by.map(|_| SUPERSEDED_NOTE); + // The join on v2_job is what authorizes this write: v2_job_completed has RLS + // disabled, so v2_job's policies are the only thing scoping rows to the caller. + // `status = 'failure'` keeps the invariant that only a failure can be resolved. + let resolved = sqlx::query_scalar!( + "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic) + SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false + FROM v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE c.id = ANY($1) + AND c.workspace_id = $2 + AND ($3::TEXT[] IS NULL OR j.tag = ANY($3)) + AND c.status = 'failure' + -- Resolution is a top-level triage state: a step resolved on its own + -- would render orange inside a flow whose status is still red. + AND j.flow_step_id IS NULL + -- A supersession claim has to be proven, not trusted: a later success of the + -- same identified runnable, itself visible to the caller. An unproven claim + -- resolves nothing, so the caller learns it was rejected instead of having + -- the fiction recorded as provenance. + AND ($6::UUID IS NULL OR EXISTS ( + SELECT 1 FROM v2_job_completed sc + JOIN v2_job sj ON sj.id = sc.id + WHERE sc.id = $6 + AND sc.workspace_id = $2 + -- Tag scope is a read restriction enforced outside RLS, so it has + -- to bind the evidence as well: otherwise the result reveals + -- whether an out-of-scope run succeeded. + AND ($3::TEXT[] IS NULL OR sj.tag = ANY($3)) + AND sc.status = 'success' + AND sc.completed_at >= c.completed_at + AND (j.runnable_id IS NOT NULL OR j.runnable_path IS NOT NULL) + AND sj.runnable_id IS NOT DISTINCT FROM j.runnable_id + AND sj.runnable_path IS NOT DISTINCT FROM j.runnable_path + )) + ON CONFLICT (job_id) DO UPDATE SET + resolved_at = now(), + -- Both COALESCEd: `resolution_attribution` returns NULLs outside EE and once the + -- licence lapses, and bulk selections routinely include already-resolved rows, + -- so overwriting would erase metadata recorded while it was valid. Clear either + -- by unresolving first. + resolved_by = COALESCE($4, job_resolution.resolved_by), + -- A person's explanation replaces what was there; machine provenance only fills + -- a blank, so re-running an already-explained failure never erases their words. + note = COALESCE($5, job_resolution.note, $7), + -- A human taking over an automatic resolution makes it no longer automatic. + automatic = false + RETURNING job_id", + &req.job_ids, + &w_id, + tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>, + resolved_by, + typed_note, + req.superseded_by, + system_note, + ) + .fetch_all(&mut *tx) + .await?; + + for id in &resolved { + audit_log( + &mut *tx, + &authed, + "jobs.resolve", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + None, + ) + .await?; + } + + tx.commit().await?; + Ok(Json(resolved)) +} + +async fn unresolve_completed_jobs( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> error::JsonResult> { + check_resolution_request(&authed, &req.job_ids, None)?; + let mut tx = user_db.begin(&authed).await?; + let tags = get_scope_tags(&authed); + let unresolved = sqlx::query_scalar!( + "DELETE FROM job_resolution r + USING v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE r.job_id = c.id + AND c.id = ANY($1) + AND c.workspace_id = $2 + AND ($3::TEXT[] IS NULL OR j.tag = ANY($3)) + RETURNING r.job_id", + &req.job_ids, + &w_id, + tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>, + ) + .fetch_all(&mut *tx) + .await?; + + for id in &unresolved { + audit_log( + &mut *tx, + &authed, + "jobs.unresolve", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + None, + ) + .await?; + } + + tx.commit().await?; + Ok(Json(unresolved)) +} + async fn get_otel_traces( OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index d73637026a..e9d6aa95fb 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -501,6 +501,9 @@ pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> e sqlx::query!("DELETE FROM zombie_job_counter WHERE job_id = ANY($1)", ids) .execute(&mut *conn) .await?; + sqlx::query!("DELETE FROM job_resolution WHERE job_id = ANY($1)", ids) + .execute(&mut *conn) + .await?; sqlx::query!("DELETE FROM v2_job WHERE id = ANY($1)", ids) .execute(&mut *conn) .await?; diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index f2d0425710..c84e58f2a1 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1055,6 +1055,32 @@ pub async fn add_completed_job( return Ok((job_id, duration, None)); } + // Auto-resolve a retry chain that ultimately worked, from whichever of the two + // completions lands last (see resolve_retry_chain_if_succeeded): a success that has a + // parent (so is a possible retry attempt), or a failure that just enqueued a retry. + // `retry_pending` already implies a non-flow-step `Script`. + let resolve_root = if success && !skipped && !completed_job.is_flow_step() { + matches!(completed_job.kind, JobKind::Script) + .then(|| completed_job.parent_job) + .flatten() + } else if !success && !skipped && retry_pending { + Some(completed_job.parent_job.unwrap_or(completed_job.id)) + } else { + None + }; + if let Some(root) = resolve_root { + if let Err(e) = resolve_retry_chain_if_succeeded( + db, + root, + &completed_job.workspace_id, + completed_job.runnable_id, + ) + .await + { + tracing::error!("Error auto-resolving native retry chain {root}: {e:#}"); + } + } + #[cfg(feature = "cloud")] apply_completed_job_cloud_usage(db, completed_job, duration); @@ -1694,6 +1720,80 @@ async fn restart_job_if_perpetual_inner( Ok(()) } +/// Marks the failures a succeeding native retry attempt superseded as resolved, so +/// triage surfaces stop showing red for a chain that ultimately worked. `automatic` is set +/// so the UI can say so without reading `resolved_by`, which is NULL for every manual CE +/// resolution too; `ON CONFLICT DO NOTHING` keeps a human's note intact. +/// +/// Membership of the chain must be *proven* per row, never inferred from `parent_job` +/// alone: `root` is `job.parent_job.unwrap_or(job.id)`, so for a job launched with an +/// explicit `parent_job` (WAC inline children, SDK-launched children) `root` is the +/// *calling* job, and its other failed children are unrelated. Resolving those would hide +/// exactly the failures this feature exists to surface, so each row must be either +/// - a job carrying a `native_retry_attempt` marker under `root` (provably an attempt), or +/// - `root` itself as the original attempt, which is only provable when `root` is +/// parentless and unmarked. +/// +/// Both arms also require the same `runnable_id` as the succeeding attempt, since every +/// attempt in a chain runs the same runnable. The deliberate cost is a miss, not an +/// over-reach: when the original attempt had a parent it is an unmarked sibling +/// indistinguishable from any other child of the caller, so it stays red. +/// +/// Nothing is trusted of the caller (this writes through a privileged pool): the gate is +/// "some marked attempt under `root` running `runnable_id` has succeeded", evaluated in +/// SQL, so a call for a chain that has not succeeded resolves nothing. +/// +/// Call this from *both* completion paths. A retry is enqueued before its predecessor's +/// failure row is committed, so with a zero delay the retry can succeed while that row is +/// still absent; the success-side call would then find nothing to resolve. Calling again +/// when a failure commits with a retry pending makes the two commit orders converge. +/// +/// `ON CONFLICT DO NOTHING` makes a repeat call idempotent while a resolution *exists*, but +/// it is not inert: once a human unresolves a chain member, a later sweep for the same +/// chain resolves it again. Reaching that needs another completion under the same `root` +/// (a WAC inline sibling, or a replayed completion), so it is rare rather than impossible. +/// Making an unresolve durable against it needs a tombstone the read path can see, which +/// this deliberately does not add. +pub async fn resolve_retry_chain_if_succeeded( + db: &Pool, + root: Uuid, + workspace_id: &str, + runnable_id: Option, +) -> Result<(), Error> { + sqlx::query!( + "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic) + SELECT c.id, c.workspace_id, NULL, NULL, true + FROM v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE c.status = 'failure' + AND c.workspace_id = $2 + AND j.flow_step_id IS NULL + AND j.runnable_id IS NOT DISTINCT FROM $3 + AND EXISTS ( + SELECT 1 FROM v2_job_completed sc + JOIN v2_job sj ON sj.id = sc.id + JOIN native_retry_attempt nra ON nra.job_id = sc.id + WHERE sc.status = 'success' + AND sc.workspace_id = $2 + AND sj.parent_job = $1 + AND sj.runnable_id IS NOT DISTINCT FROM $3 + ) + AND ( + (j.parent_job = $1 + AND EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id)) + OR (c.id = $1 AND j.parent_job IS NULL + AND NOT EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id)) + ) + ON CONFLICT (job_id) DO NOTHING", + root, + workspace_id, + runnable_id.map(|h| h.0), + ) + .execute(db) + .await?; + Ok(()) +} + /// Evaluate a `retry_if` JS expression. `result`/`previous_result` are the /// failure output and `flow_input` the job args. Defaults to retrying on eval /// error (an unevaluable gate shouldn't silently swallow retries). @@ -2131,7 +2231,16 @@ pub async fn report_error_to_workspace_handler_or_critical_side_channel( async fn fetch_error_handler_from_db( db: &Pool, w_id: &str, -) -> Result<(Option, Option>>, bool, bool, bool), Error> { +) -> Result< + ( + Option, + Option>>, + bool, + bool, + bool, + ), + Error, +> { sqlx::query_as::< _, ( @@ -2285,9 +2394,7 @@ pub async fn send_error_to_workspace_handler<'a, 'c, T: Serialize + Send + Sync> // A cancellation is a human action rather than an operational failure, and unlike the // handler path this one has no per-workspace toggle to opt out of reporting them. let suppressed = match INSTANCE_ALERT_THROTTLE.get(w_id) { - Some((last_sent, suppressed)) - if now - last_sent < INSTANCE_ALERT_COOLDOWN_SECONDS => - { + Some((last_sent, suppressed)) if now - last_sent < INSTANCE_ALERT_COOLDOWN_SECONDS => { INSTANCE_ALERT_THROTTLE.insert(w_id.clone(), (last_sent, suppressed + 1)); None } diff --git a/backend/windmill-queue/tests/native_retry_test.rs b/backend/windmill-queue/tests/native_retry_test.rs index fb8046149b..6b6c2d65ce 100644 --- a/backend/windmill-queue/tests/native_retry_test.rs +++ b/backend/windmill-queue/tests/native_retry_test.rs @@ -373,4 +373,157 @@ mod native_retry { "WAC inline child success must NOT count as a recovery" ); } + + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn succeeding_retry_resolves_the_attempts_it_superseded(db: Pool) { + // a: root fails, its marked retry succeeds -> both resolve, status stays 'failure' + // a_sibling: an unmarked child of the SAME root that failed on its own (a WAC + // inline job or an SDK-launched child) -> must stay unresolved. `root` is + // `parent_job.unwrap_or(id)`, so sharing a parent proves nothing about chain + // membership; resolving this would hide an unhandled failure. + // b: an unrelated failure -> must stay unresolved + // f: root fails, an unmarked child succeeds -> must not trigger any resolution + let (a, a_retry, a_sibling, b, f, f_child) = ( + Uuid::new_v4(), + Uuid::new_v4(), + Uuid::new_v4(), + Uuid::new_v4(), + Uuid::new_v4(), + Uuid::new_v4(), + ); + seed_job(&db, a, None, false, "failure").await; + seed_job(&db, a_retry, Some(a), true, "failure").await; + seed_job(&db, a_sibling, Some(a), false, "failure").await; + seed_job(&db, b, None, false, "failure").await; + seed_job(&db, f, None, false, "failure").await; + seed_job(&db, f_child, Some(f), false, "success").await; + + // A human resolved the first attempt already: the automatic pass must not + // overwrite their note. + sqlx::query("INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note) VALUES ($1, $2, 'ruben', 'flaky upstream')") + .bind(a) + .bind(WS) + .execute(&db) + .await + .unwrap(); + + let succeeded = Uuid::new_v4(); + seed_job(&db, succeeded, Some(a), true, "success").await; + windmill_queue::jobs::resolve_retry_chain_if_succeeded(&db, a, WS, None) + .await + .unwrap(); + // The unmarked child must not be able to resolve its own chain. + windmill_queue::jobs::resolve_retry_chain_if_succeeded(&db, f, WS, None) + .await + .unwrap(); + + let resolutions = sqlx::query_as::<_, (Uuid, Option, Option)>( + "SELECT job_id, resolved_by, note FROM job_resolution WHERE workspace_id = $1", + ) + .bind(WS) + .fetch_all(&db) + .await + .unwrap(); + let by_id: std::collections::HashMap<_, _> = resolutions + .into_iter() + .map(|(id, by, note)| (id, (by, note))) + .collect(); + + assert_eq!( + by_id.get(&a), + Some(&( + Some("ruben".to_string()), + Some("flaky upstream".to_string()) + )), + "an existing human resolution must survive the automatic pass" + ); + assert_eq!( + by_id.get(&a_retry), + Some(&(None, None)), + "the superseded attempt resolves with no attribution" + ); + let auto: bool = + sqlx::query_scalar("SELECT automatic FROM job_resolution WHERE job_id = $1") + .bind(a_retry) + .fetch_one(&db) + .await + .unwrap(); + assert!( + auto, + "automatic must be set explicitly: a manual CE resolution also has resolved_by NULL" + ); + assert!( + !by_id.contains_key(&a_sibling), + "an unmarked failed child sharing the root is not part of the chain" + ); + assert!( + !by_id.contains_key(&b), + "an unrelated failure must not be resolved" + ); + assert!( + !by_id.contains_key(&f), + "an unmarked child succeeding must not resolve its parent" + ); + + // A retry is enqueued before its predecessor's failure row is committed, so with a + // zero delay the success can land first and the success-side sweep finds nothing. + // The failure-side call must then close the chain: seed the success first, resolve + // (nothing to do yet), then make the failure visible and resolve again. + let (h, h_attempt) = (Uuid::new_v4(), Uuid::new_v4()); + seed_job(&db, h_attempt, Some(h), true, "success").await; + windmill_queue::jobs::resolve_retry_chain_if_succeeded(&db, h, WS, None) + .await + .unwrap(); + let h_early: i64 = + sqlx::query_scalar("SELECT count(*) FROM job_resolution WHERE job_id = $1") + .bind(h) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!( + h_early, 0, + "nothing to resolve while the failure is not visible" + ); + seed_job(&db, h, None, false, "failure").await; + windmill_queue::jobs::resolve_retry_chain_if_succeeded(&db, h, WS, None) + .await + .unwrap(); + let h_resolved: i64 = + sqlx::query_scalar("SELECT count(*) FROM job_resolution WHERE job_id = $1") + .bind(h) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!( + h_resolved, 1, + "a retry that succeeded before its predecessor's failure was visible must still \ + resolve it once that failure commits" + ); + + // The helper trusts nothing of its caller: a chain whose attempts all failed + // resolves nothing. + let (g, g_attempt) = (Uuid::new_v4(), Uuid::new_v4()); + seed_job(&db, g, None, false, "failure").await; + seed_job(&db, g_attempt, Some(g), true, "failure").await; + windmill_queue::jobs::resolve_retry_chain_if_succeeded(&db, g, WS, None) + .await + .unwrap(); + let g_resolved: i64 = + sqlx::query_scalar("SELECT count(*) FROM job_resolution WHERE job_id IN ($1, $2)") + .bind(g) + .bind(g_attempt) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(g_resolved, 0, "a failed attempt must not resolve its chain"); + + // Resolution is orthogonal: the run is still recorded as a failure. + let status: String = + sqlx::query_scalar("SELECT status::text FROM v2_job_completed WHERE id = $1") + .bind(a_retry) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(status, "failure", "resolving must not change job status"); + } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 79b33f86ac..8385e57bd7 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -391,6 +391,30 @@ pub struct CompletedJob { #[serde(default, skip_serializing_if = "Option::is_none")] #[sqlx(default)] pub is_retry: Option, + // True when this failure has been marked handled (has a job_resolution row), so + // triage surfaces stop rendering it red. `status` stays 'failure' either way. + // The details are only selected by the single-job GET; the runs list carries + // `resolved` alone to keep its payload small. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub resolved: Option, + // None does NOT imply automatic: attribution is enterprise-only, so a manual resolution in + // CE is also None, and list responses omit it deliberately. Use `resolved_automatically`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub resolved_by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub resolved_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub resolution_note: Option, + // True when a succeeding retry resolved this, rather than a person. Explicit rather than + // inferred from a NULL `resolved_by`, which is also NULL for a manual resolution outside + // enterprise (attribution is an EE feature). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub resolved_automatically: Option, } impl CompletedJob { diff --git a/frontend/src/lib/components/JobStatus.svelte b/frontend/src/lib/components/JobStatus.svelte index 61a84f05da..7945081482 100644 --- a/frontend/src/lib/components/JobStatus.svelte +++ b/frontend/src/lib/components/JobStatus.svelte @@ -30,6 +30,23 @@ /> {/if} +{:else if job && 'success' in job && job.resolved} + + + Failed after {msToReadableTime(job.duration_ms)}, resolved{job.resolved_automatically + ? ' automatically' + : job.resolved_by + ? ` by ${job.resolved_by}` + : ''} + {#if job.self_wait_time_ms || job.aggregate_wait_time_ms} + + {/if} + {:else if job && 'success' in job} Failed after {msToReadableTime(job.duration_ms)} diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index ed93550669..e7f45d0dcc 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -10,7 +10,14 @@ } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { userStore, workspaceStore, userWorkspaces, superadmin, devopsRole } from '$lib/stores' + import { + userStore, + workspaceStore, + userWorkspaces, + superadmin, + devopsRole, + enterpriseLicense + } from '$lib/stores' import { Button, ButtonType, @@ -20,6 +27,7 @@ Tab, Tabs } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' import RunChart from '$lib/components/RunChart.svelte' import JobRunsPreview from '$lib/components/runs/JobRunsPreview.svelte' @@ -38,7 +46,7 @@ import { twMerge } from 'tailwind-merge' import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte' import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte' - import { pluralize } from '$lib/utils' + import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils' import BatchReRunOptionsPane, { type BatchReRunOptions } from '$lib/components/runs/BatchReRunOptionsPane.svelte' @@ -271,6 +279,12 @@ ? false : undefined, isSkipped: filters.val.show_skipped ? undefined : false, + resolved: + filters.val.resolved === 'resolved' + ? true + : filters.val.resolved === 'unresolved' + ? false + : undefined, // isFlowStep: jobKindsCat != 'all' ? false : undefined, hasNullParent: filters.val.path != undefined || @@ -312,6 +326,57 @@ sendUserToast(`Canceled ${uuids.length} jobs`) } + async function setJobsResolution(jobIds: string[], resolved: boolean, note?: string) { + // Spread-count code points so this matches the server's `chars().count()` exactly; + // `.length` counts UTF-16 units and would reject valid astral-plane notes. + if (note !== undefined && [...note].length > MAX_RESOLUTION_NOTE_LEN) { + sendUserToast(`Note cannot exceed ${MAX_RESOLUTION_NOTE_LEN} characters`, true) + return + } + // The endpoint scopes rows to the path workspace, so in the admins all-workspaces + // view a selection spanning workspaces has to be dispatched per workspace or the + // out-of-workspace ids are silently skipped. Same reason cancel_selection groups. + // Index once and append in place: a scan per id plus a bucket copy per id is ~100M + // operations at the 10k selection the table allows, which blocks the page before the + // first request goes out. + const workspaceById = new Map() + for (const j of jobs ?? []) if (j.workspace_id) workspaceById.set(j.id, j.workspace_id) + const byWorkspace = new Map() + for (const id of jobIds) { + const ws = workspaceById.get(id) ?? $workspaceStore ?? '' + const bucket = byWorkspace.get(ws) + if (bucket) bucket.push(id) + else byWorkspace.set(ws, [id]) + } + // The table selects up to 10k rows but the endpoint caps a call at MAX_RESOLUTION_BATCH, + // so chunk rather than let an oversized selection reject the whole action. + const requests: { workspace: string; ids: string[] }[] = [] + for (const [workspace, ids] of byWorkspace) { + for (let i = 0; i < ids.length; i += MAX_RESOLUTION_BATCH) { + requests.push({ workspace, ids: ids.slice(i, i + MAX_RESOLUTION_BATCH) }) + } + } + const affected = ( + await Promise.all( + requests.map(({ workspace, ids }) => + resolved + ? JobService.resolveCompletedJobs({ + workspace, + requestBody: { job_ids: ids, note: note || undefined } + }) + : JobService.unresolveCompletedJobs({ workspace, requestBody: { job_ids: ids } }) + ) + ) + ).flat() + selectedIds = [] + manualSelectionMode = undefined + resolutionNote = '' + jobsLoader?.loadJobs(true, true) + sendUserToast( + `${resolved ? 'Resolved' : 'Unresolved'} ${affected.length} ${affected.length === 1 ? 'job' : 'jobs'}` + ) + } + async function onCancelAllJobsMatchingFilters() { forceCancelInPopup = false askingForConfirmation = { @@ -321,6 +386,16 @@ } const selectedFilters = getSelectedFilters() + // Cancellation targets come from the queue, but resolution only exists on completed + // jobs: "Resolved only" therefore matches nothing cancellable. The queue endpoint + // takes ListQueueQuery and has no `resolved` param at all, so without this the + // lookup would silently return the queued jobs the table is currently hiding and + // offer to cancel them. + if (selectedFilters.resolved === true) { + askingForConfirmation = undefined + sendUserToast('No queued jobs match "Resolved only" — resolution applies to completed runs') + return + } const selectedFiltersString = JSON.stringify(selectedFilters, null, 4) const jobIdsToCancel = await JobService.listFilteredQueueUuids(selectedFilters) @@ -495,7 +570,8 @@ `The exact number of concurrent jobs at the beginning of the time range may be incorrect as only the last ${perPage.val} jobs are taken into account: a job that was started earlier than this limit will not be taken into account` ) - let manualSelectionMode: undefined | 'cancel' | 'rerun' = $state() + let manualSelectionMode: undefined | 'cancel' | 'rerun' | 'resolve' = $state() + let resolutionNote = $state('') {:else} @@ -932,6 +1009,19 @@ batchRerunOptionsIsOpen = true } }, + ...(!$userStore?.operator + ? [ + { + // Operators are rejected by the endpoint, so offering it would only 403. + displayName: 'Resolve failed jobs', + action: () => ( + (manualSelectionMode = 'resolve'), + (selectedIds = []), + (resolutionNote = '') + ) + } + ] + : []), { displayName: 'Cancel all jobs matching filters', action: () => onCancelAllJobsMatchingFilters() @@ -1004,6 +1094,40 @@ Cancel {selectedIds.length} jobs + {:else if manualSelectionMode === 'resolve'} +
+

+ Resolving keeps the run a failure but stops it showing as one in the runs list. +

+ +
+ + +
+
{:else if batchRerunOptionsIsOpen} jobsLoader?.loadJobs(true, true)} /> {/if} {:else if selectedIds.length > 1} diff --git a/frontend/src/lib/components/runs/JobDetailHeader.svelte b/frontend/src/lib/components/runs/JobDetailHeader.svelte index 28519d5311..5015d5ed37 100644 --- a/frontend/src/lib/components/runs/JobDetailHeader.svelte +++ b/frontend/src/lib/components/runs/JobDetailHeader.svelte @@ -1,10 +1,19 @@ -
- {#if description != undefined} - -
- {/if} -
-
- {#if !hide_cancel} +{#if replaying} +
This step was waiting for approval.
+{:else} +
+ {#if description != undefined} + +
+ {/if} +
+
+ {#if !hide_cancel} +
+
+ {/if}
- {/if} -
- + + {#if approvalPageUrl} + + Approval page + + {/if} + + {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} +
+ +
+ + The payload is optional, it is passed to the following step through the `resume` + variable + + {/if}
- - {#if approvalPageUrl} - - Approval page - - {/if} - - {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} -
- -
- - The payload is optional, it is passed to the following step through the `resume` variable - - {/if}
-
+{/if} diff --git a/frontend/src/lib/components/GfmMarkdown.svelte b/frontend/src/lib/components/GfmMarkdown.svelte index 377951b97b..01aeeef9f0 100644 --- a/frontend/src/lib/components/GfmMarkdown.svelte +++ b/frontend/src/lib/components/GfmMarkdown.svelte @@ -1,16 +1,28 @@
- + {#if asPlainText} +

{md}

+ {:else} + + {/if}

everything here is private

` + ) + doc.documentElement.setAttribute('data-wm-no-record', '') + doc.documentElement.setAttribute('class', 'theme-dark') + doc.documentElement.setAttribute('hidden', '') + doc.documentElement.setAttribute('cite', 'https://host/private-source') + + const html = serializeDocument(doc) + expect(html).not.toContain('everything here is private') + expect(html).not.toContain('private-source') + expect(html).not.toContain('theme-dark') + expect(html).toContain('hidden') + }) + + it('keeps no attribute that could carry content, listed or not', () => { + const doc = docFrom( + `` + + `` + ) + const html = serializeDocument(doc) + expect(html).not.toContain('embedded secret') + expect(html).not.toContain('future secret') + expect(html).not.toContain('/cited') + expect(html).not.toContain('salary-92000') + // The layout-bearing class survives: the snapshot's own CSS selects on it. + expect(html).toContain('class="frame"') + }) + + it('does not let a marked stylesheet justify keeping the class it selects', () => { + // The marked sheet is scrubbed, so its selectors are not part of the + // snapshot's vocabulary: honouring them would launder the very token the + // author marked the sheet to withhold. + const doc = docFrom( + `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).not.toContain('salary-92000') + }) + + it('keeps only the class and id tokens the snapshot styles', () => { + // `class` and `id` stay on a redacted element so its box keeps its shape, + // but the values are the app's to choose and can name what the marker hides. + const doc = docFrom( + `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).toContain('class="card"') + expect(html).not.toContain('customer-acme-secret') + expect(html).not.toContain('salary-92000') + }) + + it('withholds even the state of a redacted control', () => { + // Whether a marked box is ticked is exactly what the marker exists to hide; + // the step's value is masked to match, so label and snapshot agree. + const doc = docFrom(``) + const box = doc.querySelector('input') as HTMLInputElement + box.checked = true + + const html = serializeDocument(doc) + expect(html).not.toContain('checked') + expect(html).not.toContain('acquisition target') + }) + + it('does not launder a marked stylesheet into the snapshot by inlining it', () => { + // The inliner exists for sheets whose rules live only in the CSSOM (an empty + // `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).toContain('class="2xl:block !flex"') + }) + + it('keeps a utility class whose selector is escaped', () => { + // A framework writes `md:flex` as `.md\\:flex`; reading the selector up to the + // backslash would drop the real token and leave the placeholder unstyled. + const doc = docFrom( + `` + + `
x
` + ) + const html = serializeDocument(doc) + expect(html).toContain('class="md:flex w-1/2"') + expect(html).not.toContain('not-styled-92000') + }) + + it('paints a canvas into the snapshot, but never a redacted one', () => { + // A canvas keeps its picture in a bitmap `outerHTML` cannot see, so without + // this the chart replays blank. A marked one must stay blank: the painted + // background rides on `style`, which redaction strips. + const doc = docFrom( + `
` + ) + for (const c of Array.from(doc.querySelectorAll('canvas'))) { + Object.defineProperty(c, 'width', { value: 200 }) + Object.defineProperty(c, 'height', { value: 100 }) + Object.defineProperty(c, 'toDataURL', { + value: () => `data:image/webp;base64,PIXELS-${c.id}` + }) + Object.defineProperty(c, 'getBoundingClientRect', { + value: () => ({ width: 200, height: 100 }) + }) + } + + const html = serializeDocument(doc) + expect(html).toContain('PIXELS-chart') + expect(html).not.toContain('PIXELS-secret') + }) + + it('stops encoding once a snapshot has spent its canvas budget', () => { + // Encoding is synchronous and on the app's event path, so a wall of charts + // must not each cost an encode — the per-canvas cap alone would allow it. + const doc = docFrom( + Array.from({ length: 6 }, (_, i) => ``).join('') + ) + const encoded: string[] = [] + for (const c of Array.from(doc.querySelectorAll('canvas'))) { + Object.defineProperty(c, 'width', { value: 2000 }) + Object.defineProperty(c, 'height', { value: 1500 }) // 3M pixels each + Object.defineProperty(c, 'getBoundingClientRect', { + value: () => ({ width: 200, height: 150 }) + }) + Object.defineProperty(c, 'toDataURL', { + value: () => { + encoded.push(c.id) + return `data:image/webp;base64,PIXELS-${c.id}` + } + }) + } + + serializeDocument(doc) + expect(encoded).toEqual(['c0', 'c1']) + }) + + it('keeps a disabled sheet inert without shifting what follows it', () => { + // Neutralizing a disabled sheet must not remove its node: every later path + // resolution — other sheets, and the target stamp — is by sibling index. + const off = document.createElement('style') + off.textContent = `.disabled-rule { color: red; }` + const on = document.createElement('style') + document.head.append(off, on) + try { + off.sheet!.disabled = true + on.sheet?.insertRule(`.live-rule { color: green; }`, 0) + + const html = serializeDocument(document) + expect(html).toContain('live-rule') + expect(html).not.toContain('disabled-rule') + expect(html).toContain('media="not all"') + } finally { + off.remove() + on.remove() + } + }) + + it('drops
+ {/if} +
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ac927801dc..2f42a1889d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -138,9 +138,20 @@ const config = { name: 'server', environment: 'node', include: ['src/**/*.{test,spec}.{js,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}', 'src/**/*.dom.{test,spec}.{js,ts}'], setupFiles: ['src/lib/test-setup.ts'] } + }, + { + // `*.dom.test.ts` — for the pure DOM utilities (snapshot serialization, + // replay sanitization) whose contracts can only be asserted against a + // real document. + extends: './vite.config.js', + test: { + name: 'dom', + environment: 'jsdom', + include: ['src/**/*.dom.{test,spec}.{js,ts}'] + } } ] } diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index cb933dfb0d..1d8449817f 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -647,6 +647,18 @@ const user = await backend.get_user({ user_id: '123' }); The frontend cannot reach datatables, workspace items, or external services on its own — it goes through \`backend.(args)\` for everything server-side. +### Keeping data out of recorded demos + +An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with \`data-wm-no-record\` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata: + +\`\`\`tsx + +\`\`\` + +Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded. + ## Backend runnables Each runnable has a unique key (used to call it from the frontend) and one of four types: @@ -749,6 +761,7 @@ def main(user_id: str): 3. **Keep runnables focused** — one function per runnable; small surface area. 4. **Use descriptive keys** — \`get_user\`, not \`a\`. 5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first. +6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. `; export const PIPELINE_BASE = `# Data pipeline authoring diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 2251af985f..d3c3c78bc3 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -284,6 +284,18 @@ const user = await backend.get_user({ user_id: '123' }); The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.(args)` for everything server-side. +### Keeping data out of recorded demos + +An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata: + +```tsx + +``` + +Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded. + ## Backend runnables Each runnable has a unique key (used to call it from the frontend) and one of four types: @@ -386,3 +398,4 @@ def main(user_id: str): 3. **Keep runnables focused** — one function per runnable; small surface area. 4. **Use descriptive keys** — `get_user`, not `a`. 5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first. +6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 63f26de115..622484ad69 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -49,6 +49,18 @@ const user = await backend.get_user({ user_id: '123' }); The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.(args)` for everything server-side. +### Keeping data out of recorded demos + +An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata: + +```tsx + +``` + +Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded. + ## Backend runnables Each runnable has a unique key (used to call it from the frontend) and one of four types: @@ -151,3 +163,4 @@ def main(user_id: str): 3. **Keep runnables focused** — one function per runnable; small surface area. 4. **Use descriptive keys** — `get_user`, not `a`. 5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first. +6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. From 3a08656dad656049b029255ac3374ee0a2e870d6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 26 Jul 2026 11:56:50 +0200 Subject: [PATCH 005/400] chore(main): release 1.771.0 (#10316) * chore(main): release 1.771.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 18 ++ backend/Cargo.lock | 162 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 54 +++++- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 187 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb6624a0b..1743e76555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.771.0](https://github.com/windmill-labs/windmill/compare/v1.770.0...v1.771.0) (2026-07-26) + + +### Features + +* bind WAC approval urls to a named wait_for_approval step ([#10317](https://github.com/windmill-labs/windmill/issues/10317)) ([9cef724](https://github.com/windmill-labs/windmill/commit/9cef724ff2fcae7aea7b11582ce18529f589665d)) +* make bigquery and snowflake script languages available in CE ([#10324](https://github.com/windmill-labs/windmill/issues/10324)) ([a8455ac](https://github.com/windmill-labs/windmill/commit/a8455acd7dd11a79f775a401275a128f206cace7)) +* mark failed jobs as resolved so handled failures stop showing red ([#10319](https://github.com/windmill-labs/windmill/issues/10319)) ([4d3ff02](https://github.com/windmill-labs/windmill/commit/4d3ff0299fed656c0e3a498d463375047de47d39)) +* multiple homepage sort orders via an efficient merged runnables endpoint ([#10297](https://github.com/windmill-labs/windmill/issues/10297)) ([71b7135](https://github.com/windmill-labs/windmill/commit/71b7135cf2457034949540a6c1568424ce9ba8b5)) +* record and replay raw app sessions step by step ([#10318](https://github.com/windmill-labs/windmill/issues/10318)) ([e80fee8](https://github.com/windmill-labs/windmill/commit/e80fee86b3d97268efbabf21787f439b7b52f30d)) + + +### Bug Fixes + +* **frontend:** pin sveltekit version.name so builds are reproducible across architectures ([#10315](https://github.com/windmill-labs/windmill/issues/10315)) ([65db58b](https://github.com/windmill-labs/windmill/commit/65db58bfdaf50009931e3594bd55c5d6c4c07332)) +* operators cannot archive or delete flows and apps ([#10322](https://github.com/windmill-labs/windmill/issues/10322)) ([2bf7746](https://github.com/windmill-labs/windmill/commit/2bf7746cdd299d99da7f4b48c4e24891efb42ae5)) +* scope cd in parser wasm dev.nu so cli install path resolves ([#10329](https://github.com/windmill-labs/windmill/issues/10329)) ([80ad357](https://github.com/windmill-labs/windmill/commit/80ad357c067b1716694a4005934dcb5a038308ef)) + ## [1.770.0](https://github.com/windmill-labs/windmill/compare/v1.769.0...v1.770.0) (2026-07-24) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 05435680b0..7a79df03e1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -4660,9 +4660,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" dependencies = [ "serde", ] @@ -14479,7 +14479,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-nats", @@ -14564,7 +14564,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.770.0" +version = "1.771.0" dependencies = [ "async-stream", "async-trait", @@ -14597,7 +14597,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14610,7 +14610,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "argon2", @@ -14749,7 +14749,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14772,7 +14772,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14787,7 +14787,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14813,7 +14813,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.770.0" +version = "1.771.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14823,7 +14823,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14840,7 +14840,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14862,7 +14862,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14901,7 +14901,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14922,7 +14922,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14957,7 +14957,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-nats", @@ -14992,7 +14992,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15017,7 +15017,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "flate2", @@ -15035,7 +15035,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15057,7 +15057,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15077,7 +15077,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15142,7 +15142,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.770.0" +version = "1.771.0" dependencies = [ "lazy_static", "serde", @@ -15154,7 +15154,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.770.0" +version = "1.771.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15179,7 +15179,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15193,7 +15193,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.770.0" +version = "1.771.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15228,7 +15228,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.770.0" +version = "1.771.0" dependencies = [ "chrono", "lazy_static", @@ -15242,7 +15242,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15261,7 +15261,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.770.0" +version = "1.771.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.770.0" +version = "1.771.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -15384,7 +15384,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.770.0" +version = "1.771.0" dependencies = [ "regex", "serde", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15423,7 +15423,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "futures", @@ -15440,7 +15440,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.770.0" +version = "1.771.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15456,7 +15456,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -15477,7 +15477,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "arc-swap", @@ -15533,7 +15533,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-stream", @@ -15567,7 +15567,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "futures", @@ -15585,7 +15585,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.770.0" +version = "1.771.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -15606,7 +15606,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde_json", @@ -15618,7 +15618,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "gosyn", @@ -15630,7 +15630,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -15642,7 +15642,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde_json", @@ -15654,7 +15654,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "nu-parser", @@ -15665,7 +15665,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15676,7 +15676,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15688,7 +15688,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15699,7 +15699,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-recursion", @@ -15721,7 +15721,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde_json", @@ -15733,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -15747,7 +15747,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15764,7 +15764,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -15777,7 +15777,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde", @@ -15789,7 +15789,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -15807,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15823,7 +15823,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15839,7 +15839,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde", @@ -15850,7 +15850,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-recursion", @@ -15889,7 +15889,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "const_format", @@ -15929,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.770.0" +version = "1.771.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15940,7 +15940,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-recursion", @@ -15974,7 +15974,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -15998,7 +15998,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16031,7 +16031,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16058,7 +16058,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16091,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16111,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16145,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16181,7 +16181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16204,7 +16204,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16228,7 +16228,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-nats", @@ -16252,7 +16252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16287,7 +16287,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16315,7 +16315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-trait", @@ -16340,7 +16340,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16359,7 +16359,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-once-cell", @@ -16474,7 +16474,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.770.0" +version = "1.771.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b10de4017f..c7e47d3db9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.770.0" +version = "1.771.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.770.0" +version = "1.771.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 8a0052d2c7..06003deffc 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.770.0" +version = "1.771.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.770.0" +version = "1.771.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.770.0" +version = "1.771.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.770.0" +version = "1.771.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 7c866fbd30..20f9a743cd 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.770.0" +version = "1.771.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2b611188e0..87f54e8a85 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.770.0 + version: 1.771.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index debc81ac94..afefb427dc 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.770.0"; +export const VERSION = "v1.771.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index eb25ef24ea..05550625a7 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.770.0"; +export const VERSION = "1.771.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a75c157e8..f69ec047a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.770.0", + "version": "1.771.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.770.0", + "version": "1.771.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -1102,6 +1102,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1113,6 +1114,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1123,6 +1125,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1656,6 +1659,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1804,6 +1808,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1820,6 +1825,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1836,6 +1842,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1852,6 +1859,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1868,6 +1876,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1884,6 +1893,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1900,6 +1910,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1916,6 +1927,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1932,6 +1944,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1948,6 +1961,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1964,6 +1978,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1980,6 +1995,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1996,6 +2012,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2014,6 +2031,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2030,6 +2048,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2335,6 +2354,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7659,7 +7679,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -8355,6 +8375,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8375,6 +8396,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8395,6 +8417,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8415,6 +8438,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8435,6 +8459,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8455,6 +8480,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8475,6 +8501,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8495,6 +8522,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8515,6 +8543,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8535,6 +8564,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8555,6 +8585,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13261,6 +13292,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -14040,7 +14086,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index caebcdfe86..1ff9c8bc99 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.770.0", + "version": "1.771.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 30119c0328..db7c7491b8 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.770.0" +wmill = ">=1.771.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 4344fc4d1e..f155b9b79a 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.770.0 + version: 1.771.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 1f103d3671..75a7f28502 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.770.0' + ModuleVersion = '1.771.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index e146031b92..ff83ff85d8 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.770.0" +version = "1.771.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index eeefd19a4c..1b61618711 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.770.0", + "version": "1.771.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 968f4ed8e3..a9b7cc7137 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.770.0", + "version": "1.771.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index dc6e88537c..32bde184ab 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.770.0 +1.771.0 From 71575bf941eaf854090594dc248f0477b7d16f55 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 26 Jul 2026 14:07:55 +0200 Subject: [PATCH 006/400] chore: remove the unreachable hub raw-app embed proxy (#10332) The raw-app session recorder replaced the live-iframe demo, and removing `Share as iframe` took the only caller of this proxy with it. Nothing in the frontend, the CLI or the backend can reach `publish_raw_app_embed` any more, so it is an authenticated route kept alive for no consumer. The Hub still stores and renders `external_embed_url` for the raw apps that already carry one, and still exposes its own editors for it; this only drops Windmill's write path, which no longer has a producer. Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-api/openapi.yaml | 46 ------------------------- backend/windmill-api/src/hub_publish.rs | 16 --------- 2 files changed, 62 deletions(-) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 87f54e8a85..69b62430bc 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -23284,40 +23284,6 @@ paths: schema: type: string - /w/{workspace}/hub/raw_apps/{id}/embed: - post: - summary: set or clear the embed url of a hub raw app - description: | - Requires the caller to be a workspace admin. Forwards the request to the - configured Hub scoped to the `{workspace}:{folder}` source and returns - the Hub's status code and raw response body. - operationId: publishHubRawAppEmbed - tags: - - hubPublish - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - name: id - in: path - required: true - description: hub id of the raw app - schema: - type: integer - format: int64 - - $ref: "#/components/parameters/HubPublishFolder" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RawAppEmbedBody" - responses: - "200": - description: raw Hub response body (status code is passed through from the Hub) - content: - text/plain: - schema: - type: string - /w/{workspace}/hub/raw_apps/{id}/recording: post: summary: attach a recorded session to a hub raw app @@ -32887,18 +32853,6 @@ components: - summary - project_slug - RawAppEmbedBody: - type: object - properties: - external_embed_url: - type: string - nullable: true - description: explicit `null` clears the embed (unpublish) - project_slug: - $ref: "#/components/schemas/HubProjectSlug" - required: - - project_slug - RecordingBody: type: object properties: diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index 697da9407b..033c6ebce5 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -22,7 +22,6 @@ pub fn workspaced_service() -> Router { .route("/flows", post(publish_flow)) .route("/apps", post(publish_app)) .route("/raw_apps", post(publish_raw_app)) - .route("/raw_apps/{id}/embed", post(publish_raw_app_embed)) .route("/raw_apps/{id}/recording", post(publish_raw_app_recording)) .route( "/scripts/{ask_id}/recording", @@ -306,21 +305,6 @@ async fn publish_raw_app( ctx.post("/raw_apps", &body).await } -#[derive(Deserialize, Serialize)] -struct RawAppEmbedBody { - // No skip_serializing_if: `null` must reach the Hub to clear the embed (unpublish). - external_embed_url: Option, - project_slug: ProjectSlug, -} - -async fn publish_raw_app_embed( - ctx: HubPublishCtx, - Path((_workspace, id)): Path<(String, i64)>, - Json(body): Json, -) -> Result { - ctx.post(&format!("/raw_apps/{}/embed", id), &body).await -} - #[derive(Deserialize, Serialize)] struct RecordingBody { #[serde(skip_serializing_if = "Option::is_none")] From 434c4ac7c888e6a0535c13c68d1008c4a4e2b32e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 26 Jul 2026 14:10:02 +0200 Subject: [PATCH 007/400] chore: pin ruff to 0.16.0 and keep the python editor rule set stable (#10331) * chore: pin ruff to 0.16.0 and keep the python editor rule set stable * chore: keep the ruff config path rationale at a single site --- docker/DockerfileExtra | 2 +- frontend/src/lib/components/Editor.svelte | 10 ++- .../src/lib/components/instanceSettings.ts | 2 +- lsp/Dockerfile | 2 +- lsp/pyls_launcher.py | 64 +++++++++++-------- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index b0e4bd8ad7..808bc6ed2a 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -68,7 +68,7 @@ ENV PIPENV_VENV_IN_PROJECT=1 ENV XDG_CACHE_HOME=/pyls/.cache # Install Python packages for LSP using uv -RUN uv pip install --system --break-system-packages pipenv tornado python-lsp-jsonrpc ruff Cython +RUN uv pip install --system --break-system-packages pipenv tornado python-lsp-jsonrpc ruff==0.16.0 Cython # Install Node-based language servers RUN npm install -g diagnostic-languageserver pyright diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 8563190a89..1215f8ed84 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1272,7 +1272,15 @@ } ) - connectToLanguageServer(buildWsUrl('/ws/ruff'), 'ruff', {}, undefined) + // `ruff server` resolves settings per workspace folder, and the folder registered + // below is the document URI rather than a directory containing it, so a ruff.toml + // on disk never applies unless named here (written by lsp/pyls_launcher.py). + connectToLanguageServer( + buildWsUrl('/ws/ruff'), + 'ruff', + { settings: { configuration: '/tmp/monaco/ruff.toml' } }, + undefined + ) } else if (lang === 'go') { connectToLanguageServer( buildWsUrl('/ws/go'), diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 3b892049c6..1e0b267d21 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -970,7 +970,7 @@ export const settings: Record = { { label: 'Ruff config (ruff.toml)', description: - 'Shared ruff.toml applied to the Python editor linter across the whole instance. The LSP container fetches this every minute and writes it next to edited files. See ruff docs', + 'Shared ruff.toml applied to the Python editor linter across the whole instance. The LSP container fetches this every minute and writes it next to edited files. Leave empty to use the Windmill default (select = ["E4", "E7", "E9", "F"]); anything set here replaces that default entirely. See ruff docs', key: 'ruff_config', fieldType: 'codearea', codeAreaLang: 'toml', diff --git a/lsp/Dockerfile b/lsp/Dockerfile index 7efa5e8cd9..b9bba17df2 100644 --- a/lsp/Dockerfile +++ b/lsp/Dockerfile @@ -37,7 +37,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GOBIN=/usr/local/go/bin RUN /usr/local/go/bin/go install -v golang.org/x/tools/gopls@latest -RUN pip3 install tornado python-lsp-jsonrpc ruff +RUN pip3 install tornado python-lsp-jsonrpc ruff==0.16.0 COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno diff --git a/lsp/pyls_launcher.py b/lsp/pyls_launcher.py index aef2dee076..9feec0033a 100644 --- a/lsp/pyls_launcher.py +++ b/lsp/pyls_launcher.py @@ -18,23 +18,47 @@ log = logging.getLogger(__name__) logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) -# Path where ruff (spawned with workspace rooted at /tmp/monaco) will discover -# a ruff.toml. Ruff walks up from the file being linted looking for a -# ruff.toml / .ruff.toml / pyproject.toml, so dropping it here covers every -# python editor session. +# Named explicitly by the python editor's ruff initializationOptions, which must +# stay in sync with this path (Editor.svelte explains why ruff needs it named). RUFF_CONFIG_PATH = "/tmp/monaco/ruff.toml" # How often to re-fetch the instance ruff config from the backend. Existing # ruff server processes won't pick up the change mid-session, but the next # editor reload / WebSocket reconnect will. RUFF_CONFIG_POLL_INTERVAL_SECS = int(os.environ.get("RUFF_CONFIG_POLL_INTERVAL_SECS", "60")) +# Written whenever the instance sets no `ruff_config` of its own. Ruff's own +# defaults grow between releases (0.16 went from 59 to 413 enabled rules), so +# without this every ruff bump would change the diagnostics script authors see. +DEFAULT_RUFF_CONFIG = '[lint]\nselect = ["E4", "E7", "E9", "F"]\n' + + +def _write_ruff_config(content): + """Write content to RUFF_CONFIG_PATH, skipping the write when unchanged.""" + try: + if os.path.exists(RUFF_CONFIG_PATH): + with open(RUFF_CONFIG_PATH, "r") as f: + if f.read() == content: + return + os.makedirs(os.path.dirname(RUFF_CONFIG_PATH), exist_ok=True) + # Rename into place: a `ruff server` spawned mid-write must never read a + # truncated config, which would drop that session onto ruff's defaults. + tmp_path = RUFF_CONFIG_PATH + ".tmp" + with open(tmp_path, "w") as f: + f.write(content) + os.replace(tmp_path, RUFF_CONFIG_PATH) + log.info("Wrote ruff config to %s (%d bytes)", RUFF_CONFIG_PATH, len(content)) + except OSError as e: + log.warning("Could not write ruff config to %s: %s", RUFF_CONFIG_PATH, e) + def _sync_ruff_config_once(): - """Fetch the instance ruff config from the windmill backend and write it - to disk. No-op when WINDMILL_BASE_URL is unset (e.g., running the LSP - standalone for local development).""" + """Fetch the instance ruff config from the windmill backend and write it to + disk, falling back to DEFAULT_RUFF_CONFIG when the instance has none. + Falls back without fetching when WINDMILL_BASE_URL is unset (e.g., running + the LSP standalone for local development).""" base_url = os.environ.get("WINDMILL_BASE_URL") or os.environ.get("BASE_INTERNAL_URL") if not base_url: + _write_ruff_config(DEFAULT_RUFF_CONFIG) return url = base_url.rstrip("/") + "/api/settings_u/ruff_config" try: @@ -44,27 +68,17 @@ def _sync_ruff_config_once(): log.warning("Could not fetch instance ruff config from %s: %s", url, e) return - try: - existing = "" - if os.path.exists(RUFF_CONFIG_PATH): - with open(RUFF_CONFIG_PATH, "r") as f: - existing = f.read() - if existing == body: - return - if body: - os.makedirs(os.path.dirname(RUFF_CONFIG_PATH), exist_ok=True) - with open(RUFF_CONFIG_PATH, "w") as f: - f.write(body) - log.info("Wrote instance ruff config to %s (%d bytes)", RUFF_CONFIG_PATH, len(body)) - elif os.path.exists(RUFF_CONFIG_PATH): - os.remove(RUFF_CONFIG_PATH) - log.info("Removed %s (instance ruff config is empty)", RUFF_CONFIG_PATH) - except OSError as e: - log.warning("Could not write ruff config to %s: %s", RUFF_CONFIG_PATH, e) + # A blank-but-not-empty setting (a stray newline left in the codearea) would + # otherwise parse as a valid config selecting ruff's defaults. + _write_ruff_config(body if body.strip() else DEFAULT_RUFF_CONFIG) def start_ruff_config_poller(): - """Fetch the ruff config once synchronously, then poll in the background.""" + """Seed the default ruff config, fetch the instance override once + synchronously, then poll in the background.""" + # Seeded first so a backend that is unreachable at startup still leaves the + # editor on the pinned rule selection rather than on ruff's own defaults. + _write_ruff_config(DEFAULT_RUFF_CONFIG) _sync_ruff_config_once() def loop(): From 023e85bd634db6ce003f3a5ebe4628f8b93314da Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 26 Jul 2026 23:30:59 +0200 Subject: [PATCH 008/400] fix: open a pipeline step on its code, not its output (#10335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: open a pipeline step on its code, not its output Clicking a script node in a pipeline replay landed on Output. The code is what the step is, and it is the thing a viewer is usually there to read, so open on it and put the Code toggle first. Recordings made before `codes` existed carry no source, and defaulting them to Code would open an empty pane saying nothing was captured, so the default falls back to Output when the step has no recorded source. The reset is keyed on the selected step, so a tab chosen by hand survives until another step is opened. Co-Authored-By: Claude Opus 5 (1M context) * refactor: depend the step-tab reset on the selected path alone untrack the codes lookup so the effect tracks only which step is selected. It could not loop either way — it never reads the tab it writes, and the toggle group's programmatic dispatch settles on an identical value — but the dependency set should say what the reset means: reset on a new step, not on a new recording object. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../recording/PipelineRecordingReplay.svelte | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/recording/PipelineRecordingReplay.svelte b/frontend/src/lib/components/recording/PipelineRecordingReplay.svelte index 033ff211fe..49b3fbfbd4 100644 --- a/frontend/src/lib/components/recording/PipelineRecordingReplay.svelte +++ b/frontend/src/lib/components/recording/PipelineRecordingReplay.svelte @@ -261,12 +261,24 @@ return (shownStatuses[selection.path] ?? finalStatuses[selection.path])?.status }) - // Output (args/logs/result) vs the step's source code, for a runnable node. - let runnableTab = $state<'output' | 'code'>('output') + // The step's source code vs its output (args/logs/result), for a runnable node. + let runnableTab = $state<'output' | 'code'>('code') let selectedCode = $derived.by(() => { if (selection?.kind !== 'runnable') return undefined return recording.codes?.[selection.path] }) + // Land on the code, since that is what the step *is* — but fall back to Output + // when the recording carries none, so a recording made before `codes` existed + // does not open on an empty pane. Depends on the selected path alone: the + // lookup is untracked so a tab the viewer picked by hand survives until they + // move to another step, rather than resetting if the recording object changes. + $effect(() => { + const path = selection?.kind === 'runnable' ? selection.path : undefined + if (path === undefined) return + untrack(() => { + runnableTab = recording.codes?.[path] ? 'code' : 'output' + }) + }) // Recorded data-sample for a selected asset node (ducklake/datatable). let selectedAssetSample = $derived.by(() => { @@ -382,8 +394,8 @@ on:selected={(e) => (runnableTab = e.detail)} > {#snippet children({ item })} - + {/snippet}
From 32c018dc85868741f9f5f2b062cede8d205a327c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 03:04:21 +0200 Subject: [PATCH 009/400] fix: app stepper no longer runs its validation on subgrid focus (#10338) --- .../components/apps/components/layout/AppStepper.svelte | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/apps/components/layout/AppStepper.svelte b/frontend/src/lib/components/apps/components/layout/AppStepper.svelte index 006e792748..ee5919e144 100644 --- a/frontend/src/lib/components/apps/components/layout/AppStepper.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppStepper.svelte @@ -72,11 +72,10 @@ lastAction: undefined as 'previous' | 'next' | undefined }) - async function handleTabSelection() { - if (runnableComponent && !debugMode) { - await runnableComponent?.runComponent() - } - + // Bookkeeping only. The runnable is the step validation function and must run exclusively in + // runStep, where its error gates the navigation: running it here would also fire it on every + // pointerdown in a subgrid, and a second time right after runStep moved to the next step. + function handleTabSelection() { selectedIndex = tabs?.indexOf(selected) if (selectedIndex > maxReachedIndex) { maxReachedIndex = selectedIndex From dc5182f86cdedf0b9056fe817c3efcad04a1df2f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 03:05:42 +0200 Subject: [PATCH 010/400] fix: operators cannot see flows and apps on the homepage (#10340) --- backend/tests/runnables_list_pagination.rs | 74 ++++++++++++++++++++-- backend/windmill-api/src/runnables.rs | 4 -- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/backend/tests/runnables_list_pagination.rs b/backend/tests/runnables_list_pagination.rs index 05655f4561..e9513097cb 100644 --- a/backend/tests/runnables_list_pagination.rs +++ b/backend/tests/runnables_list_pagination.rs @@ -277,11 +277,12 @@ fn new_app(path: &str, summary: &str) -> serde_json::Value { /// A single list request; returns the ordered `type:path` identifiers. async fn list_once(port: u16, query: &str) -> Vec { + list_once_as(port, query, "SECRET_TOKEN").await +} + +async fn list_once_as(port: u16, query: &str, token: &str) -> Vec { let url = format!("http://localhost:{port}/api/w/test-workspace/runnables/list?{query}"); - let resp = authed(client().get(&url), "SECRET_TOKEN") - .send() - .await - .unwrap(); + let resp = authed(client().get(&url), token).send().await.unwrap(); assert_eq!(resp.status(), 200, "list should succeed for {query}"); let body: serde_json::Value = resp.json().await.unwrap(); body["items"] @@ -392,6 +393,71 @@ async fn test_runnables_search_and_kind_filters(db: Pool) -> anyhow::R Ok(()) } +#[sqlx::test(fixtures("base"))] +async fn test_runnables_operator_sees_flows_and_apps(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // A folder the operator can read, holding one runnable of each kind. + let r = authed( + client().post(format!("{base}/folders/create")), + "SECRET_TOKEN", + ) + .json(&json!({ + "name": "opview", + "owners": ["u/test-user"], + "extra_perms": { "u/test-user-3": false }, + })) + .send() + .await?; + assert_eq!(r.status(), 200, "create folder: {}", r.text().await?); + + let r = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script("f/opview/s", "Op script")) + .send() + .await?; + assert_eq!(r.status(), 201, "create script: {}", r.text().await?); + let r = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow("f/opview/f", "Op flow")) + .send() + .await?; + assert_eq!(r.status(), 201, "create flow: {}", r.text().await?); + let r = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") + .json(&new_app("f/opview/a", "Op app")) + .send() + .await?; + assert_eq!(r.status(), 201, "create app: {}", r.text().await?); + + sqlx::query( + "UPDATE usr SET operator = true, role = 'Operator' WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'", + ) + .execute(&db) + .await?; + + // Operators run flows and apps, so the homepage listing must return them, not + // scripts alone. + let mut items = list_once_as(port, "", "SECRET_TOKEN_3").await; + items.sort(); + assert_eq!( + items, + vec![ + "app:f/opview/a".to_string(), + "flow:f/opview/f".to_string(), + "script:f/opview/s".to_string(), + ], + "an operator must see every kind they have read access to" + ); + Ok(()) +} + #[sqlx::test(fixtures("base"))] async fn test_runnables_starred_pinned_first(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 489c0d5dd5..02d28f06e9 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -276,10 +276,6 @@ async fn list_runnables( if show_archived { kinds.retain(|k| *k != "app"); } - // Operators may only see scripts. - if authed.is_operator { - kinds.retain(|k| *k == "script"); - } let branches = branch_sqls(); From 907141152ee062d9b51c12d4e8745dba35fb5491 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 03:14:35 +0200 Subject: [PATCH 011/400] chore(main): release 1.771.1 (#10336) * chore(main): release 1.771.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 9 + backend/Cargo.lock | 158 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 128 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1743e76555..48d6e345c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.771.1](https://github.com/windmill-labs/windmill/compare/v1.771.0...v1.771.1) (2026-07-27) + + +### Bug Fixes + +* app stepper no longer runs its validation on subgrid focus ([#10338](https://github.com/windmill-labs/windmill/issues/10338)) ([32c018d](https://github.com/windmill-labs/windmill/commit/32c018dc85868741f9f5f2b062cede8d205a327c)) +* open a pipeline step on its code, not its output ([#10335](https://github.com/windmill-labs/windmill/issues/10335)) ([023e85b](https://github.com/windmill-labs/windmill/commit/023e85bd634db6ce003f3a5ebe4628f8b93314da)) +* operators cannot see flows and apps on the homepage ([#10340](https://github.com/windmill-labs/windmill/issues/10340)) ([dc5182f](https://github.com/windmill-labs/windmill/commit/dc5182f86cdedf0b9056fe817c3efcad04a1df2f)) + ## [1.771.0](https://github.com/windmill-labs/windmill/compare/v1.770.0...v1.771.0) (2026-07-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7a79df03e1..57dc77a19b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14479,7 +14479,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-nats", @@ -14564,7 +14564,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.771.0" +version = "1.771.1" dependencies = [ "async-stream", "async-trait", @@ -14597,7 +14597,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14610,7 +14610,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "argon2", @@ -14749,7 +14749,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14772,7 +14772,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14787,7 +14787,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14813,7 +14813,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.771.0" +version = "1.771.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14823,7 +14823,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14840,7 +14840,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14862,7 +14862,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14901,7 +14901,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14922,7 +14922,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14943,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14957,7 +14957,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-nats", @@ -14992,7 +14992,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15017,7 +15017,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "flate2", @@ -15035,7 +15035,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15057,7 +15057,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15077,7 +15077,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15142,7 +15142,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.771.0" +version = "1.771.1" dependencies = [ "lazy_static", "serde", @@ -15154,7 +15154,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.771.0" +version = "1.771.1" dependencies = [ "argon2", "axum 0.8.9", @@ -15179,7 +15179,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15193,7 +15193,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.771.0" +version = "1.771.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15228,7 +15228,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.771.0" +version = "1.771.1" dependencies = [ "chrono", "lazy_static", @@ -15242,7 +15242,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15261,7 +15261,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.771.0" +version = "1.771.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.771.0" +version = "1.771.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -15384,7 +15384,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.771.0" +version = "1.771.1" dependencies = [ "regex", "serde", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15423,7 +15423,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "futures", @@ -15440,7 +15440,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.771.0" +version = "1.771.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15456,7 +15456,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -15477,7 +15477,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "arc-swap", @@ -15533,7 +15533,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-stream", @@ -15567,7 +15567,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "futures", @@ -15585,7 +15585,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.771.0" +version = "1.771.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -15606,7 +15606,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde_json", @@ -15618,7 +15618,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "gosyn", @@ -15630,7 +15630,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -15642,7 +15642,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde_json", @@ -15654,7 +15654,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "nu-parser", @@ -15665,7 +15665,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15676,7 +15676,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15688,7 +15688,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15699,7 +15699,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-recursion", @@ -15721,7 +15721,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde_json", @@ -15733,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -15747,7 +15747,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15764,7 +15764,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -15777,7 +15777,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde", @@ -15789,7 +15789,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -15807,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15823,7 +15823,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15839,7 +15839,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde", @@ -15850,7 +15850,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-recursion", @@ -15889,7 +15889,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "const_format", @@ -15929,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.771.0" +version = "1.771.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15940,7 +15940,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-recursion", @@ -15974,7 +15974,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -15998,7 +15998,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16031,7 +16031,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16058,7 +16058,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16091,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16111,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16145,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16181,7 +16181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16204,7 +16204,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16228,7 +16228,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-nats", @@ -16252,7 +16252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16287,7 +16287,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16315,7 +16315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-trait", @@ -16340,7 +16340,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16359,7 +16359,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-once-cell", @@ -16474,7 +16474,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.771.0" +version = "1.771.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c7e47d3db9..5e99240ad9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.771.0" +version = "1.771.1" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.771.0" +version = "1.771.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 06003deffc..bfaf3782c8 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.771.0" +version = "1.771.1" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.771.0" +version = "1.771.1" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.771.0" +version = "1.771.1" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.771.0" +version = "1.771.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 20f9a743cd..d2c0d0138b 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.771.0" +version = "1.771.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 69b62430bc..15bc74920c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.771.0 + version: 1.771.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index afefb427dc..15cfa7f4e9 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.771.0"; +export const VERSION = "v1.771.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 05550625a7..37272edfa8 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.771.0"; +export const VERSION = "1.771.1"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f69ec047a3..a9e5206df4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.771.0", + "version": "1.771.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.771.0", + "version": "1.771.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 1ff9c8bc99..784b628813 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.771.0", + "version": "1.771.1", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index db7c7491b8..44795c1e82 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.771.0" +wmill = ">=1.771.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index f155b9b79a..d9782b2dd6 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.771.0 + version: 1.771.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 75a7f28502..8bf422a0b6 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.771.0' + ModuleVersion = '1.771.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ff83ff85d8..bd265e9641 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.771.0" +version = "1.771.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 1b61618711..d595b20b9e 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.771.0", + "version": "1.771.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index a9b7cc7137..0e5f208bf1 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.771.0", + "version": "1.771.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 32bde184ab..b07a8d204f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.771.0 +1.771.1 From 0f62891d4310d305b599bfcd18fec4dc520ab8fc Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 27 Jul 2026 10:42:33 +0200 Subject: [PATCH 012/400] fix(ai): stop teaching nonexistent while-loop iter.value state-carrying (#10345) * fix(ai): stop teaching nonexistent while-loop iter.value state-carrying Co-Authored-By: Claude Fable 5 * fix(ai): scope while-loop results guidance to cross-iteration reads only Co-Authored-By: Claude Fable 5 * fix(ai): drop unverified wmill state-helper fallback from while-loop guidance Co-Authored-By: Claude Fable 5 * fix(ai): document supported cross-iteration results state in while loops Co-Authored-By: Claude Fable 5 * fix(ai): rescope while-loop fast-path rule and add results-carrying example Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- ai_evals/cases/flow.yaml | 10 ++-- .../expected/test10_while_loop_counter.json | 47 ------------------- cli/src/guidance/skills.gen.ts | 40 ++++++++++++---- .../lib/components/copilot/chat/flow/core.ts | 42 +++++++++++++++++ .../copilot/chat/flow/openFlow.json | 2 +- .../copilot/chat/flow/openFlowZod.gen.ts | 6 +-- openflow.openapi.yaml | 6 +-- system_prompts/auto-generated/flow.md | 40 ++++++++++++---- system_prompts/auto-generated/prompts.ts | 40 ++++++++++++---- .../auto-generated/skills/write-flow/SKILL.md | 40 ++++++++++++---- system_prompts/base/flow-base.md | 38 +++++++++++---- 11 files changed, 206 insertions(+), 105 deletions(-) delete mode 100644 ai_evals/fixtures/frontend/flow/expected/test10_while_loop_counter.json diff --git a/ai_evals/cases/flow.yaml b/ai_evals/cases/flow.yaml index fa2ca3bee7..3a4f203b48 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -305,23 +305,19 @@ type: rawscript moduleRules: - id: count_until_target - hasStopAfterIf: true hasStopAfterAllItersIf: false exactImmediateChildStepIds: - increment_counter immediateChildStepTypes: - id: increment_counter type: rawscript - moduleFieldRules: - - id: count_until_target - path: stop_after_if.expr - equals: result >= flow_input.target judgeChecklist: - "the input schema includes a number field named `target`" - "the top-level while loop step is named `count_until_target`" - "`count_until_target` contains a single increment step named `increment_counter`" - - "`count_until_target` uses module-level `stop_after_if` to stop when the counter reaches `target`" - - "`increment_counter` uses `flow_input.iter.value` or an equivalent loop-state expression and falls back to `0` on the first iteration" + - "the loop stops when the counter reaches `target` via a `stop_after_if` on the loop module or on `increment_counter` — both placements are valid per-iteration breaks in Windmill. Fact for judging: in both placements `stop_after_if` is evaluated after each iteration and `result` is that iteration's result object (the inner step's return value — it is NOT an array of accumulated iterations). Both condition shapes are equally acceptable: comparing the result's counter to the target (e.g. `result.counter >= flow_input.target`) or checking a boolean the step returns (e.g. `result.done === true`). Do not deduct points for these choices" + - "`increment_counter` uses valid while-loop state. A counter derived from the iteration index (`flow_input.iter.index` or `flow_input.iter.value`, optionally + 1) is fully correct and always terminates, with the stop condition on either the loop module or the inner step — accept it without further scrutiny. Carrying state via `results.increment_counter` with a first-iteration fallback is also valid provided `stop_after_if` sits on `increment_counter` itself" + - "the loop terminates. Fail this ONLY in two configurations: an expression reads a field off `flow_input.iter.value` (it is a plain number, so e.g. `flow_input.iter.value.counter` never advances), or the single-step body reads `results.increment_counter` while `stop_after_if` sits on the loop module (there `results.increment_counter` is null every iteration). Otherwise pass it — do not invent additional termination concerns" - "`return_final_counter` returns the final counter value" - id: flow-test11-preprocessor-and-failure-handler diff --git a/ai_evals/fixtures/frontend/flow/expected/test10_while_loop_counter.json b/ai_evals/fixtures/frontend/flow/expected/test10_while_loop_counter.json deleted file mode 100644 index 6bcd8671fb..0000000000 --- a/ai_evals/fixtures/frontend/flow/expected/test10_while_loop_counter.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "value": { - "modules": [ - { - "id": "count_until_target", - "value": { - "type": "whileloopflow", - "skip_failures": false, - "modules": [ - { - "id": "increment_counter", - "value": { - "type": "rawscript", - "language": "bun" - } - } - ] - }, - "stop_after_if": { - "expr": "result >= flow_input.target", - "skip_if_stopped": false - } - }, - { - "id": "return_final_counter", - "value": { - "type": "rawscript" - } - } - ] - }, - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "target": { - "type": "number" - } - }, - "required": [ - "target" - ], - "order": [ - "target" - ] - } -} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index b85ae6fec9..f28d1c67ad 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5185,16 +5185,17 @@ value: - \`flow_input.property\` - Access flow input parameters - \`results.step_id\` - Access output from a previous step only when that step result is in scope - \`results.step_id.property\` - Access specific property from a previous step output only when that step result is in scope -- \`flow_input.iter.value\` - Current iteration value when inside a loop (\`forloopflow\` or \`whileloopflow\`) +- \`flow_input.iter.value\` - Current iteration value inside a \`forloopflow\`; in a \`whileloopflow\` it is just the iteration index (a plain number, same as \`flow_input.iter.index\`) - \`flow_input.iter.index\` - Current loop index when inside a loop (\`forloopflow\` or \`whileloopflow\`) ## Loop Structure Rules -- For \`whileloopflow\`, use module-level \`stop_after_if\` on the loop module itself when the loop should stop after an iteration result -- Do NOT put \`stop_after_if\` inside \`value\` of a \`whileloopflow\` +- For \`whileloopflow\`, break the loop with a module-level \`stop_after_if\`: on the loop module itself, or on an inner step (required when that step carries state via its own \`results\` — see below) +- \`stop_after_if\` is always a sibling of \`id\` and \`value\` on a flow module — never a direct key of the loop's \`value\` object - \`stop_after_all_iters_if\` is for checks after the whole loop finishes, not the normal per-iteration break condition -- When a \`whileloopflow\` carries state forward between iterations, use \`flow_input.iter.value\` as the current loop value and provide an explicit first-iteration fallback when needed -- Use \`flow_input.iter.index\` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value +- \`flow_input.iter.value\` in a \`whileloopflow\` is just the iteration index (same number as \`flow_input.iter.index\`) — it never carries state, so \`flow_input.iter.value.\` is always undefined and a loop whose stop condition depends on it never terminates +- To carry state across iterations, a step reads its own previous-iteration result via \`results.\` with a first-iteration fallback (e.g. \`results.b ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.\` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own \`stop_after_if\`, retry or similar, resolve \`results\` across iterations regardless of stop placement) +- For state that is just a counter, derive it from the index instead (e.g. \`flow_input.iter.index + 1\`) — that works in every configuration, including with \`stop_after_if\` on the loop module - If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array Correct \`whileloopflow\` shape: @@ -5212,9 +5213,9 @@ Correct \`whileloopflow\` shape: value: type: rawscript input_transforms: - state: + count: type: javascript - expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state + expr: flow_input.iter.index + 1 - id: return_final_state value: type: rawscript @@ -5224,6 +5225,26 @@ Correct \`whileloopflow\` shape: expr: results.loop_until_done[results.loop_until_done.length - 1] \`\`\` +Correct \`whileloopflow\` shape carrying state via \`results\` (stop condition on the inner step): + +\`\`\`yaml +- id: loop_until_done + value: + type: whileloopflow + skip_failures: false + modules: + - id: advance_state + stop_after_if: + expr: result.done === true + skip_if_stopped: false + value: + type: rawscript + input_transforms: + state: + type: javascript + expr: results.advance_state ?? flow_input.initial_state +\`\`\` + Incorrect \`whileloopflow\` patterns: \`\`\`yaml @@ -5238,7 +5259,8 @@ Incorrect \`whileloopflow\` patterns: input_transforms: state: type: javascript - expr: flow_input.iter.index + # iter.value is a number (the iteration index); there is no previous-iteration state + expr: flow_input.iter.value.count \`\`\` \`\`\`yaml @@ -5432,7 +5454,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."},"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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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 until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"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/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index ff4143fe98..2e6fea35a7 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -974,6 +974,48 @@ set_flow_json({ }) \`\`\` +**Example - Flow with while loop:** + +In a while loop, \`flow_input.iter.value\` equals \`flow_input.iter.index\` (a plain number: 0, 1, 2, ...) — it never carries state, so \`flow_input.iter.value.count\` is always undefined and a counter built on it never advances. To carry state across iterations, a step reads its own previous-iteration result via \`results.\` with a first-iteration fallback (e.g. \`results.tick ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.\` is null every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own \`stop_after_if\`, retry or similar, resolve \`results\` across iterations regardless of stop placement). For plain counters, deriving from \`flow_input.iter.index\` works in every configuration. \`stop_after_if\` is evaluated after each iteration — on the loop module \`result\` is the last iteration's result (the return of the iteration's final step); on an inner step it is that step's result. + +\`\`\`javascript +set_flow_json({ + modules: [ + { + id: "count_up", + summary: "Increment until target", + value: { + type: "whileloopflow", + skip_failures: false, + modules: [ + { + id: "tick", + summary: "Compute current count", + value: { + type: "rawscript", + language: "bun", + content: "export async function main(count: number, target: number) { return { count, done: count >= target }; }", + input_transforms: { + count: { type: "javascript", expr: "flow_input.iter.index + 1" }, + target: { type: "javascript", expr: "flow_input.target" } + } + } + } + ] + }, + stop_after_if: { expr: "result.done", skip_if_stopped: false } + } + ], + schema: { + type: "object", + properties: { + target: { type: "number", description: "Stop when the count reaches this value" } + }, + required: ["target"] + } +}) +\`\`\` + **Example - Flow with branches (branchone):** \`\`\`javascript set_flow_json({ diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index 539a0e89e5..110cc49301 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.756.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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"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.765.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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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 until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"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 5a30bedf78..cce01b6244 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","bunnative","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","azure_foundry","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')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).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, reasoning_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(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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","bunnative","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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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"), "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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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","azure_foundry","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')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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, reasoning_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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").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"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "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","bunnative","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","azure_foundry","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')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).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, reasoning_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(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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","bunnative","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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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"), "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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result"), 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","azure_foundry","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')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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, reasoning_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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").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"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "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") + }).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"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), 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/openflow.openapi.yaml b/openflow.openapi.yaml index d9782b2dd6..f637abff29 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -421,7 +421,7 @@ components: 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 + description: JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index') properties: expr: type: string @@ -826,11 +826,11 @@ components: 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 + description: Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result properties: modules: type: array - description: Steps to execute in each iteration. Use stop_after_if to control when the loop ends + description: Steps to execute in each iteration items: $ref: '#/components/schemas/FlowModule' skip_failures: diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index d9b4cc03b8..61dedbae72 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -74,16 +74,17 @@ value: - `flow_input.property` - Access flow input parameters - `results.step_id` - Access output from a previous step only when that step result is in scope - `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope -- `flow_input.iter.value` - Current iteration value when inside a loop (`forloopflow` or `whileloopflow`) +- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`) - `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`) ## Loop Structure Rules -- For `whileloopflow`, use module-level `stop_after_if` on the loop module itself when the loop should stop after an iteration result -- Do NOT put `stop_after_if` inside `value` of a `whileloopflow` +- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below) +- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object - `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition -- When a `whileloopflow` carries state forward between iterations, use `flow_input.iter.value` as the current loop value and provide an explicit first-iteration fallback when needed -- Use `flow_input.iter.index` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value +- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates +- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement) +- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module - If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array Correct `whileloopflow` shape: @@ -101,9 +102,9 @@ Correct `whileloopflow` shape: value: type: rawscript input_transforms: - state: + count: type: javascript - expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state + expr: flow_input.iter.index + 1 - id: return_final_state value: type: rawscript @@ -113,6 +114,26 @@ Correct `whileloopflow` shape: expr: results.loop_until_done[results.loop_until_done.length - 1] ``` +Correct `whileloopflow` shape carrying state via `results` (stop condition on the inner step): + +```yaml +- id: loop_until_done + value: + type: whileloopflow + skip_failures: false + modules: + - id: advance_state + stop_after_if: + expr: result.done === true + skip_if_stopped: false + value: + type: rawscript + input_transforms: + state: + type: javascript + expr: results.advance_state ?? flow_input.initial_state +``` + Incorrect `whileloopflow` patterns: ```yaml @@ -127,7 +148,8 @@ Incorrect `whileloopflow` patterns: input_transforms: state: type: javascript - expr: flow_input.iter.index + # iter.value is a number (the iteration index); there is no previous-iteration state + expr: flow_input.iter.value.count ``` ```yaml @@ -321,4 +343,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."},"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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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 until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"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 1d8449817f..bf505748e1 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -105,16 +105,17 @@ value: - \`flow_input.property\` - Access flow input parameters - \`results.step_id\` - Access output from a previous step only when that step result is in scope - \`results.step_id.property\` - Access specific property from a previous step output only when that step result is in scope -- \`flow_input.iter.value\` - Current iteration value when inside a loop (\`forloopflow\` or \`whileloopflow\`) +- \`flow_input.iter.value\` - Current iteration value inside a \`forloopflow\`; in a \`whileloopflow\` it is just the iteration index (a plain number, same as \`flow_input.iter.index\`) - \`flow_input.iter.index\` - Current loop index when inside a loop (\`forloopflow\` or \`whileloopflow\`) ## Loop Structure Rules -- For \`whileloopflow\`, use module-level \`stop_after_if\` on the loop module itself when the loop should stop after an iteration result -- Do NOT put \`stop_after_if\` inside \`value\` of a \`whileloopflow\` +- For \`whileloopflow\`, break the loop with a module-level \`stop_after_if\`: on the loop module itself, or on an inner step (required when that step carries state via its own \`results\` — see below) +- \`stop_after_if\` is always a sibling of \`id\` and \`value\` on a flow module — never a direct key of the loop's \`value\` object - \`stop_after_all_iters_if\` is for checks after the whole loop finishes, not the normal per-iteration break condition -- When a \`whileloopflow\` carries state forward between iterations, use \`flow_input.iter.value\` as the current loop value and provide an explicit first-iteration fallback when needed -- Use \`flow_input.iter.index\` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value +- \`flow_input.iter.value\` in a \`whileloopflow\` is just the iteration index (same number as \`flow_input.iter.index\`) — it never carries state, so \`flow_input.iter.value.\` is always undefined and a loop whose stop condition depends on it never terminates +- To carry state across iterations, a step reads its own previous-iteration result via \`results.\` with a first-iteration fallback (e.g. \`results.b ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.\` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own \`stop_after_if\`, retry or similar, resolve \`results\` across iterations regardless of stop placement) +- For state that is just a counter, derive it from the index instead (e.g. \`flow_input.iter.index + 1\`) — that works in every configuration, including with \`stop_after_if\` on the loop module - If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array Correct \`whileloopflow\` shape: @@ -132,9 +133,9 @@ Correct \`whileloopflow\` shape: value: type: rawscript input_transforms: - state: + count: type: javascript - expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state + expr: flow_input.iter.index + 1 - id: return_final_state value: type: rawscript @@ -144,6 +145,26 @@ Correct \`whileloopflow\` shape: expr: results.loop_until_done[results.loop_until_done.length - 1] \`\`\` +Correct \`whileloopflow\` shape carrying state via \`results\` (stop condition on the inner step): + +\`\`\`yaml +- id: loop_until_done + value: + type: whileloopflow + skip_failures: false + modules: + - id: advance_state + stop_after_if: + expr: result.done === true + skip_if_stopped: false + value: + type: rawscript + input_transforms: + state: + type: javascript + expr: results.advance_state ?? flow_input.initial_state +\`\`\` + Incorrect \`whileloopflow\` patterns: \`\`\`yaml @@ -158,7 +179,8 @@ Incorrect \`whileloopflow\` patterns: input_transforms: state: type: javascript - expr: flow_input.iter.index + # iter.value is a number (the iteration index); there is no previous-iteration state + expr: flow_input.iter.value.count \`\`\` \`\`\`yaml @@ -2865,7 +2887,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."},"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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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 until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"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 976b424b59..cfdb405250 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -160,16 +160,17 @@ value: - `flow_input.property` - Access flow input parameters - `results.step_id` - Access output from a previous step only when that step result is in scope - `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope -- `flow_input.iter.value` - Current iteration value when inside a loop (`forloopflow` or `whileloopflow`) +- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`) - `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`) ## Loop Structure Rules -- For `whileloopflow`, use module-level `stop_after_if` on the loop module itself when the loop should stop after an iteration result -- Do NOT put `stop_after_if` inside `value` of a `whileloopflow` +- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below) +- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object - `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition -- When a `whileloopflow` carries state forward between iterations, use `flow_input.iter.value` as the current loop value and provide an explicit first-iteration fallback when needed -- Use `flow_input.iter.index` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value +- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates +- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement) +- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module - If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array Correct `whileloopflow` shape: @@ -187,9 +188,9 @@ Correct `whileloopflow` shape: value: type: rawscript input_transforms: - state: + count: type: javascript - expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state + expr: flow_input.iter.index + 1 - id: return_final_state value: type: rawscript @@ -199,6 +200,26 @@ Correct `whileloopflow` shape: expr: results.loop_until_done[results.loop_until_done.length - 1] ``` +Correct `whileloopflow` shape carrying state via `results` (stop condition on the inner step): + +```yaml +- id: loop_until_done + value: + type: whileloopflow + skip_failures: false + modules: + - id: advance_state + stop_after_if: + expr: result.done === true + skip_if_stopped: false + value: + type: rawscript + input_transforms: + state: + type: javascript + expr: results.advance_state ?? flow_input.initial_state +``` + Incorrect `whileloopflow` patterns: ```yaml @@ -213,7 +234,8 @@ Incorrect `whileloopflow` patterns: input_transforms: state: type: javascript - expr: flow_input.iter.index + # iter.value is a number (the iteration index); there is no previous-iteration state + expr: flow_input.iter.value.count ``` ```yaml @@ -407,4 +429,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."},"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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"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 for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","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","azure_foundry","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')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"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","bunnative","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 until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","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)"},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"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, reasoning_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"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"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/base/flow-base.md b/system_prompts/base/flow-base.md index def01a3535..78fa521d96 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -74,16 +74,17 @@ value: - `flow_input.property` - Access flow input parameters - `results.step_id` - Access output from a previous step only when that step result is in scope - `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope -- `flow_input.iter.value` - Current iteration value when inside a loop (`forloopflow` or `whileloopflow`) +- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`) - `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`) ## Loop Structure Rules -- For `whileloopflow`, use module-level `stop_after_if` on the loop module itself when the loop should stop after an iteration result -- Do NOT put `stop_after_if` inside `value` of a `whileloopflow` +- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below) +- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object - `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition -- When a `whileloopflow` carries state forward between iterations, use `flow_input.iter.value` as the current loop value and provide an explicit first-iteration fallback when needed -- Use `flow_input.iter.index` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value +- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates +- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement) +- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module - If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array Correct `whileloopflow` shape: @@ -101,9 +102,9 @@ Correct `whileloopflow` shape: value: type: rawscript input_transforms: - state: + count: type: javascript - expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state + expr: flow_input.iter.index + 1 - id: return_final_state value: type: rawscript @@ -113,6 +114,26 @@ Correct `whileloopflow` shape: expr: results.loop_until_done[results.loop_until_done.length - 1] ``` +Correct `whileloopflow` shape carrying state via `results` (stop condition on the inner step): + +```yaml +- id: loop_until_done + value: + type: whileloopflow + skip_failures: false + modules: + - id: advance_state + stop_after_if: + expr: result.done === true + skip_if_stopped: false + value: + type: rawscript + input_transforms: + state: + type: javascript + expr: results.advance_state ?? flow_input.initial_state +``` + Incorrect `whileloopflow` patterns: ```yaml @@ -127,7 +148,8 @@ Incorrect `whileloopflow` patterns: input_transforms: state: type: javascript - expr: flow_input.iter.index + # iter.value is a number (the iteration index); there is no previous-iteration state + expr: flow_input.iter.value.count ``` ```yaml From 9b55f1d67d632be83cd4bca70c5477a0aba9fef2 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 27 Jul 2026 10:52:47 +0200 Subject: [PATCH 013/400] fix: name the resource in the delete confirmation modal (#10344) Co-authored-by: Claude Fable 5 --- frontend/src/routes/(root)/(logged)/resources/+page.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index d03fe118ee..ea31760069 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -123,6 +123,7 @@ let supabaseConnect: SupabaseConnect | undefined = $state(undefined) let deleteConfirmedCallback: (() => void) | undefined = $state(undefined) let deleteIsLinked = $state(false) + let deletePath = $state('') let loading = $state({ resources: true, types: true @@ -644,7 +645,10 @@ }} >
- Are you sure you want to remove this resource? + Are you sure you want to remove {deletePath}? {#if deleteIsLinked} This resource is linked with a variable of the same path. The linked variable will also be @@ -1226,6 +1230,7 @@ deleteResource(path, account) } else { deleteIsLinked = is_linked ?? false + deletePath = path deleteConfirmedCallback = () => { deleteResource(path, account) } From 4b7ab64a48d57b8b0f91ce181eee66b155159eb5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 10:59:50 +0200 Subject: [PATCH 014/400] fix: enforce per-job authorization on cancel and force_cancel endpoints (#10341) * fix: enforce per-job authorization on cancel and force_cancel Co-Authored-By: Claude Opus 5 (1M context) * fix: authorize force_cancel on the ancestor it actually kills Co-Authored-By: Claude Opus 5 (1M context) * fix: fail closed when the force_cancel ancestor walk is truncated Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ...bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json | 30 ++++++++ backend/tests/fixtures/jobs_read_auth.sql | 27 +++++++ backend/tests/jobs_read_auth.rs | 64 ++++++++++++++++- backend/windmill-api/src/jobs.rs | 72 ++++++++++++++----- 4 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 backend/.sqlx/query-a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json diff --git a/backend/.sqlx/query-a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json b/backend/.sqlx/query-a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json new file mode 100644 index 0000000000..c10a455d24 --- /dev/null +++ b/backend/.sqlx/query-a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE queued_ancestors AS (\n SELECT j.id, j.parent_job, 0 AS depth\n FROM v2_job j JOIN v2_job_queue q USING (id)\n WHERE j.id = $1 AND j.workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job, a.depth + 1\n FROM queued_ancestors a\n JOIN v2_job j ON j.id = a.parent_job AND j.workspace_id = $2\n JOIN v2_job_queue q ON q.id = j.id\n WHERE a.depth < $3\n )\n SELECT id AS \"id!\", depth AS \"depth!\" FROM queued_ancestors ORDER BY depth DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "depth!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int4" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "a6a9a8013ac8ea8ecba8a39c3c0bfb0b5ccdd31740cd63f2d174b3cd1e18019d" +} diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index 456ac8c801..03ee69b1f4 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -229,3 +229,30 @@ INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, resu '{"mid": "MID_RESULT"}'), ('88888888-8888-8888-8888-888888888888', 'test-workspace', 1000, 'success'::job_status, '{"deep": "DEEP_STEP_INHERITED"}'); + +-- 6. QUEUED nesting for force-cancel: a hidden top flow (`f/secret/qtop`) whose +-- step is a sub-flow in the visible `shared` folder (`f/shared/qmid`). Force +-- cancel walks up to the highest queued ancestor, so force-cancelling the +-- sub-flow test-user-3 CAN see would kill the top flow they cannot. +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner +) VALUES ( + '66666666-6666-6666-6666-666666666666', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flow', 'deno', 'f/secret/qtop', 'flow', true +); +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, + parent_job, root_job, flow_innermost_root_job +) VALUES ( + '55555555-5555-5555-5555-555555555555', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flow', 'deno', 'f/shared/qmid', 'flow', true, + '66666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', + '66666666-6666-6666-6666-666666666666' +); +INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES + ('66666666-6666-6666-6666-666666666666', 'test-workspace', '2023-01-01 00:00:00', true, 'flow'), + ('55555555-5555-5555-5555-555555555555', 'test-workspace', '2023-01-01 00:00:00', true, 'flow'); diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 1eab0bffee..e95748d8de 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -23,7 +23,11 @@ //! - the "app component" affordance survives: a viewer who *launched* a job //! (created_by) running as someone else's identity can still read its result, //! - unauthenticated behavior is unchanged: anonymous jobs readable, the -//! non-anonymous victim job rejected. +//! non-anonymous victim job rejected, +//! - `queue/cancel` and `queue/force_cancel` are gated by that same access, so +//! a viewer cannot kill a run hidden from them while its owner still can, and +//! force cancel gates on the ancestor it actually kills rather than the id in +//! the URL. use sqlx::{Pool, Postgres}; use windmill_test_utils::*; @@ -42,6 +46,9 @@ const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777"; const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212"; // A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it. const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313"; +// Queued sub-flow test-user-3 can see (folder `shared`), whose parent top flow they +// cannot. Force cancel walks up to that parent. +const QUEUED_VISIBLE_MID: &str = "55555555-5555-5555-5555-555555555555"; // Secrets that must never leak to an unauthorized viewer. const RESULT_SECRET: &str = "RESULT_SECRET"; @@ -347,8 +354,8 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul // ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token // may cancel a job it launched (created_by == viewer), but `cancel_job_api` - // denies (NotFound) a job created by someone else, even though cancel - // otherwise has no per-job ownership check. + // denies (NotFound) a job created by someone else, even one the (admin) + // viewer could otherwise cancel. let (status, body) = post( &base, &format!("queue/cancel/{EMBED_OWN_QUEUED}"), @@ -598,5 +605,56 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul "owner must see the running job as started (got {status}): {body}" ); + // ---- CANCEL / FORCE_CANCEL are gated by the same per-job access as reading: + // knowing the UUID of a run hidden from you must not let you kill it. ---- + for path in [ + format!("queue/cancel/{RUNNING_JOB}"), + format!("queue/force_cancel/{RUNNING_JOB}"), + ] { + let (status, body) = post(&base, &path, Some("SECRET_TOKEN_3")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must not cancel another user's job ({path}, got {status}): {body}" + ); + } + // The owner still cancels their own job (no over-blocking). Keep this last: it + // takes RUNNING_JOB out of the queue. + let (status, body) = post( + &base, + &format!("queue/cancel/{RUNNING_JOB}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success(), + "owner must still cancel their own job (got {status}): {body}" + ); + + // Force cancel kills the highest queued ancestor, not the job named in the URL, so it + // must authorize that ancestor: the viewer can see the sub-flow (asserted first, or the + // denial below would prove nothing) but not the top flow force-cancelling it would kill. + let (status, body) = get( + &base, + &format!("get/{QUEUED_VISIBLE_MID}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "viewer must be able to read the sub-flow (got {status}): {body}" + ); + let (status, body) = post( + &base, + &format!("queue/force_cancel/{QUEUED_VISIBLE_MID}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must not force-cancel up into a flow they cannot see (got {status}): {body}" + ); + Ok(()) } diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 4ee13226c5..f030aaa3d1 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -526,27 +526,17 @@ async fn cancel_job_api( OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Json(CancelJob { reason }): Json, ) -> error::Result { - // App embed tokens (the sandboxed app iframe) may cancel ONLY jobs they launched - // — their app's component runs, stamped created_by == viewer. cancel_job_api has - // no other per-job ownership check, so without this an embed token (which carries - // the viewer's identity) could cancel any job by id. NotFound (not 403) so the - // untrusted app can't probe job existence. + // Cancelling needs the same per-job access as reading: own job, admin, or RLS-visible + // directly/through a flow ancestor — which also confines app embed tokens to the + // component runs they launched. No `view_token`: a share link grants read, never the + // right to kill someone else's run. Anonymous callers are instead confined to + // anonymous-created jobs by `cancel_job`'s `require_anonymous`. if let Some(authed) = opt_authed.as_ref() { - if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) { - let created_by = sqlx::query_scalar!( - "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", - id, - &w_id - ) - .fetch_optional(&db) - .await?; - if created_by.as_deref() != Some(authed.username.as_str()) { - return Err(Error::NotFound(format!("Job {id} not found"))); - } - } + require_job_update_read_access(&db, &user_db, authed, &w_id, &id, None).await?; } let tx = db.begin().await?; @@ -656,13 +646,61 @@ async fn cancel_persistent_script_api( Ok(()) } +/// Bounds the ancestor walk below so a cyclic `parent_job` chain cannot spin forever. +/// `cancel_job` itself is unbounded, so a chain longer than this would leave the two +/// disagreeing about which job gets killed — hence the fail-closed error. +const FORCE_CANCEL_MAX_ANCESTOR_DEPTH: i32 = 500; + +/// The job a force-cancel of `id` actually kills: `cancel_job(force_cancel = true)` walks +/// up to the highest still-queued ancestor and cancels that one instead. Falls back to +/// `id` when it is not queued (the cancel is then a no-op anyway). +async fn force_cancel_target(db: &DB, w_id: &str, id: Uuid) -> error::Result { + let target = sqlx::query!( + r#"WITH RECURSIVE queued_ancestors AS ( + SELECT j.id, j.parent_job, 0 AS depth + FROM v2_job j JOIN v2_job_queue q USING (id) + WHERE j.id = $1 AND j.workspace_id = $2 + UNION ALL + SELECT j.id, j.parent_job, a.depth + 1 + FROM queued_ancestors a + JOIN v2_job j ON j.id = a.parent_job AND j.workspace_id = $2 + JOIN v2_job_queue q ON q.id = j.id + WHERE a.depth < $3 + ) + SELECT id AS "id!", depth AS "depth!" FROM queued_ancestors ORDER BY depth DESC LIMIT 1"#, + id, + w_id, + FORCE_CANCEL_MAX_ANCESTOR_DEPTH, + ) + .fetch_optional(db) + .await?; + match target { + None => Ok(id), + // Truncated: we cannot prove which job the cancel would reach, so refuse rather + // than authorize an ancestor that may not be the one killed. + Some(r) if r.depth >= FORCE_CANCEL_MAX_ANCESTOR_DEPTH => Err(Error::internal_err(format!( + "flow nesting above job {id} is too deep to authorize a force cancel" + ))), + Some(r) => Ok(r.id), + } +} + async fn force_cancel( OptAuthed(opt_authed): OptAuthed, tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Json(CancelJob { reason }): Json, ) -> error::Result { + // Same per-job access as `cancel_job_api`, but on the job force-cancel actually kills. + // Read visibility is inherited *down* the flow chain, so gating on `id` would let a + // caller who can only see an inner step kill a root flow hidden from them. + if let Some(authed) = opt_authed.as_ref() { + let target = force_cancel_target(&db, &w_id, id).await?; + require_job_update_read_access(&db, &user_db, authed, &w_id, &target, None).await?; + } + let tx = db.begin().await?; let audit_author: AuditAuthor = match opt_authed.as_ref() { From 9bbfe12011812fe449723202c60893790e68f9cd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 12:20:21 +0200 Subject: [PATCH 015/400] fix(frontend): stop spurious asset analysis toasts in the flow editor (#10349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): stop spurious asset analysis toasts in the flow editor The flow editor asks "Assets were detected in this step. Analyze entire flow for assets?" whenever a raw script step without asset metadata is selected and its code turns out to declare assets. Nothing recorded that the question had already been asked, and the selection watcher is re-created (and fires) on every structural change to the flow, so the prompt reappeared on every step click and every time a step was added. Steps created during the session — most visibly the ones an AI agent inserts one by one — were also treated as legacy steps, so each new step raised its own prompt even though writing their assets only completes an edit the user already made. Ask at most once per editor session, restrict the prompt to the modules the flow was loaded with, and skip re-analyzing a module whose content has not changed since its last parse. Fixes WIN-2251 * fix(frontend): key the asset inference cache on language and replay it inferAssets depends on the module's language as well as its content, and the content-only cache also turned a re-derivation into a no-op whenever the assets field alone was reset (undo/redo, reset to deployed, AI diff apply). Cache the inference result keyed on both inputs and re-apply it on a hit, so a cache hit is idempotent rather than a skip; that also removes the need for analyzeEntireFlow to force a re-parse. Cached values are copied before reaching the flow store, which would otherwise proxy them and let a later replay mutate the cache in place. Accepting "Analyze entire flow" now carries over to modules analyzed later in the session instead of leaving them for a prompt that will not be shown again. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): build the asset cache key without a raw NUL byte The separator was written as a literal U+0000, which makes git treat the Svelte source as binary: diffs render as +0/-0, blame and log -p stop working, and ripgrep skips the file. Build the key with JSON.stringify instead, which is unambiguous and keeps the file ASCII. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../components/flows/FlowAssetsHandler.svelte | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/flows/FlowAssetsHandler.svelte b/frontend/src/lib/components/flows/FlowAssetsHandler.svelte index c216022520..947eca1a67 100644 --- a/frontend/src/lib/components/flows/FlowAssetsHandler.svelte +++ b/frontend/src/lib/components/flows/FlowAssetsHandler.svelte @@ -117,10 +117,32 @@ }) } }) - // Prune all additionalAssetsMap entries from deleted modules + + // Ids the flow was loaded with. Only those modules can carry asset metadata predating + // the assets feature; anything appearing later was added in this session, so writing + // its assets adds nothing on top of a change the user has already made. The editor + // only mounts once the flow is loaded, so reading the prop here is the loaded state. + const loadedModuleIds = new Set(getAllModules(modules).map((m) => m.id)) + // Analyzing is a flow-wide action: offer it once, then apply that answer to every + // other module of the flow rather than asking again for each one. A prompt left + // unanswered stays 'offered' and keeps those modules untouched for the session. + let flowAnalysis: 'unoffered' | 'offered' | 'accepted' = 'unoffered' + // Last inference per module, keyed on everything inferAssets depends on. The watchers + // below are re-created whenever a module is added or removed and the selection watch + // fires on creation, so a miss here means a re-parse on every structural edit. + type InferredAssets = Extract>, { status: 'ok' }> + let analyzed: Record = {} + + // Prune per-module caches from deleted modules $effect(() => { - if (!flowGraphAssetsCtx) return const modulesSet = new Set(allModules.map((m) => m.id)) + for (const key of Object.keys(analyzed)) { + if (!modulesSet.has(key)) delete analyzed[key] + } + for (const key of [...loadedModuleIds]) { + if (!modulesSet.has(key)) loadedModuleIds.delete(key) + } + if (!flowGraphAssetsCtx) return for (const key of Object.keys(flowGraphAssetsCtx.val.additionalAssetsMap)) { if (!modulesSet.has(key)) { delete flowGraphAssetsCtx.val.additionalAssetsMap[key] @@ -129,6 +151,7 @@ }) function analyzeEntireFlow() { + flowAnalysis = 'accepted' for (const mod of allModules) { if (mod.value.type === 'rawscript') { parseAndUpdateRawScriptModule(mod.value, mod.id) @@ -136,27 +159,37 @@ } } - async function parseAndUpdateRawScriptModule( - v: RawScript, - modId: string, - isUserEdit: boolean = true - ) { - console.log('Parsing assets for RawScript module', modId) - let inferAssetsResult = await inferAssets(v.language, v.content) - if (inferAssetsResult.status === 'error') return - if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = inferAssetsResult.sql_queries - let newAssets = inferAssetsResult.assets as AssetWithAltAccessType[] + async function parseAndUpdateRawScriptModule(v: RawScript, modId: string, prompt = false) { + const key = JSON.stringify([v.language, v.content]) + let inferred = analyzed[modId]?.key === key ? analyzed[modId].result : undefined + if (!inferred) { + const inferAssetsResult = await inferAssets(v.language, v.content) + if (inferAssetsResult.status === 'error') return + inferred = inferAssetsResult + analyzed[modId] = { key, result: inferred } + } + // Copy before handing anything to the flow store: stored values become reactive + // proxies, and a later replay of this same inference would mutate the cache. + const { assets, sql_queries } = structuredClone(inferred) + if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = sql_queries + let newAssets = assets as AssetWithAltAccessType[] for (const asset of newAssets) { const old = v.assets?.find((a) => assetEq(a, asset)) if (old?.alt_access_type) asset.alt_access_type = old.alt_access_type } const normalizedAssets = newAssets.length > 0 ? newAssets : undefined if (!deepEqual(v.assets, normalizedAssets)) { - if (!isUserEdit && normalizedAssets && normalizedAssets.length > 0) { + if (prompt && flowAnalysis !== 'accepted' && normalizedAssets?.length) { + if (flowAnalysis === 'offered') return + flowAnalysis = 'offered' + // Long-lived because it is the only entry point to analyzeEntireFlow and it is + // offered once: a toast the user misses cannot be brought back without a reload. sendUserToast( 'Assets were detected in this step. Analyze entire flow for assets?', 'warning', - [{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }] + [{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }], + undefined, + 20000 ) } else { v.assets = normalizedAssets @@ -181,7 +214,11 @@ // Also recompute if the module is selected watch([() => selectedId === mod.id], () => { if (selectedId === mod.id) - parseAndUpdateRawScriptModule(modValue, mod.id, modValue.assets !== undefined) + parseAndUpdateRawScriptModule( + modValue, + mod.id, + modValue.assets === undefined && loadedModuleIds.has(mod.id) + ) }) } } From 78e115bee5a573eb6e0abfbdccc4a0e677a80085 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 12:32:44 +0200 Subject: [PATCH 016/400] fix: datatable full schema hangs behind a transaction-pooling postgres proxy (#10352) * fix: datatable full schema hangs behind a transaction-pooling postgres proxy * test: pause the clock in the pg connection shutdown test --- backend/windmill-api-settings/src/lib.rs | 7 +- .../windmill-api-workspaces/src/workspaces.rs | 17 ++--- .../src/workspaces_extra.rs | 4 +- backend/windmill-common/Cargo.toml | 4 ++ backend/windmill-common/src/lib.rs | 50 ++++++++++++-- backend/windmill-common/src/query_builders.rs | 67 +++++++++++++------ backend/windmill-common/src/workspaces.rs | 4 +- 7 files changed, 106 insertions(+), 47 deletions(-) diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 8351ec0d2f..3d474ff4b1 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -1727,12 +1727,7 @@ async fn setup_custom_instance_pg_database_inner( logs.grant_permissions = "OK".to_string(); drop(client); // /!\ Drop before joining to avoid deadlock - join_handle - .await - .map_err(|e| error::Error::ExecutionErr(format!("join error: {}", e.to_string())))? - .map_err(|e| { - error::Error::ExecutionErr(format!("tokio_postgres error: {}", e.to_string())) - })?; + windmill_common::shutdown_pg_connection(join_handle).await?; Ok(()) } diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9089b6e8b6..c8f67c9d2e 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2677,7 +2677,7 @@ async fn create_pg_database( if db_exists { drop(client); - let _ = join_handle.await; + let _ = windmill_common::shutdown_pg_connection(join_handle).await; return Err(Error::BadRequest(format!( "Database '{}' already exists on the resource server", req.target_dbname @@ -2695,10 +2695,7 @@ async fn create_pg_database( })?; drop(client); - join_handle - .await - .map_err(|e| Error::internal_err(format!("join error: {}", e)))? - .map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?; + windmill_common::shutdown_pg_connection(join_handle).await?; } Ok(format!("Created database '{}'", req.target_dbname)) @@ -2801,10 +2798,7 @@ async fn get_datatable_full_schema( .map_err(Error::internal_err)?; drop(client); - join_handle - .await - .map_err(|e| Error::internal_err(format!("join error: {}", e)))? - .map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?; + windmill_common::shutdown_pg_connection(join_handle).await?; Ok(Json(result)) } @@ -6389,10 +6383,7 @@ async fn snapshot_datatable_schema( .map_err(Error::internal_err)?; drop(client); - join_handle - .await - .map_err(|e| Error::internal_err(format!("join error: {}", e)))? - .map_err(|e| Error::internal_err(format!("tokio_postgres error: {}", e)))?; + windmill_common::shutdown_pg_connection(join_handle).await?; serde_json::to_value(schema) .map_err(|e| Error::internal_err(format!("Failed to serialize schema: {}", e))) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 19494c41fc..8755bc0a05 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -1352,7 +1352,7 @@ pub async fn drop_forked_datatable_databases( )); } drop(client); - let _ = join_handle.await; + let _ = windmill_common::shutdown_pg_connection(join_handle).await; } Err(e) => { errors.push(format!( @@ -1681,7 +1681,7 @@ async fn drop_fork_ducklake_metadata_schema( ) .await; drop(client); - let _ = join_handle.await; + let _ = windmill_common::shutdown_pg_connection(join_handle).await; res.map_err(|e| Error::internal_err(format!("{e:#}")))?; Ok(()) } diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index e45bba05c3..e2c3233183 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -130,6 +130,10 @@ opentelemetry-appender-tracing = { workspace = true, optional = true } tonic = { workspace = true, optional = true } equivalent = "1.0.2" +[dev-dependencies] +# `test-util` is not part of tokio's `full`; it is what lets tests pause the clock. +tokio = { workspace = true, features = ["test-util"] } + [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemalloc-ctl = { optional = true, workspace = true } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 08a4ea93ed..1eda7503af 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -35,6 +35,7 @@ pub mod auth; pub mod bench; pub mod cache; pub mod client; +pub mod data_metrics; pub mod db; #[cfg(all(feature = "enterprise", feature = "private"))] mod db_entra_ee; @@ -62,7 +63,6 @@ pub mod instance_config; pub mod job_metrics; pub mod log_context; pub mod materialization; -pub mod data_metrics; pub mod min_version; pub mod notify_events; pub mod runtime_assets; @@ -1068,6 +1068,49 @@ impl PgDatabase { } } +/// How long a `tokio_postgres` connection task gets to wind down once its `Client` is dropped. +const PG_CONNECTION_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5); + +/// Wind down the task driving a `tokio_postgres` connection after its `Client` has been dropped, +/// surfacing whatever error the connection ended with. A teardown that has to be aborted is +/// reported as success — the work the client did is already done and complete. +/// +/// The task only finishes once the exchange the client left behind (its Terminate, and any +/// still-unanswered request) has been settled by the peer. A connection proxy that stops +/// replying leaves that pending forever, so waiting on the task without a deadline pins the +/// caller and the socket for the lifetime of the process. Aborting past the grace period drops +/// the stream, which is the only cleanup the task owes. +pub async fn shutdown_pg_connection( + join_handle: tokio::task::JoinHandle>, +) -> error::Result<()> { + let abort_handle = join_handle.abort_handle(); + match tokio::time::timeout(PG_CONNECTION_SHUTDOWN_GRACE, join_handle).await { + Ok(Ok(Ok(()))) => Ok(()), + Ok(Ok(Err(e))) => Err(error::Error::internal_err(format!( + "tokio_postgres error: {}", + e + ))), + Ok(Err(e)) => Err(error::Error::internal_err(format!("join error: {}", e))), + Err(_) => { + tracing::warn!( + "Postgres connection did not close within {}s of its client being dropped, aborting it", + PG_CONNECTION_SHUTDOWN_GRACE.as_secs() + ); + abort_handle.abort(); + Ok(()) + } + } +} + +#[cfg(test)] +mod pg_connection_shutdown_tests { + #[tokio::test(start_paused = true)] + async fn gives_up_on_a_connection_task_that_never_finishes() { + let never_finishes = tokio::spawn(std::future::pending()); + assert!(super::shutdown_pg_connection(never_finishes).await.is_ok()); + } +} + /// Validate a database name to prevent SQL injection. /// Must start with a letter, contain only alphanumeric characters, underscores, or hyphens, and be <= 63 chars. pub fn validate_dbname(dbname: &str) -> error::Result<()> { @@ -1219,10 +1262,7 @@ pub async fn create_custom_instance_database( } drop(client); - join_handle - .await - .map_err(|e| error::Error::internal_err(format!("join error: {}", e)))? - .map_err(|e| error::Error::internal_err(format!("tokio_postgres error: {}", e)))?; + shutdown_pg_connection(join_handle).await?; // Register in global_settings let status_json = serde_json::json!({ diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 063d3958de..c0eedadbc4 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -4830,8 +4830,37 @@ fn pg_action_to_string(action: &str) -> String { } } +/// Rows of a simple-protocol result, dropping the framing messages. +fn simple_query_rows( + messages: Vec, +) -> Vec { + messages + .into_iter() + .filter_map(|m| match m { + tokio_postgres::SimpleQueryMessage::Row(row) => Some(row), + _ => None, + }) + .collect() +} + +fn required_str<'a>( + row: &'a tokio_postgres::SimpleQueryRow, + column: &str, +) -> Result<&'a str, String> { + row.try_get(column) + .map_err(|e| format!("Failed to read column {}: {}", column, e))? + .ok_or_else(|| format!("Unexpected NULL in column {}", column)) +} + /// Introspect a PostgreSQL database and return the full schema. /// Takes a connected tokio_postgres Client. +/// +/// Both statements go through the simple query protocol. The extended protocol allocates a +/// named prepared statement per call and closes it when the statement handle drops; behind a +/// transaction-pooling proxy those names are shared with, and outlive, other sessions on the +/// same backend, and the exchange then stalls with no reply — the connection never becomes +/// idle again and the request hangs. Neither statement takes parameters, so nothing here +/// needs the extended protocol. pub async fn pg_get_full_schema( client: &tokio_postgres::Client, ) -> Result { @@ -4840,7 +4869,7 @@ pub async fn pg_get_full_schema( // per-column correlated subqueries — on large catalogs those subqueries run // once per column and make the introspection time out. let column_rows = client - .query( + .simple_query( "SELECT ns.nspname AS schema_name, c.relname AS table_name, @@ -4862,13 +4891,13 @@ pub async fn pg_get_full_schema( AND NOT a.attisdropped AND ns.nspname NOT IN ('pg_catalog', 'information_schema') ORDER BY ns.nspname, c.relname, a.attnum", - &[], ) .await + .map(simple_query_rows) .map_err(|e| format!("Failed to query columns: {}", e))?; let fk_rows = client - .query( + .simple_query( "SELECT ns.nspname AS schema_name, c.relname AS table_name, @@ -4890,21 +4919,21 @@ pub async fn pg_get_full_schema( WHERE con.contype = 'f' AND ns.nspname NOT IN ('pg_catalog', 'information_schema') ORDER BY ns.nspname, c.relname, con.conname, u.ord", - &[], ) .await + .map(simple_query_rows) .map_err(|e| format!("Failed to query foreign keys: {}", e))?; let mut result: FullDatabaseSchema = std::collections::HashMap::new(); for row in &column_rows { - let schema_name: &str = row.get("schema_name"); - let table_name: &str = row.get("table_name"); - let column_name: &str = row.get("column_name"); - let datatype: &str = row.get("datatype"); + let schema_name = required_str(row, "schema_name")?; + let table_name = required_str(row, "table_name")?; + let column_name = required_str(row, "column_name")?; + let datatype = required_str(row, "datatype")?; let default_value: Option<&str> = row.get("default_value"); - let nullable: bool = row.get("nullable"); - let is_primary_key: bool = row.get("is_primary_key"); + let nullable = required_str(row, "nullable")? == "t"; + let is_primary_key = required_str(row, "is_primary_key")? == "t"; let pk_constraint_name: Option<&str> = row.get("pk_constraint_name"); let schema_tables = result.entry(schema_name.to_string()).or_default(); @@ -4937,15 +4966,15 @@ pub async fn pg_get_full_schema( > = std::collections::HashMap::new(); for row in &fk_rows { - let schema_name: &str = row.get("schema_name"); - let table_name: &str = row.get("table_name"); - let fk_name: &str = row.get("fk_constraint_name"); - let source_column: &str = row.get("source_column"); - let ref_schema: &str = row.get("ref_schema"); - let ref_table: &str = row.get("ref_table"); - let ref_column: &str = row.get("ref_column"); - let on_delete: &str = row.get("on_delete"); - let on_update: &str = row.get("on_update"); + let schema_name = required_str(row, "schema_name")?; + let table_name = required_str(row, "table_name")?; + let fk_name = required_str(row, "fk_constraint_name")?; + let source_column = required_str(row, "source_column")?; + let ref_schema = required_str(row, "ref_schema")?; + let ref_table = required_str(row, "ref_table")?; + let ref_column = required_str(row, "ref_column")?; + let on_delete = required_str(row, "on_delete")?; + let on_update = required_str(row, "on_update")?; let target_table = if ref_schema == schema_name { ref_table.to_string() diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 135b8b9558..fb73ba7e2c 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1866,7 +1866,7 @@ async fn inspect_fork_catalog( ); let table_rows = client.query(&qt, &[]).await; drop(client); - let _ = join_handle.await; + let _ = crate::shutdown_pg_connection(join_handle).await; existing_schemas.extend( same_catalog_res @@ -1920,7 +1920,7 @@ async fn inspect_fork_catalog( let join_handle = tokio::spawn(async move { connection.await }); let res = query_schemas(&client, schemas).await; drop(client); - let _ = join_handle.await; + let _ = crate::shutdown_pg_connection(join_handle).await; res.map_err(|e| Error::internal_err(format!("{e}"))) } .await; From be5e3bbfc43f45328b0aaa4fd0d0a6cee872ec09 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 12:38:32 +0200 Subject: [PATCH 017/400] fix(wac): checkpoint step errors so a caught exception does not hang replay (#10348) * fix(wac): checkpoint step errors so a caught exception does not hang replay Co-Authored-By: Claude Opus 5 * fix(wac): honour a step suspend the workflow body caught and swallowed Co-Authored-By: Claude Opus 5 * fix(wac): park every suspend, not only those from a failing step Co-Authored-By: Claude Opus 5 * chore(wac): keep the generated bun wrapper comment-free Co-Authored-By: Claude Opus 5 * fix(wac): park the child task-completion suspend and align error identity Co-Authored-By: Claude Opus 5 * test(wac): pin the TaskError identity of replayed step and task failures Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- backend/windmill-worker/src/bun_executor.rs | 8 + python-client/wmill/tests/test_workflow.py | 101 ++++++++++- python-client/wmill/wmill/client.py | 56 +++++-- typescript-client/client.ts | 106 ++++++++++-- typescript-client/tests/workflow.test.ts | 175 +++++++++++++++++++- 5 files changed, 418 insertions(+), 28 deletions(-) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 0f9f73ad1d..77bdff64e0 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1799,6 +1799,10 @@ pub async fn handle_bun_job( format!("argsObjToArr(args)") }; + // Kept comment-free — this string is written out per job. + // `_takePendingSuspend` returns a StepSuspend the body caught and swallowed + // (it is an `Error`), so honour it instead of reporting a `complete` whose + // step never reached the checkpoint. Optional: npm clients may predate it. let wrapper_content = if is_wac_v2 { format!( r#" @@ -1843,6 +1847,10 @@ async function run() {{ try {{ const result = await workflowFn(...argsArr); setWorkflowCtx(null); + const swallowed = ctx._takePendingSuspend?.(); + if (swallowed) {{ + throw swallowed; + }} // Flush any unawaited tasks (e.g. forgotten await on last statement) const trailing = ctx._flushPending(); if (trailing.length > 0) {{ diff --git a/python-client/wmill/tests/test_workflow.py b/python-client/wmill/tests/test_workflow.py index a8c97a31d2..5e49f7f595 100644 --- a/python-client/wmill/tests/test_workflow.py +++ b/python-client/wmill/tests/test_workflow.py @@ -3,7 +3,7 @@ import asyncio import pytest -from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow +from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async @task @@ -914,6 +914,105 @@ class TestErrorPropagation: assert "step failed" in r["result"]["caught"] +class TestRaisingInlineStepIsCheckpointed: + """A ``step()`` whose body raises must still land in ``completed_steps``. + + Otherwise a workflow that catches the exception and later dispatches a task + replays with ``_executing_key`` set, reaches the unrecorded key, and parks + on the never-resolving future forever. + """ + + MARKER = { + "__wmill_error": True, + "message": "boom", + "step_key": "risky", + "result": {"error": "boom", "type": "ValueError"}, + } + + @staticmethod + def _boom(): + raise ValueError("boom") + + @classmethod + def _wf(cls): + @workflow + async def wf(x: int): + try: + await step("risky", cls._boom) + except Exception: + pass + return await double(x=x) + + return wf + + def test_first_run_emits_error_checkpoint(self): + r = _run_workflow(self._wf(), {}, {"x": 5}) + assert r["type"] == "inline_checkpoint" + assert r["key"] == "risky" + assert r["result"] == self.MARKER + + def test_fast_path_posts_error_and_raises_the_replay_exception(self, monkeypatch): + """The default path: the checkpoint is POSTed and the workflow body gets + the same ``TaskError`` a replay rebuilds from the marker — raising the + original ``ValueError`` here would make ``except ValueError:`` catch on + this run and miss on the next one.""" + for var, val in ( + ("WM_JOB_ID", "job-1"), + ("WM_WORKSPACE", "admins"), + ("BASE_INTERNAL_URL", "http://localhost:8000"), + ("WM_TOKEN", "tok"), + ): + monkeypatch.setenv(var, val) + + posted = [] + + class _StubResponse: + def raise_for_status(self): + pass + + class _StubClient: + async def post(self, url, json=None): + posted.append(json) + return _StubResponse() + + async def aclose(self): + pass + + async def run(): + ctx = WorkflowCtx({}) + ctx._inline_http_client = _StubClient() + with pytest.raises(TaskError, match="boom") as live: + await ctx._run_inline_step("risky", self._boom) + # ...and the replay of that very checkpoint raises the same thing. + replayed = WorkflowCtx({"completed_steps": {"risky": self.MARKER}}) + with pytest.raises(TaskError, match="boom") as replay: + await replayed._run_inline_step("risky", self._boom) + assert type(live.value) is type(replay.value) + assert live.value.args == replay.value.args + assert live.value.result == replay.value.result == self.MARKER["result"] + assert isinstance(live.value.__cause__, ValueError) + + asyncio.run(run()) + assert len(posted) == 1 + assert posted[0]["key"] == "risky" + assert posted[0]["result"] == self.MARKER + + def test_replay_reraises_and_does_not_hang(self): + checkpoint = { + "completed_steps": {"risky": self.MARKER}, + "_executing_key": "double", + } + + async def run(): + return await asyncio.wait_for( + _run_workflow_async(self._wf(), checkpoint, {"x": 5}), timeout=5 + ) + + r = asyncio.run(run()) + assert r["type"] == "complete" + assert r["result"] == 10 + + # ===================================================================== # TASK OPTIONS TESTS # ===================================================================== diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index f18cffc8e7..a4b8e9c476 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -2689,6 +2689,29 @@ class TaskError(Exception): self.result = result +def _step_error_marker(key: str, exc: BaseException) -> dict: + """Serialize a failed ``step()`` body into the ``__wmill_error`` marker that + task failures also use, so it can be stored in ``completed_steps``.""" + return { + "__wmill_error": True, + "message": str(exc), + "step_key": key, + "result": {"error": str(exc), "type": type(exc).__name__}, + } + + +def _step_error_from_marker(marker: dict, name: str) -> TaskError: + """Rebuild the exception a failed step raises. Both the run that produced the + failure and every later replay go through here, so a workflow's ``except`` + clauses see the same type either way.""" + return TaskError( + marker.get("message", f"Step '{name}' failed"), + step_key=marker.get("step_key", ""), + child_job_id=marker.get("child_job_id", ""), + result=marker.get("result"), + ) + + _workflow_ctx: _contextvars.ContextVar["WorkflowCtx"] = _contextvars.ContextVar( "_workflow_ctx" ) @@ -2858,12 +2881,7 @@ class WorkflowCtx: if key in self._completed: val = self._completed[key] if isinstance(val, dict) and val.get("__wmill_error"): - raise TaskError( - val.get("message", f"Step '{name}' failed"), - step_key=val.get("step_key", ""), - child_job_id=val.get("child_job_id", ""), - result=val.get("result"), - ) + raise _step_error_from_marker(val, name) return val if self._executing_key is not None: @@ -2873,9 +2891,18 @@ class WorkflowCtx: started_at = _dt.now(_tz.utc).isoformat() print(f"WM_WAC_STEP: {_json_mod.dumps({'key': key, 'started_at': started_at})}") t0 = _time_mod.monotonic() - result = fn() - if _asyncio.iscoroutine(result): - result = await result + # A raised step still has to reach ``completed_steps``, or a replay with + # ``_executing_key`` set finds nothing recorded and parks forever on the + # ``_asyncio.Future()`` above. ``_StepSuspend`` and ``CancelledError`` are + # ``BaseException``, so they pass through untouched. + step_error: Optional[Exception] = None + try: + result = fn() + if _asyncio.iscoroutine(result): + result = await result + except Exception as _exc: + step_error = _exc + result = _step_error_marker(key, _exc) duration_ms = int((_time_mod.monotonic() - t0) * 1000) # Fast path: POST the delta to the new per-job API endpoint and return @@ -2892,6 +2919,7 @@ class WorkflowCtx: _base = os.environ.get("BASE_INTERNAL_URL") _token = os.environ.get("WM_TOKEN") if _fast_path_enabled and _job_id and _workspace and _base and _token: + _fast_path_ok = False try: if self._inline_lock is None: self._inline_lock = _asyncio.Lock() @@ -2917,7 +2945,7 @@ class WorkflowCtx: }, ) _resp.raise_for_status() - return result + _fast_path_ok = True except Exception as _e: logger.info( "WAC v2 inline fast path failed for key %s, falling back to suspend: %s", @@ -2925,6 +2953,14 @@ class WorkflowCtx: _e, ) # fall through to the legacy suspend path + if _fast_path_ok: + # Raise what a replay would rebuild from the marker, never the + # original: a replay cannot reconstruct the original type, so + # raising it here would make ``except ValueError:`` catch on this + # run and miss on the next. ``__cause__`` is for tracebacks only. + if step_error is not None: + raise _step_error_from_marker(result, name) from step_error + return result raise _StepSuspend({ "mode": "inline_checkpoint", diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 1b5d8473f6..a17e7e361f 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1520,6 +1520,32 @@ export class StepSuspend extends Error { } } +/** Serialize a failed `step()` body into the `__wmill_error` marker that task + * failures also use, so it can be stored in `completed_steps`. */ +function stepErrorMarker(key: string, e: unknown): Record { + const message = e instanceof Error ? e.message : String(e); + // Constructor name, not `e.name`: a `class MyError extends Error {}` that + // never assigns `this.name` reports "Error", which would make the same + // failure read as `MyError` in the python client and `Error` here. + const type = e instanceof Error ? (e.constructor?.name ?? e.name) : typeof e; + return { __wmill_error: true, message, step_key: key, result: { error: message, type } }; +} + +/** Rebuild the error a failed step throws. Both the run that produced the + * failure and every later replay go through here, so a workflow's catch block + * sees the same shape either way. */ +function stepErrorFromMarker(marker: any, name: string): Error { + const err = new Error(marker?.message || `Step '${name}' failed`); + // Matches the python client, which raises TaskError here; the failed body's + // own type stays in `result.type`. Keeps a failed job's serialized error + // identical across the two languages. + err.name = "TaskError"; + (err as any).result = marker?.result; + (err as any).step_key = marker?.step_key; + (err as any).child_job_id = marker?.child_job_id; + return err; +} + export interface TaskOptions { timeout?: number; tag?: string; @@ -1563,6 +1589,11 @@ export class WorkflowCtx { [k: string]: any; }> = []; private _suspended = false; + /** The last suspend this ctx raised. `StepSuspend` is an `Error`, so any + * `catch` in the workflow body swallows it and the run would report a + * `complete` whose step never reached `completed_steps`. Python is immune: + * `_StepSuspend` derives from `BaseException`. */ + private _pendingSuspend: StepSuspend | null = null; /** When set, the task matching this key executes its inner function directly */ _executingKey: string | null; /** Serializes fast-path POSTs across concurrent step() calls within one @@ -1604,12 +1635,14 @@ export class WorkflowCtx { dispatch_type: string = "inline", options?: TaskOptions, ): PromiseLike { + this._rethrowSwallowedSuspend(); const key = this._allocKey(name || script || "step"); if (key in this.completed) { const value = this.completed[key]; if (value && typeof value === "object" && (value as any).__wmill_error) { const err = new Error((value as any).message || `Task '${name}' failed`); + err.name = "TaskError"; (err as any).result = (value as any).result; (err as any).step_key = (value as any).step_key; (err as any).child_job_id = (value as any).child_job_id; @@ -1654,7 +1687,7 @@ export class WorkflowCtx { this.pending = []; const names = steps.map(s => s.name).join(", "); console.log(`\n--- WAC: ${names} ---`); - throw new StepSuspend({ + this._raiseSuspend({ mode: steps.length > 1 ? "parallel" : "sequential", steps, }); @@ -1674,6 +1707,7 @@ export class WorkflowCtx { selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { + this._rethrowSwallowedSuspend(); if (options?.key !== undefined) assertUsableStepKey(options.key, "waitForApproval key"); const key = this._allocKey(options?.key || "approval"); @@ -1700,7 +1734,7 @@ export class WorkflowCtx { // Throw immediately — approval is always a blocking step console.log(`\n--- WAC: approval(${key}) ---`); - throw new StepSuspend({ + this._raiseSuspend({ mode: "approval", key, timeout: options?.timeout ?? 1800, @@ -1711,6 +1745,7 @@ export class WorkflowCtx { } _sleep(seconds: number): PromiseLike { + this._rethrowSwallowedSuspend(); const key = this._allocKey("sleep"); if (key in this.completed) { @@ -1722,7 +1757,7 @@ export class WorkflowCtx { } console.log(`\n--- WAC: sleep(${key}, ${seconds}s) ---`); - throw new StepSuspend({ + this._raiseSuspend({ mode: "sleep", key, seconds: Math.max(1, Math.round(seconds)), @@ -1731,16 +1766,13 @@ export class WorkflowCtx { } async _runInlineStep(name: string, fn: () => T | Promise): Promise { + this._rethrowSwallowedSuspend(); const key = this._allocKey(name || "step"); if (key in this.completed) { const value = this.completed[key]; if (value && typeof value === "object" && (value as any).__wmill_error) { - const err = new Error((value as any).message || `Step '${name}' failed`); - (err as any).result = (value as any).result; - (err as any).step_key = (value as any).step_key; - (err as any).child_job_id = (value as any).child_job_id; - throw err; + throw stepErrorFromMarker(value, name); } return value as T; } @@ -1753,7 +1785,21 @@ export class WorkflowCtx { const startedAt = new Date().toISOString(); console.log(`WM_WAC_STEP: ${JSON.stringify({ key, started_at: startedAt })}`); const t0 = Date.now(); - const result = await fn(); + // A thrown step still has to reach `completed_steps`, or a replay with + // `_executingKey` set finds nothing recorded and parks forever on the + // never-resolving promise above. A nested StepSuspend is control flow, + // not a step failure. + let result: T; + let stepError: unknown; + let errored = false; + try { + result = await fn(); + } catch (e) { + if ((e as any)?.name === "StepSuspend" || e instanceof StepSuspend) throw e; + errored = true; + stepError = e; + result = stepErrorMarker(key, e) as any; + } const durationMs = Date.now() - t0; // Fast path: POST the delta to the new per-job API endpoint and return the @@ -1810,18 +1856,54 @@ export class WorkflowCtx { }); // Swallow chain errors so a past failure does not poison future awaits. this._inlineChain = chainTail.catch(() => {}); + let fastPathOk = false; try { await chainTail; - return result as T; + fastPathOk = true; } catch (e) { console.log( `WAC v2 inline fast path failed for key ${key}, falling back to suspend: ${e}`, ); // fall through to the legacy suspend path below } + if (fastPathOk) { + // Throw what a replay would rebuild from the marker, never the + // original: a replay cannot reconstruct the original type, so throwing + // it here would match `e instanceof TypeError` on this run and miss on + // the next. `cause` is for logging only — absent on replay. + if (errored) { + throw Object.assign(stepErrorFromMarker(result, name), { cause: stepError }); + } + return result as T; + } } - throw new StepSuspend({ mode: "inline_checkpoint", steps: [], key, result, started_at: startedAt, duration_ms: durationMs }); + this._raiseSuspend({ mode: "inline_checkpoint", steps: [], key, result, started_at: startedAt, duration_ms: durationMs }); + } + + /** Raise a suspend, parking it so a body that catches it cannot make it + * vanish. Every suspend raised for this ctx must go through here. */ + _raiseSuspend(dispatchInfo: Record): never { + const suspend = new StepSuspend(dispatchInfo); + this._pendingSuspend = suspend; + throw suspend; + } + + /** Re-throw a swallowed suspend at the next SDK call. It happened before + * whatever the body is doing now, so it wins: the run is unwinding either + * way and everything after it re-runs on the replay. Left set, so a body + * that catches in a loop can't swallow it a second time. */ + private _rethrowSwallowedSuspend(): void { + if (this._pendingSuspend) throw this._pendingSuspend; + } + + /** Hand the runner a suspend the workflow body caught and swallowed, so it is + * honoured instead of silently turning into a `complete`. Returns null when + * the suspend propagated normally. */ + _takePendingSuspend(): StepSuspend | null { + const s = this._pendingSuspend; + this._pendingSuspend = null; + return s; } } @@ -1897,7 +1979,7 @@ export function task Promise>( if ((stepResult as any)?._execute_directly) { return (async () => { const result = await fn(...args); - throw new StepSuspend({ mode: "step_complete", steps: [], result }); + ctx._raiseSuspend({ mode: "step_complete", steps: [], result }); })(); } return stepResult; diff --git a/typescript-client/tests/workflow.test.ts b/typescript-client/tests/workflow.test.ts index a42895ccbb..6a4fed30c4 100644 --- a/typescript-client/tests/workflow.test.ts +++ b/typescript-client/tests/workflow.test.ts @@ -26,8 +26,25 @@ class WorkflowCtx { key: string; }> = []; private _suspended = false; + private _pendingSuspend: StepSuspend | null = null; _executingKey: string | null; + _raiseSuspend(dispatchInfo: Record): never { + const suspend = new StepSuspend(dispatchInfo); + this._pendingSuspend = suspend; + throw suspend; + } + + private _rethrowSwallowedSuspend(): void { + if (this._pendingSuspend) throw this._pendingSuspend; + } + + _takePendingSuspend(): StepSuspend | null { + const s = this._pendingSuspend; + this._pendingSuspend = null; + return s; + } + constructor(checkpoint: Record = {}) { this.completed = checkpoint?.completed_steps ?? {}; this._executingKey = checkpoint?._executing_key ?? null; @@ -43,12 +60,14 @@ class WorkflowCtx { args: Record = {}, options?: Record, ): PromiseLike { + this._rethrowSwallowedSuspend(); const key = this._allocKey(); if (key in this.completed) { const value = this.completed[key]; if (value && typeof value === "object" && (value as any).__wmill_error) { const err = new Error((value as any).message || `Task '${name}' failed`); + err.name = "TaskError"; (err as any).result = (value as any).result; (err as any).step_key = (value as any).step_key; (err as any).child_job_id = (value as any).child_job_id; @@ -79,7 +98,7 @@ class WorkflowCtx { this._suspended = true; const steps = [...this.pending]; this.pending = []; - throw new StepSuspend({ + this._raiseSuspend({ mode: steps.length > 1 ? "parallel" : "sequential", steps, }); @@ -99,6 +118,7 @@ class WorkflowCtx { } _sleep(seconds: number): PromiseLike { + this._rethrowSwallowedSuspend(); const key = this._allocKey(); if (key in this.completed) { return { then: (resolve: any) => resolve(undefined) }; @@ -106,7 +126,7 @@ class WorkflowCtx { if (this._executingKey !== null) { return { then: () => new Promise(() => {}) }; } - throw new StepSuspend({ + this._raiseSuspend({ mode: "sleep", key, seconds: Math.max(1, Math.round(seconds)), @@ -118,12 +138,14 @@ class WorkflowCtx { name: string, fn: () => T | Promise ): Promise { + this._rethrowSwallowedSuspend(); const key = this._allocKey(); if (key in this.completed) { const value = this.completed[key]; if (value && typeof value === "object" && (value as any).__wmill_error) { const err = new Error((value as any).message || `Step '${name}' failed`); + err.name = "TaskError"; (err as any).result = (value as any).result; throw err; } @@ -134,8 +156,25 @@ class WorkflowCtx { return new Promise(() => {}); } - const result = await fn(); - throw new StepSuspend({ + let result: any; + let errored = false; + try { + result = await fn(); + } catch (e: any) { + if (e?.name === "StepSuspend" || e instanceof StepSuspend) throw e; + errored = true; + const message = e instanceof Error ? e.message : String(e); + result = { + __wmill_error: true, + message, + step_key: key, + result: { + error: message, + type: e instanceof Error ? (e.constructor?.name ?? e.name) : typeof e, + }, + }; + } + this._raiseSuspend({ mode: "inline_checkpoint", steps: [], key, @@ -193,7 +232,7 @@ function task Promise>( if ((stepResult as any)?._execute_directly) { return (async () => { const result = await fn(...args); - throw new StepSuspend({ + ctx._raiseSuspend({ mode: "step_complete", steps: [], result, @@ -263,6 +302,9 @@ async function runWorkflow( _workflowCtx = ctx; try { const result = await fn(...args); + // Mirrors bun_executor.rs: honour a suspend the body caught and swallowed. + const swallowed = ctx._takePendingSuspend?.(); + if (swallowed) throw swallowed; // Flush unawaited tasks const pending = ctx._flushPending(); if (pending.length > 0) { @@ -1231,6 +1273,7 @@ describe("error propagation via __wmill_error marker", () => { await runWorkflow(wf, checkpoint, [5]); expect(true).toBe(false); // should not reach here } catch (e: any) { + expect(e.name).toBe("TaskError"); expect(e.message).toContain("double"); expect(e.result).toEqual({ message: "division by zero" }); expect(e.child_job_id).toBe("abc-123"); @@ -1343,6 +1386,128 @@ describe("error propagation via __wmill_error marker", () => { }); }); +// A step() whose body throws must still land in completed_steps. Otherwise a +// workflow that catches the error and later dispatches a task replays with +// _executingKey set, reaches the unrecorded key, and parks on the +// never-resolving promise forever. +describe("throwing inline step is checkpointed", () => { + const marker = { + __wmill_error: true, + message: "boom", + step_key: "step_0", + result: { error: "boom", type: "TypeError" }, + }; + + // The workflow body catches — the shape a failing step is written for, and + // the one that makes StepSuspend (an Error) swallowable in TS. + const catchingWf = () => + workflow(async (x: number) => { + let caught = null; + try { + await step("risky", () => { + throw new TypeError("boom"); + }); + } catch (e: any) { + caught = `${e.name}: ${e.message}`; + } + return { caught, doubled: await double(x) }; + }); + + test("a throwing step suspends with an error checkpoint", async () => { + const ctx = new WorkflowCtx({}); + let caught: any; + try { + await ctx._runInlineStep("risky", () => { + throw new TypeError("boom"); + }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(StepSuspend); + expect(caught.dispatchInfo.mode).toBe("inline_checkpoint"); + expect(caught.dispatchInfo.key).toBe("step_0"); + expect(caught.dispatchInfo.result).toEqual(marker); + }); + + test("a swallowed suspend still reaches the runner", async () => { + // Without _pendingSuspend the catch eats the suspend and the run reports a + // dispatch (or a complete) with `risky` missing from completed_steps. + const result = await runWorkflow(catchingWf(), {}, [5]); + expect(result.type).toBe("inline_checkpoint"); + expect(result.key).toBe("step_0"); + expect(result.result).toEqual(marker); + }); + + test("a swallowed suspend from a succeeding step still reaches the runner", async () => { + const wf = workflow(async () => { + try { + await step("fine", () => 42); + } catch { + // a body that catches broadly must not be able to erase the suspend + } + return "never reached on the first run"; + }); + const result = await runWorkflow(wf, {}, []); + expect(result.type).toBe("inline_checkpoint"); + expect(result.result).toBe(42); + }); + + test("a replayed step failure is named TaskError, like the python client", async () => { + const ctx = new WorkflowCtx({ completed_steps: { step_0: marker } }); + let caught: any; + try { + await ctx._runInlineStep("risky", () => 1); + } catch (e) { + caught = e; + } + expect(`${caught.name}: ${caught.message}`).toBe("TaskError: boom"); + // the failing body's own type stays addressable here + expect(caught.result).toEqual({ error: "boom", type: "TypeError" }); + }); + + test("a child job cannot swallow its own completion signal", async () => { + // The catch below is reached only if step_complete escapes the parking + // mechanism; the child would then report the catch branch as the result. + const wf = workflow(async (x: number) => { + try { + await double(x); + } catch { + return "swallowed"; + } + return "unreachable"; + }); + const result = await runWorkflow(wf, { _executing_key: "step_0" }, [5]); + expect(result.type).toBe("complete"); + expect(result.result).toBe(10); + }); + + test("a swallowed suspend from a task dispatch still reaches the runner", async () => { + const wf = workflow(async (x: number) => { + try { + await double(x); + } catch { + // ditto for task steps + } + return "never reached on the first run"; + }); + const result = await runWorkflow(wf, {}, [5]); + expect(result.type).toBe("dispatch"); + expect(result.steps[0].key).toBe("step_0"); + }); + + test("replay rethrows the error and does not hang", async () => { + const result = await runWorkflow( + catchingWf(), + { completed_steps: { step_0: marker }, _executing_key: "step_1" }, + [5], + ); + // The child runs only the dispatched task, so its result is that task's — + // what matters is that it got there instead of parking on `risky`. + expect(result.type).toBe("complete"); + expect(result.result).toBe(10); + }); +}); + // ===================================================================== // TASK OPTIONS TESTS // ===================================================================== From a8ef98edff5b413bce083f1253f59d0d20bf3e3e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 12:53:43 +0200 Subject: [PATCH 018/400] feat: build a React raw app from the script and flow detail pages (#10337) * feat: build a React raw app from the script and flow detail pages * fix: keep generated raw-app state and setter names unique * fix: make the generated app readable on dark and handle labeled enums * fix: reserve the undefined binding in generated raw apps * fix: mask password args, keep __proto__ args, and enforce required inputs * fix: quote non-identifier arg names, preserve JSX entities, support resource args * fix: JSON-quote generated arg keys and start resource fields empty * fix: render array enums as multi-selects and let optional enums be omitted * fix: stop the generated template naming Math/Array and omit untouched optional json * fix: enforce required on array-enum multiselects --- .../components/details/createAppFromScript.ts | 1098 ----------------- .../details/createRawAppFromScript.test.ts | 310 +++++ .../details/createRawAppFromScript.ts | 720 +++++++++++ .../src/lib/components/raw_apps/utils.test.ts | 21 + frontend/src/lib/schema.ts | 5 +- .../(logged)/flows/get/[...path]/+page.svelte | 11 +- .../scripts/get/[...hash]/+page.svelte | 18 +- 7 files changed, 1068 insertions(+), 1115 deletions(-) delete mode 100644 frontend/src/lib/components/details/createAppFromScript.ts create mode 100644 frontend/src/lib/components/details/createRawAppFromScript.test.ts create mode 100644 frontend/src/lib/components/details/createRawAppFromScript.ts diff --git a/frontend/src/lib/components/details/createAppFromScript.ts b/frontend/src/lib/components/details/createAppFromScript.ts deleted file mode 100644 index 07308dbf20..0000000000 --- a/frontend/src/lib/components/details/createAppFromScript.ts +++ /dev/null @@ -1,1098 +0,0 @@ -import { ccomponents } from '../apps/editor/component' - -export function createAppFromScript(path: string, schema: Record | undefined) { - return { - grid: [ - { - '3': { - fixed: false, - x: 0, - y: 2, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 2, - w: 12, - h: 21, - fullHeight: false - }, - data: { - type: 'verticalsplitpanescomponent', - configuration: {}, - panes: [50, 50], - customCss: structuredClone(ccomponents['verticalsplitpanescomponent'].customCss), - numberOfSubgrids: 2, - id: 'a' - }, - id: 'a' - }, - { - '3': { - fixed: false, - x: 0, - y: 8, - fullHeight: false, - w: 6, - h: 2 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 12, - h: 2 - }, - data: { - type: 'containercomponent', - configuration: {}, - customCss: { - container: { - class: '!p-0', - style: '' - } - }, - actions: undefined, - numberOfSubgrids: 1, - id: 'topbar' - }, - id: 'topbar' - } - ], - fullscreen: false, - unusedInlineScripts: [], - hiddenInlineScripts: [], - css: {}, - norefreshbar: false, - hideLegacyTopBar: true, - subgrids: { - 'a-0': [ - { - '3': { - fixed: false, - x: 0, - y: 1, - w: 3, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 19, - fullHeight: false - }, - data: { - type: 'schemaformcomponent', - configuration: { - displayType: { - type: 'static', - value: false - }, - largeGap: { - type: 'static', - value: false - } - }, - componentInput: { - type: 'static', - fieldType: 'schema', - value: schema - }, - customCss: structuredClone(ccomponents['schemaformcomponent'].customCss), - id: 'c' - }, - id: 'c' - }, - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 1, - h: 1, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 19, - w: 12, - h: 1, - fullHeight: false - }, - data: { - type: 'buttoncomponent', - configuration: { - label: { - type: 'static', - value: 'Submit' - }, - color: { - type: 'static', - value: 'dark' - }, - size: { - type: 'static', - value: 'xs' - }, - fillContainer: { - type: 'static', - value: false - }, - disabled: { - type: 'evalv2', - expr: '!c.valid', - connections: [ - { - componentId: 'c', - id: 'valid' - } - ] - }, - beforeIcon: { - type: 'static', - value: '' - }, - afterIcon: { - type: 'static', - value: '' - }, - triggerOnAppLoad: { - type: 'static', - value: false - }, - onSuccess: { - type: 'oneOf', - selected: 'none', - configuration: { - none: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendToast: { - message: { - type: 'static', - value: '' - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - }, - onError: { - type: 'oneOf', - selected: 'errorOverlay', - configuration: { - errorOverlay: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendErrorToast: { - message: { - type: 'static', - value: '' - }, - appendError: { - type: 'static', - value: true - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - } - }, - componentInput: { - type: 'runnable', - fieldType: 'any', - fields: convertSchemaToFields(schema), - runnable: { - type: 'path', - path: path, - runType: 'script', - schema: schema, - name: path - }, - autoRefresh: true, - recomputeOnInputChanged: true - }, - customCss: structuredClone(ccomponents['buttoncomponent'].customCss), - recomputeIds: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'd' - }, - id: 'd' - } - ], - 'a-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 20, - fullHeight: false - }, - data: { - type: 'tabscomponent', - configuration: { - tabsKind: { - type: 'static', - value: 'tabs' - } - }, - tabs: ['Result', 'Logs'], - customCss: structuredClone(ccomponents['tabscomponent'].customCss), - numberOfSubgrids: 2, - id: 'b', - disabledTabs: [ - { - type: 'static', - value: false, - fieldType: 'boolean' - }, - { - type: 'static', - value: false, - fieldType: 'boolean' - } - ] - }, - id: 'b' - } - ], - 'b-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'displaycomponent', - configuration: {}, - componentInput: { - type: 'connected', - fieldType: 'object', - connection: { - componentId: 'd', - path: 'result' - } - }, - customCss: structuredClone(ccomponents['displaycomponent'].customCss), - id: 'e' - }, - id: 'e' - } - ], - 'b-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidlogcomponent', - configuration: { - jobId: { - type: 'connected', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidlogcomponent'].customCss), - id: 'f' - }, - id: 'f' - } - ], - 'topbar-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'textcomponent', - configuration: { - style: { - type: 'static', - value: 'Body' - }, - copyButton: { - type: 'static', - value: false - }, - tooltip: { - type: 'evalv2', - value: '', - fieldType: 'text', - expr: '`Author: ${ctx.author}`', - connections: [ - { - componentId: 'ctx', - id: 'author' - } - ] - }, - disableNoText: { - type: 'static', - value: true, - fieldType: 'boolean' - } - }, - componentInput: { - type: 'templatev2', - fieldType: 'template', - eval: '${ctx.summary}', - connections: [ - { - id: 'summary', - componentId: 'ctx' - } - ] - }, - customCss: { - text: { - class: 'text-xl font-semibold whitespace-nowrap truncate', - style: '' - }, - container: { - class: '', - style: '' - } - }, - actions: undefined, - horizontalAlignment: 'left', - verticalAlignment: 'center', - id: 'title' - }, - id: 'title' - }, - { - '3': { - fixed: false, - x: 0, - y: 1, - fullHeight: false, - w: 3, - h: 1 - }, - '12': { - fixed: false, - x: 6, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'recomputeallcomponent', - configuration: {}, - customCss: { - container: { - style: '', - class: '' - } - }, - menuItems: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'recomputeall' - }, - id: 'recomputeall' - } - ] - } - } -} - -type Field = { - type: 'static' | 'connected' - value: any - fieldType?: string - format?: string - connection?: { - componentId: string - path: string - } - allowUserResources?: boolean -} - -function convertSchemaToFields(schema: Record | undefined): { [key: string]: Field } { - const fields: { [key: string]: Field } = {} - - if (!schema) { - return fields - } - - Object.entries(schema.properties).forEach(([fieldName, fieldInfo]: [string, any]) => { - fields[fieldName] = { - type: 'connected', - value: fieldInfo.default, - fieldType: fieldInfo.type, - format: fieldInfo.format, - allowUserResources: true, - connection: { - componentId: 'c', - path: `values.${fieldName}` - } - } - }) - - return fields -} - -export function createAppFromFlow(path: string, schema: Record | undefined) { - return { - grid: [ - { - '3': { - fixed: false, - x: 0, - y: 2, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 2, - w: 12, - h: 21, - fullHeight: false - }, - data: { - type: 'verticalsplitpanescomponent', - configuration: {}, - panes: [50, 50], - customCss: structuredClone(ccomponents['verticalsplitpanescomponent'].customCss), - numberOfSubgrids: 2, - id: 'a' - }, - id: 'a' - }, - { - '3': { - fixed: true, - x: 0, - y: 8, - fullHeight: false, - w: 6, - h: 2 - }, - '12': { - fixed: true, - x: 0, - y: 0, - fullHeight: false, - w: 12, - h: 2 - }, - data: { - type: 'containercomponent', - configuration: {}, - customCss: { - container: { - class: '!p-0', - style: '' - } - }, - numberOfSubgrids: 1, - id: 'topbar' - }, - id: 'topbar' - } - ], - fullscreen: false, - unusedInlineScripts: [], - hiddenInlineScripts: [], - css: {}, - norefreshbar: false, - hideLegacyTopBar: true, - subgrids: { - 'a-0': [ - { - '3': { - fixed: false, - x: 0, - y: 1, - w: 3, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 19, - fullHeight: false - }, - data: { - type: 'schemaformcomponent', - configuration: { - displayType: { - type: 'static', - value: false - }, - largeGap: { - type: 'static', - value: false - } - }, - componentInput: { - type: 'static', - fieldType: 'schema', - value: schema - }, - customCss: structuredClone(ccomponents['schemaformcomponent'].customCss), - id: 'c' - }, - id: 'c' - }, - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 1, - h: 1, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 19, - w: 12, - h: 1, - fullHeight: false - }, - data: { - type: 'buttoncomponent', - configuration: { - label: { - type: 'static', - value: 'Submit' - }, - color: { - type: 'static', - value: 'dark' - }, - size: { - type: 'static', - value: 'xs' - }, - fillContainer: { - type: 'static', - value: false - }, - disabled: { - type: 'evalv2', - expr: '!c.valid', - connections: [ - { - componentId: 'c', - id: 'valid' - } - ] - }, - beforeIcon: { - type: 'static', - value: '' - }, - afterIcon: { - type: 'static', - value: '' - }, - triggerOnAppLoad: { - type: 'static', - value: false - }, - onSuccess: { - type: 'oneOf', - selected: 'none', - configuration: { - none: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendToast: { - message: { - type: 'static', - value: '' - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - }, - onError: { - type: 'oneOf', - selected: 'errorOverlay', - configuration: { - errorOverlay: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendErrorToast: { - message: { - type: 'static', - value: '' - }, - appendError: { - type: 'static', - value: true - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - } - }, - componentInput: { - type: 'runnable', - fieldType: 'any', - fields: convertSchemaToFields(schema), - runnable: { - type: 'path', - path: path, - runType: 'flow', - schema: schema, - name: path - }, - autoRefresh: false, - recomputeOnInputChanged: false - }, - customCss: structuredClone(ccomponents['buttoncomponent'].customCss), - recomputeIds: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'd' - }, - id: 'd' - } - ], - 'a-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 20, - fullHeight: false - }, - data: { - type: 'tabscomponent', - configuration: { - tabsKind: { - type: 'static', - value: 'tabs' - } - }, - tabs: ['Result', 'Logs'], - customCss: structuredClone(ccomponents['tabscomponent'].customCss), - numberOfSubgrids: 2, - id: 'b', - disabledTabs: [ - { - type: 'static', - value: false, - fieldType: 'boolean' - }, - { - type: 'static', - value: false, - fieldType: 'boolean' - } - ] - }, - id: 'b' - } - ], - 'b-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidflowstatuscomponent', - configuration: { - jobId: { - type: 'connected', - value: '', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidflowstatuscomponent'].customCss), - id: 'e' - }, - id: 'e' - } - ], - 'b-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidlogcomponent', - configuration: { - jobId: { - type: 'connected', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidlogcomponent'].customCss), - id: 'f' - }, - id: 'f' - } - ], - 'topbar-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'textcomponent', - configuration: { - style: { - type: 'static', - value: 'Body' - }, - copyButton: { - type: 'static', - value: false - }, - tooltip: { - type: 'evalv2', - value: '', - fieldType: 'text', - expr: '`Author: ${ctx.author}`', - connections: [ - { - componentId: 'ctx', - id: 'author' - } - ] - }, - disableNoText: { - type: 'static', - value: true, - fieldType: 'boolean' - } - }, - componentInput: { - type: 'templatev2', - fieldType: 'template', - eval: '${ctx.summary}', - connections: [ - { - id: 'summary', - componentId: 'ctx' - } - ] - }, - customCss: { - text: { - class: 'text-xl font-semibold whitespace-nowrap truncate', - style: '' - }, - container: { - class: '', - style: '' - } - }, - horizontalAlignment: 'left', - verticalAlignment: 'center', - id: 'title' - }, - id: 'title' - }, - { - '3': { - fixed: false, - x: 0, - y: 1, - fullHeight: false, - w: 3, - h: 1 - }, - '12': { - fixed: false, - x: 6, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'recomputeallcomponent', - configuration: {}, - customCss: { - container: { - style: '', - class: '' - } - }, - menuItems: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'recomputeall' - }, - id: 'recomputeall' - } - ] - } - } -} diff --git a/frontend/src/lib/components/details/createRawAppFromScript.test.ts b/frontend/src/lib/components/details/createRawAppFromScript.test.ts new file mode 100644 index 0000000000..a8f9a9e0e2 --- /dev/null +++ b/frontend/src/lib/components/details/createRawAppFromScript.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest' + +import { + RESERVED_LOCALS, + createRawAppFromFlow, + createRawAppFromScript +} from './createRawAppFromScript' + +describe('createRawAppFromScript', () => { + it('builds a path runnable and a form calling it', () => { + const app = createRawAppFromScript('u/dev/greet_user', 'Greet a user', { + type: 'object', + order: ['name', 'age', 'mode'], + required: ['name', 'age'], + properties: { + name: { type: 'string' }, + age: { type: 'integer', default: 42 }, + mode: { type: 'string', enum: ['fast', 'slow'] } + } + }) + + expect(app.summary).toBe('Greet a user') + expect(app.value.runnables).toEqual({ + greet_user: { + name: 'u/dev/greet_user', + type: 'path', + runType: 'script', + path: 'u/dev/greet_user', + schema: expect.objectContaining({ type: 'object' }), + fields: {} + } + }) + + const appTsx = app.value.files['/App.tsx'] + expect(appTsx).toContain("const [name, setName] = useState('')") + expect(appTsx).toContain("const [ageText, setAgeText] = useState('42')") + // Required args are passed unconditionally so they satisfy the non-optional + // type `genWmillTs` derives from the same schema. + expect(appTsx).toContain('age: Number(ageText)') + expect(appTsx).toContain('mode,') + expect(appTsx).toContain('await backend.greet_user({') + expect(appTsx).toContain('') + }) + + it('lets an emptied optional number input mean "unset"', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + properties: { n: { type: 'number' } } + }) + expect(app.value.files['/App.tsx']).toContain("n: nText === '' ? undefined : Number(nText)") + }) + + it('renames arguments that would shadow the component locals', () => { + const app = createRawAppFromFlow('u/dev/f', 'Flow', { + type: 'object', + required: ['result'], + properties: { result: { type: 'string' } } + }) + const appTsx = app.value.files['/App.tsx'] + expect(appTsx).toContain("const [result_, setResult_] = useState('')") + expect(appTsx).toContain('result: result_') + expect(app.value.runnables['f'].runType).toBe('flow') + }) + + it('keeps every generated binding unique and syntactically valid', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + order: ['foo', 'setFoo', 'class'], + properties: { + foo: { type: 'string' }, + setFoo: { type: 'string' }, + class: { type: 'string' } + } + }) + const appTsx = app.value.files['/App.tsx'] + expect(appTsx).toContain("const [foo, setFoo] = useState('')") + expect(appTsx).toContain("const [setFoo_, setSetFoo_] = useState('')") + expect(appTsx).toContain("const [class_, setClass_] = useState('')") + + const declared = [...appTsx.matchAll(/const \[(\w+), (\w+)\]/g)].flatMap((m) => [m[1], m[2]]) + expect(new Set(declared).size).toBe(declared.length) + }) + + it('does not let an argument shadow a global the generated body calls', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + order: ['JSON', 'eval'], + properties: { JSON: { type: 'string' }, eval: { type: 'string' } } + }) + const appTsx = app.value.files['/App.tsx'] + expect(appTsx).toContain("const [JSON_, setJSON_] = useState('')") + expect(appTsx).toContain("const [eval_, setEval_] = useState('')") + // The result panel must still reach the real global. + expect(appTsx).toContain('JSON.stringify(result, null, 2)') + }) + + it('keeps the undefined sentinel reachable when an argument is named undefined', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + order: ['undefined', 'n'], + properties: { undefined: { type: 'string' }, n: { type: 'number' } } + }) + const appTsx = app.value.files['/App.tsx'] + expect(appTsx).toContain("const [undefined_, setUndefined_] = useState('')") + expect(appTsx).toContain('undefined: undefined_') + // The omitted-optional sentinel and the render guards must still be the + // real global, not the field's value. + expect(appTsx).toContain("nText === '' ? undefined : Number(nText)") + expect(appTsx).toContain('{result !== undefined &&') + expect(appTsx).toContain('{error !== undefined &&') + }) + + it('masks password arguments and declares them sensitive on the runnable', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + required: ['token'], + properties: { token: { type: 'string', password: true }, plain: { type: 'string' } } + }) + expect(app.value.files['/App.tsx']).toContain('type="password"') + // The policy's `sensitive_inputs` is derived from these fields, so without + // them the secret is stored in the job args in the clear. + expect(app.value.runnables['s'].fields).toEqual({ + token: { type: 'user', value: undefined, sensitive: true } + }) + }) + + it('passes a __proto__ argument as an own property', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + required: ['__proto__'], + // Computed key: a plain `__proto__:` here would set this literal's + // prototype and the property would not exist at all. + properties: { ['__proto__']: { type: 'number' } } + }) + expect(app.value.files['/App.tsx']).toContain('["__proto__"]: Number(__proto__Text)') + }) + + it('omits blank optional text instead of sending an empty string', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + required: ['req'], + properties: { req: { type: 'string' }, opt: { type: 'string' } } + }) + const appTsx = app.value.files['/App.tsx'] + expect(appTsx).toContain("opt: opt === '' ? undefined : opt") + expect(appTsx).toContain('req,') + // The required marker has to be enforced, not just drawn. + expect(appTsx).toMatch(/type="text"\n\t+required\n\t+value=\{req\}/) + }) + + it('lets a resource argument through as a user-supplied resource', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + required: ['db'], + properties: { db: { type: 'object', format: 'resource-postgresql' } } + }) + // Without `allowUserResources` the backend swaps the submitted `$res:` + // reference for a placeholder and the argument never resolves. + expect(app.value.runnables['s'].fields).toEqual({ + db: { type: 'user', value: undefined, allowUserResources: true } + }) + const appTsx = app.value.files['/App.tsx'] + // Empty, not a bare `$res:`: an untouched optional field must read as + // omitted and an untouched required one must trip the `required` check. + expect(appTsx).toContain("const [db, setDb] = useState('')") + expect(appTsx).toContain('resource path, e.g. $res:u/user/my_postgresql') + }) + + it('omits untouched optional object and array fields', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + required: ['reqObj'], + properties: { + reqObj: { type: 'object' }, + optObj: { type: 'object' }, + optArr: { type: 'array' } + } + }) + const appTsx = app.value.files['/App.tsx'] + // A pre-filled `{}` could never reach the omission branch, so an untouched + // field would override the runnable's own default. + expect(appTsx).toContain("const [optObjText, setOptObjText] = useState('')") + expect(appTsx).toContain("const [optArrText, setOptArrText] = useState('')") + expect(appTsx).toContain("const [reqObjText, setReqObjText] = useState('{}')") + }) + + it('quotes argument names that cannot be bare property keys', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + required: ["user's-name"], + properties: { "user's-name": { type: 'string' } } + }) + // `str` would pick a template literal here, which is not a legal property + // name, so the module would not compile. + expect(app.value.files['/App.tsx']).toContain('"user\'s-name": user_s_name') + }) + + // Every global the template names is an identifier a schema property could + // shadow, so each one has to be reserved. + it('reserves every global the generated source names', () => { + const app = createRawAppFromScript('u/dev/s', 'T', { + type: 'object', + required: ['a', 'tags'], + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + c: { type: 'object' }, + d: { type: 'boolean' }, + tags: { type: 'array', items: { type: 'string' }, enum: ['x', 'y'] }, + res: { type: 'object', format: 'resource-postgresql' }, + pw: { type: 'string', password: true } + } + }) + const src = app.value.files['/App.tsx'] + const globals = [ + 'Array', + 'BigInt', + 'Boolean', + 'Date', + 'Error', + 'Infinity', + 'JSON', + 'Map', + 'Math', + 'NaN', + 'Number', + 'Object', + 'Promise', + 'RegExp', + 'Set', + 'String', + 'Symbol', + 'console', + 'globalThis', + 'isNaN', + 'parseFloat', + 'parseInt', + 'undefined' + ] + const named = globals.filter((g) => new RegExp(`(? !RESERVED_LOCALS.includes(g))).toEqual([]) + }) + + it('keeps an array enum an array', () => { + const app = createRawAppFromScript('u/dev/s', undefined, { + type: 'object', + required: ['tags'], + properties: { + tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], default: ['a'] } + } + }) + const appTsx = app.value.files['/App.tsx'] + // `genWmillTs` types this `string[]`, so scalar state would not compile. + expect(appTsx).toContain("const [tags, setTags] = useState(['a'] as string[])") + // `required` does constrain a `` would generate state the runnable call rejects. + return prop?.type === 'array' ? 'multienum' : 'enum' + } + if (prop?.type === 'boolean') return 'boolean' + if (prop?.type === 'number' || prop?.type === 'integer') return 'number' + if (prop?.type === 'string') return prop.password === true ? 'password' : 'text' + return 'json' +} + +// Newline excluded: a template literal can carry it verbatim. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u0009\u000b-\u001f\u007f]/ + +/** TS string literal. Prefers the quoting that keeps the generated source + * readable, falling back to `JSON.stringify`'s escaping. */ +function str(value: string): string { + if (value.includes('\\') || value.includes('\r') || CONTROL_CHARS.test(value)) { + return JSON.stringify(value) + } + if (!value.includes("'") && !value.includes('\n')) return `'${value}'` + if (!value.includes('`') && !value.includes('${')) return `\`${value}\`` + return JSON.stringify(value) +} + +/** JSX text that would otherwise be swallowed by the parser (`{`, `<`, `}`) or + * rewritten by it (`&` starts an entity, so a literal `&` would decode to + * `&`) goes through an expression container, which JSX copies verbatim. */ +function jsxText(value: string): string { + return /^[^{}<>&]*$/.test(value) ? value : `{${str(value)}}` +} + +/** JSX attribute value: a plain quoted string when it can be, an expression + * container otherwise. Entities decode in attributes too, so `&` disqualifies + * the plain form — an enum value must reach the runnable unchanged. */ +function jsxAttr(value: string): string { + return /^[^"{}<>&\n]*$/.test(value) ? `"${value}"` : `{${str(value)}}` +} + +/** The schema's own description, plus what the control can't convey on its own: + * which resource type to point at, and that an object-typed secret is stored + * encrypted even though its textarea shows it in the clear (the platform's own + * ArgInput says the same rather than masking it). */ +function fieldHint(prop: any, kind: FieldKind): string | undefined { + const own = + typeof prop.description === 'string' && prop.description !== '' ? prop.description : undefined + let extra: string | undefined + if (kind === 'resource') { + extra = `resource path, e.g. $res:u/user/my_${String(prop.format).slice('resource-'.length)}` + } else if (kind === 'json' && prop.password === true) { + extra = 'stored as a secret on submit' + } + if (!extra) return own + return own ? `${own} (${extra})` : extra +} + +function toFields(schema: Record | undefined): Field[] { + const properties: Record = schema?.properties ?? {} + const order: string[] = Array.isArray(schema?.order) ? schema.order : [] + const keys = [ + ...order.filter((k) => k in properties), + ...Object.keys(properties).filter((k) => !order.includes(k)) + ] + const required: string[] = Array.isArray(schema?.required) ? schema.required : [] + const taken = [...RESERVED_LOCALS] + + return keys.map((key) => { + const prop = properties[key] ?? {} + const kind = fieldKind(prop) + const isRequired = required.includes(key) + const { local, setter } = allocLocal( + kind === 'number' || kind === 'json' ? `${key}Text` : key, + taken + ) + const enumValues: EnumOption[] = enumSource(prop).map((v: any) => + v != undefined && typeof v === 'object' + ? { value: String(v.value), label: String(v.label ?? v.value) } + : { value: String(v), label: String(v) } + ) + + let init: string + let arg: string + if (kind === 'boolean') { + init = prop.default === true ? 'true' : 'false' + arg = local + } else if (kind === 'number') { + init = str(prop.default != undefined ? String(prop.default) : '') + // An emptied input means "unset", which only type-checks when the + // argument is optional. + arg = isRequired ? `Number(${local})` : `${local} === '' ? undefined : Number(${local})` + } else if (kind === 'json') { + // Only a required field is pre-filled with an empty collection: a + // non-blank optional textarea can never take the omission branch, so an + // untouched one would override the runnable's own default. + const seed = + prop.default != undefined + ? JSON.stringify(prop.default, null, 2) + : isRequired + ? JSON.stringify(prop.type === 'array' ? [] : {}, null, 2) + : '' + init = str(seed) + arg = isRequired + ? `JSON.parse(${local})` + : `${local}.trim() === '' ? undefined : JSON.parse(${local})` + } else if (kind === 'multienum') { + const defaults = Array.isArray(prop.default) ? prop.default.map((v: any) => String(v)) : [] + // Annotated: `useState([])` alone infers `never[]`. + init = `[${defaults.map(str).join(', ')}] as string[]` + arg = isRequired ? local : `${local}.length === 0 ? undefined : ${local}` + } else if (kind === 'enum') { + // An optional enum gets a blank option so the runnable's own default + // stays reachable; a required one always carries a real selection. + const seed = typeof prop.default === 'string' ? prop.default : '' + init = str(seed !== '' || !isRequired ? seed : (enumValues[0]?.value ?? '')) + arg = isRequired ? local : `${local} === '' ? undefined : ${local}` + } else if (kind === 'resource') { + // Starts empty, never at a bare `$res:`: an untouched optional field has + // to read as omitted, and an untouched required one has to trip the + // browser's `required` check rather than ask the backend to resolve an + // empty path. The hint carries the expected shape instead. + init = str(typeof prop.default === 'string' ? prop.default : '') + arg = isRequired ? local : `${local} === '' ? undefined : ${local}` + } else { + init = str(typeof prop.default === 'string' ? prop.default : '') + // Blank optional text is "unset", so the runnable's own default applies + // rather than an empty string overriding it. + arg = isRequired ? local : `${local} === '' ? undefined : ${local}` + } + + return { + key, + kind, + local, + setter, + label: typeof prop.title === 'string' && prop.title !== '' ? prop.title : key, + description: fieldHint(prop, kind), + required: isRequired, + sensitive: prop.password === true, + allowUserResources: isResourceProp(prop), + enumValues, + init, + arg + } + }) +} + +function fieldInput(field: Field): string { + // The `*` marker is only decorative without this: the browser has to block + // the submit, else an empty required field posts '' (or NaN) and fails + // server-side. Two exemptions: `required` on a checkbox would force it on, and + // a required single `` can be empty, so it does take the attribute. + const req = + field.required && field.kind !== 'boolean' && field.kind !== 'enum' + ? '\n\t\t\t\t\t\trequired' + : '' + switch (field.kind) { + case 'boolean': + return ` ${field.setter}(e.target.checked)} + />` + case 'number': + return ` ${field.setter}(e.target.value)} + />` + case 'enum': + return `` + case 'multienum': + // `size` is resolved here and the handler spreads rather than calling + // `Math.min` / `Array.from`: every global the template names is one more + // identifier an argument could shadow, so the template names none. + return `` + case 'json': + return ` - {/if} -
- + argName="stop_after_if" + argType="javascript" + collapsed={!isStopAfterIfEnabled || isParallelLoop} + animateAppear + header={stopAfterToggle} + noDynamicToggle + schema={predicateSchema} + previousModuleId={undefined} + pickableProperties={stepPropPicker.pickableProperties} + extraLib={`declare const result = ${JSON.stringify(earlyStopResult)};\n` + + stepPropPicker.extraLib + + (isLoop ? `\ndeclare const all_iters = ${JSON.stringify(result)};` : '')} + bind:editor={stopAfterEditor} + /> + + {#if isStopAfterIfEnabled && !breakableParent && !isLoop && flowModule.stop_after_if} + {@render stopStatusPicker(flowModule.stop_after_if)} + {/if} + {/if} - {#if isLoop || isBranchAll} -
- {#snippet header()} - - If defined, at the end of the step, the predicate expression will be evaluated to decide - if the flow should stop early, skip rest of steps in iteration/branch if inside a parallel - for loop or branch all, or break if inside a for/while loop or branch all. - - {/snippet} - - { - if (isStopAfterAllIterationsEnabled && flowModule.stop_after_all_iters_if) { - flowModule.stop_after_all_iters_if = undefined - } else { - flowModule.stop_after_all_iters_if = { - expr: 'result == undefined', - skip_if_stopped: false, - error_message: undefined, - error_include_result: false + {#if blocks !== 'stop-after' && (isLoop || isBranchAll)} +
+ { + stopAfterAllItersEditor?.insertAtCursor(detail) + stopAfterAllItersEditor?.focus() + }} + > + flowModule.stop_after_all_iters_if, + (v) => { + flowModule.stop_after_all_iters_if = v } } - }} - options={{ - right: - (breakableParent - ? breakableParent.isParallel - ? breakableParent.type === 'loop' - ? 'Skip rest of steps in iteration' - : 'Skip rest of steps in branch' - : 'Break parent loop module ' + breakableParent.stepId - : 'Stop flow') + ' if condition met' - }} - /> - -
- {#if flowModule.stop_after_all_iters_if} - {#if !breakableParent} -
- { - 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 - } - }} - options={{ - right: 'Label flow as "skipped" if stopped' - }} - /> - { - if (flowModule.stop_after_all_iters_if) { - if (event.detail) { - flowModule.stop_after_all_iters_if.error_message = '' - 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 - } - } - }} - options={{ - right: 'Raise an error message if stopped', - rightTooltip: - 'If enabled and the stop condition is met, an error message will be raised. A custom message can be provided; otherwise, a default message will be used. Mutually exclusive with "Label flow as skipped".' - }} - /> -
- {/if} - {#if raise_error_message_stop_after_all_if} - - - {/if} - Stop condition expression -
- { - editor?.insertAtCursor(detail) - editor?.focus() - }} - > - - -
- {:else} - {#if !breakableParent} -
- - -
- {/if} - Stop condition expression - - {/if} -
-
+ argName="stop_after_all_iters_if" + argType="javascript" + collapsed={!isStopAfterAllIterationsEnabled} + animateAppear + header={stopAfterAllItersToggle} + noDynamicToggle + schema={predicateSchema} + previousModuleId={undefined} + pickableProperties={stepPropPicker.pickableProperties} + extraLib={`declare const result = ${JSON.stringify(result)};\n` + stepPropPicker.extraLib} + bind:editor={stopAfterAllItersEditor} + /> + + {#if isStopAfterAllIterationsEnabled && !breakableParent && flowModule.stop_after_all_iters_if} + {@render stopStatusPicker(flowModule.stop_after_all_iters_if)} + {/if} + {/if} diff --git a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte index b34a78e1db..63d053fe14 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte @@ -2,24 +2,10 @@ import Button from '$lib/components/common/button/Button.svelte' import { type FlowModule } from '$lib/gen' import { createEventDispatcher, getContext } from 'svelte' - import { - Bed, - Database, - Gauge, - GitFork, - Pen, - PhoneIncoming, - RefreshCcw, - Repeat, - Square, - Pin, - Save, - Settings - } from 'lucide-svelte' - import Popover from '../../Popover.svelte' + import { Pen, RefreshCcw, Save } from 'lucide-svelte' + import DropdownV2 from '../../DropdownV2.svelte' import type { FlowEditorContext } from '../types' import { sendUserToast } from '$lib/utils' - import { getLatestHashForScript } from '$lib/scripts' import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' import FlowModuleWorkerTagSelect from './FlowModuleWorkerTagSelect.svelte' @@ -29,134 +15,14 @@ } let { module, tag }: Props = $props() - const { scriptEditorDrawer, workspaceScriptSettingsDrawer, flowEditorDrawer, opWorkspace } = - getContext('FlowEditorContext') + const { flowEditorDrawer } = getContext('FlowEditorContext') const dispatch = createEventDispatcher() let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi') - - let popoverClasses = - 'center-center rounded p-2 bg-blue-100 text-blue-800 border border-blue-300 hover:bg-blue-200 dark:bg-frost-700 dark:text-frost-100 dark:border-frost-600' -
- {#if module.value.type === 'script' || module.value.type === 'rawscript' || module.value.type == 'flow'} - {#if module.retry?.constant || module.retry?.exponential} - dispatch('toggleRetry')}> - - {#snippet text()} - Retries - {/snippet} - - {/if} - {#if module?.value?.['concurrent_limit'] != undefined} - dispatch('toggleConcurrency')} - > - - {#snippet text()} - Concurrency Limits - {/snippet} - - {/if} - {#if module.cache_ttl != undefined} - dispatch('toggleCache')}> - - {#snippet text()} - Cache - {/snippet} - - {/if} - {#if module.stop_after_if || module.stop_after_all_iters_if} - dispatch('toggleStopAfterIf')} - > - - {#snippet text()} - Early stop/break - {/snippet} - - {/if} - {#if module.suspend} - dispatch('toggleSuspend')}> - - {#snippet text()} - Suspend - {/snippet} - - {/if} - {#if module.sleep} - dispatch('toggleSleep')}> - - {#snippet text()} - Sleep - {/snippet} - - {/if} - {#if module.mock?.enabled} - dispatch('togglePin')}> - - {#snippet text()} - This step is pinned - {/snippet} - - {/if} - {/if} +
{#if module.value.type === 'script'} - {#if !module.value.path.startsWith('hub/') && customUi?.scriptEdit != false} - - + dispatch('createScriptFromInlineScript') + } + ]} + /> {/if}
diff --git a/frontend/src/lib/components/flows/content/FlowModuleMock.svelte b/frontend/src/lib/components/flows/content/FlowModuleMock.svelte index e3d4cb3bea..e56fee962c 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleMock.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleMock.svelte @@ -2,11 +2,11 @@ import { run } from 'svelte/legacy' import Toggle from '$lib/components/Toggle.svelte' - import Tooltip from '$lib/components/Tooltip.svelte' import type { FlowModule } from '$lib/gen' - import { Section } from '$lib/components/common' + import Label from '$lib/components/Label.svelte' import JsonEditor from '$lib/components/JsonEditor.svelte' import { untrack } from 'svelte' + import { slideDynamic } from '$lib/transitions' interface Props { flowModule: FlowModule @@ -56,47 +56,38 @@ } -
- {#snippet header()} -
- - If defined and enabled, the step will immediately return the mock value instead of being - executed. - - { - if (isMockEnabled) { - flowModule.mock = { - enabled: false, - return_value: flowModule.mock?.return_value - } - } else { - flowModule.mock = { - enabled: true, - return_value: flowModule.mock?.return_value ?? { example: 'value' } - } - code = JSON.stringify(flowModule.mock?.return_value, null, 2) - } - }} - size="xs" - /> +
+ { + if (isMockEnabled) { + flowModule.mock = { + enabled: false, + return_value: flowModule.mock?.return_value + } + } else { + flowModule.mock = { + enabled: true, + return_value: flowModule.mock?.return_value ?? { example: 'value' } + } + code = JSON.stringify(flowModule.mock?.return_value, null, 2) + } + }} + options={{ + right: 'Pin output', + rightTooltip: + 'While pinned, the step returns this value immediately instead of executing. The same control lives on the step in the graph.' + }} + /> + {#if isMockEnabled} +
+
- {/snippet} - -
- Mocked Return value - - {#if isMockEnabled} - {#key renderCount} - - {/key} - {:else} -
{flowModule.mock?.return_value
-					? JSON.stringify(flowModule.mock?.return_value, null, 2)
-					: ''}
- {/if} -
-
+ {/if} +
diff --git a/frontend/src/lib/components/flows/content/FlowModuleMockTransitionMessage.svelte b/frontend/src/lib/components/flows/content/FlowModuleMockTransitionMessage.svelte deleted file mode 100644 index 34384cc506..0000000000 --- a/frontend/src/lib/components/flows/content/FlowModuleMockTransitionMessage.svelte +++ /dev/null @@ -1,145 +0,0 @@ - - - - -
- -
-
-
NEW
- Mock has evolved into - - - PIN - -
-
- Find it in: - "Test this step" tab -
-
- - -
- - How to use the PIN feature: - - -
- -
-
1
-
-
Pick a result from history
-
- - - History picker -
-
-
- - -
-
2
-
-
Pin it as a fixed output
-
- - - Pin action -
-
-
- - -
-
-
-
The last pin can be recovered from history
-
- - - Recover pins -
-
-
- - -
-
- -
-
-
All of this can be done from the flow view
-
- - - Flow view pinning -
-
-
-
-
-
diff --git a/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte b/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte index ffb0a8d502..51d75b0ca6 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte @@ -1,12 +1,13 @@ -
-
- {#snippet header()} - - If the condition is met, the step will behave as an identity step, passing the previous - step's result through unchanged. - - {/snippet} +{#snippet skipToggle()} + { + if (isSkipEnabled && flowModule.skip_if) { + flowModule.skip_if = undefined + } else { + flowModule.skip_if = stepSettingDefaults('skip') + } + }} + options={{ + right: 'Skip step if', + rightTooltip: + "If the condition is met, the step behaves as an identity step, passing the previous step's result through unchanged." + }} + /> +{/snippet} - { - if (isSkipEnabled && flowModule.skip_if) { - flowModule.skip_if = undefined - } else { - flowModule.skip_if = { - expr: 'false' - } +
+ { + editor?.insertAtCursor(detail) + editor?.focus() + }} + > + flowModule.skip_if, + (v) => { + flowModule.skip_if = v } - }} - options={{ - right: 'Skip step if condition is met' - }} + } + argName="skip_if" + argType="javascript" + collapsed={!isSkipEnabled} + animateAppear + header={skipToggle} + noDynamicToggle + {schema} + previousModuleId={previousModule?.id} + pickableProperties={stepPropPicker.pickableProperties} + extraLib={`declare const result = ${JSON.stringify(result)};\n` + stepPropPicker.extraLib} + bind:editor /> - -
- {#if flowModule.skip_if} - Skip condition expression -
- { - editor?.insertAtCursor(detail) - editor?.focus() - }} - > - - -
- {:else} - Skip condition expression - - {/if} -
-
+
diff --git a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte index 92086ccd37..03dcf2b6f5 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte @@ -2,16 +2,14 @@ import InputTransformForm from '$lib/components/InputTransformForm.svelte' import type SimpleEditor from '$lib/components/SimpleEditor.svelte' import Toggle from '$lib/components/Toggle.svelte' - import Tooltip from '$lib/components/Tooltip.svelte' import type { FlowModule } from '$lib/gen' + import { stepSettingDefaults } from '../flowStepSettings' import { emptySchema } from '$lib/utils' import { getContext } from 'svelte' import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte' import type { FlowEditorContext } from '../types' - import { SecondsInput } from '../../common' - import Section from '$lib/components/Section.svelte' - import Label from '$lib/components/Label.svelte' import { getStepPropPicker } from '../previousResults' + import { slideDynamic } from '$lib/transitions' import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte' import { Alert } from '$lib/components/common' @@ -51,66 +49,54 @@ let sameWorker = $derived(Boolean(!isAgentTool && flowStore.val.value.same_worker)) -
- {#snippet header()} - - If defined, at the end of the step, the flow will sleep for a number of seconds before - scheduling the next job (if any, no effect if the step is the last one). - - {/snippet} - +
{#if sameWorker} - + {SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use a sleep. {/if} - { if (isSleepEnabled && flowModule.sleep != undefined) { flowModule.sleep = undefined } else { - flowModule.sleep = { - type: 'static', - value: 0 - } + flowModule.sleep = stepSettingDefaults('sleep') } }} options={{ - right: 'Sleep after module successful execution' + right: 'Sleep after step', + rightTooltip: + 'At the end of the step, the flow sleeps for a number of seconds before scheduling the next job (no effect if the step is the last one).', + rightDocumentationLink: 'https://www.windmill.dev/docs/flows/sleep' }} /> - -
+ {#if flowModule.sleep && schema.properties['sleep'] && !sameWorker} +
+ { + editor?.insertAtCursor(detail) + editor?.focus() + }} + > + + +
+ {/if} + diff --git a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte index 3a9e791d2d..a20bb8c671 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte @@ -1,22 +1,23 @@ -
- {#snippet action()} - - {/snippet} - {#snippet header()} -
- - If defined, at the end of the step, the flow will be suspended until it receives external - requests to be resumed or canceled. This is most useful to implement approval steps but can - be used flexibly for other purposes. - - { - if (isSuspendEnabled && flowModule.suspend != undefined) { - flowModule.suspend = undefined - } else { - flowModule.suspend = { - required_events: 1, - timeout: 1800 - } - } - }} - options={{ - right: 'Suspend flow execution until events/approvals received' - }} - /> -
- {/snippet} +
+ { + if (isSuspendEnabled && flowModule.suspend != undefined) { + flowModule.suspend = undefined + } else { + flowModule.suspend = stepSettingDefaults('suspend') + } + }} + options={{ + right: 'Suspend until approval/resume', + rightTooltip: + 'At the end of the step, the flow is suspended until it receives external requests to resume or cancel it. Most useful for approval steps, but can be used flexibly for other purposes.', + rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_approval' + }} + /> -
- - - - - -
+ {#if isSuspendEnabled} +
+
+ + + + + +
- {#if suspendTabSelected === 'core'} -
- - + {#if suspendTabSelected === 'core'} +
+ + - { - if (flowModule.suspend) { - flowModule.suspend.continue_on_disapprove_timeout = e.detail - } - }} - /> - {#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)} - - We recommend using the expr resume?.error to handle null payload values. -
- To filter timeout, use resume?.error?.name === "SuspendedTimedOut".
- To filter disapproval, use resume?.error?.name === "SuspendedDisapproved" -
- {/if} -
- {:else if suspendTabSelected === 'permissions'} -
- {#if emptyString($enterpriseLicense)} - - {/if} - {#if flowModule.suspend} -
- { if (flowModule.suspend) { - flowModule.suspend.user_auth_required = e.detail - if (e.detail && flowModule.suspend?.user_groups_required === undefined) { - flowModule.suspend.user_groups_required = { - type: 'static', - value: [] + flowModule.suspend.continue_on_disapprove_timeout = e.detail + } + }} + /> + {#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)} + + We recommend using the expr resume?.error to handle null payload values. +
+ To filter timeout, use resume?.error?.name === "SuspendedTimedOut". +
+ To filter disapproval, use resume?.error?.name === "SuspendedDisapproved" +
+ {/if} +
+ {:else if suspendTabSelected === 'permissions'} +
+
+ { + if (flowModule.suspend) { + flowModule.suspend.user_auth_required = e.detail + if (e.detail && flowModule.suspend?.user_groups_required === undefined) { + flowModule.suspend.user_groups_required = { + type: 'static', + value: [] + } + } else if (!e.detail) { + flowModule.suspend.user_groups_required = undefined + flowModule.suspend.self_approval_disabled = false } - } else if (!e.detail) { - flowModule.suspend.user_groups_required = undefined - flowModule.suspend.self_approval_disabled = false } - } - }} - /> + }} + /> - { - if (flowModule.suspend) { - flowModule.suspend.self_approval_disabled = e.detail - } - }} - /> + { + if (flowModule.suspend) { + flowModule.suspend.self_approval_disabled = e.detail + } + }} + /> -
+
- {#if Boolean(flowModule.suspend.user_auth_required) && allUserGroups.length !== 0 && flowModule.suspend && schema.properties['groups']} - Require approvers to be members of one of the following user groups (leave empty for - any) - -
+ {#if Boolean(flowModule.suspend?.user_auth_required) && allUserGroups.length !== 0 && flowModule.suspend && schema.properties['groups']} + Require approvers to be members of one of the following user groups (leave empty + for any) + { @@ -231,75 +237,79 @@ bind:editor /> + {/if} +
+
+ {:else} +
+ {#if flowModule?.suspend?.resume_form} +
+
+ +
+ +
+ {:else if emptyString($enterpriseLicense)} + + {:else} + + {/if} + + flowModule.suspend?.resume_form?.schema ?? draftFormSchema, + (v) => { + if (flowModule.suspend) { + flowModule.suspend.resume_form = { schema: v } + } + } + } + drawerOnly + /> + + {#if flowModule.suspend?.resume_form} + { + if (flowModule.suspend) { + flowModule.suspend.hide_cancel = e.detail + } + }} + options={{ + right: 'Hide cancel button on approval page' + }} + /> {/if}
{/if} -
- {:else} -
-
- {#if flowModule?.suspend?.resume_form} - - {:else if emptyString($enterpriseLicense)} - - {:else} -
- { - if (flowModule.suspend) { - flowModule.suspend.resume_form = { - schema: emptySchema() - } - } - jsonView = true - }} - /> -
- { - jsonView = false - if (flowModule.suspend) { - flowModule.suspend.resume_form = { - schema: e.detail - } - } - }} - schema={{}} - /> - {/if} -
-
- {#if flowModule.suspend} - {#if emptyString($enterpriseLicense)} - - {/if} -
-
- -
-
- {/if} - {#if flowModule.suspend} - - {/if} +
+
{/if} -
+ diff --git a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte index 62a3432995..9f6a1b32b2 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte @@ -1,19 +1,17 @@ -
- {#snippet header()} - - If defined, the custom timeout will be used instead of the instance timeout for the step. The - step's timeout cannot be greater than the instance timeout. - - {/snippet} - +
{ if (istimeoutEnabled && flowModule.timeout != undefined) { @@ -77,39 +70,37 @@ } }} options={{ - right: 'Add a custom timeout for this step' + right: 'Custom timeout', + rightTooltip: + "The custom timeout is used instead of the instance timeout for the step. The step's timeout cannot be greater than the instance timeout." }} /> - + {#if flowModule.timeout && schema.properties['timeout']} +
+ { + editor?.insertAtCursor(detail) + editor?.focus() + }} + > + + +
+ {/if} {#if flowModule.timeout && flowModule.timeout.type !== 'static'} -
+

A dynamic timeout expression is evaluated when running the full flow. It is ignored when @@ -118,4 +109,4 @@

{/if} -
+ diff --git a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte index 0cae21b630..6f832902d0 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte @@ -1,8 +1,11 @@ @@ -44,7 +38,6 @@ {#if isOwner !== undefined && suspendStatus} { + const r = flowModuleRetry + if (!r) return + untrack(() => { + if (delayType === 'constant' && !r.constant && r.exponential) { + delayType = 'exponential' + } else if (delayType === 'exponential' && !r.exponential && r.constant) { + delayType = 'constant' + } else if (delayType === 'disabled' && (r.constant || r.exponential)) { + // Retries added from outside: without this the row keeps reading "off" while + // rendering the incoming values greyed out, and toggling on would overwrite them. + delayType = r.constant ? 'constant' : 'exponential' + } + }) + }) + let displayRetry = $derived( + flowModuleRetry ?? { + constant: { attempts: 1, seconds: 5 }, + exponential: { attempts: 1, multiplier: 1, seconds: 5, random_factor: 0 } + } + ) + // Always-defined sub-configs so the read-only off-state can bind without + // undefined checks; when the real config exists these are the same refs. + let displayConstant = $derived(displayRetry.constant ?? { attempts: 1, seconds: 5 }) + let displayExponential = $derived( + displayRetry.exponential ?? { attempts: 1, multiplier: 1, seconds: 5, random_factor: 0 } + ) + // Only feed the preview the branch that's actually shown, so the off-state + // defaults don't render a bogus combined schedule. + let previewRetry = $derived( + retriesOff + ? displayDelayType === 'constant' + ? { constant: displayRetry.constant } + : { exponential: displayRetry.exponential } + : flowModuleRetry + ) let result = $derived( flowModule && flowStateStore?.val ? (flowStateStore.val[flowModule.id]?.previewResult ?? NEVER_TESTED_THIS_FAR) @@ -99,10 +157,12 @@ } function initialLoad() { + // Presence, not attempts > 0: a retry block with zero attempts is still + // configured, and flowStepSettings describes it that way. delayType = - (flowModuleRetry?.constant?.attempts ?? 0) > 0 + flowModuleRetry?.constant != undefined ? 'constant' - : (flowModuleRetry?.exponential?.attempts ?? 0) > 0 + : flowModuleRetry?.exponential != undefined ? 'exponential' : 'disabled' loaded = true @@ -122,258 +182,289 @@ const u32Max = 4294967295 -
+
{#if sameWorker} {SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use retries. {/if} - - { - flowModuleRetry = undefined - if (e.detail === 'constant') { + checked={delayType === 'constant' || delayType === 'exponential'} + on:change={() => { + if (delayType === 'constant' || delayType === 'exponential') { flowModuleRetry = undefined + delayType = 'disabled' + } else { setConstantRetries() - } else if (e.detail === 'exponential') { - flowModuleRetry = undefined - setExponentialRetries() + delayType = 'constant' } }} - > - {#snippet children({ item })} - - - - {/snippet} - + options={{ + right: 'Retry on failure', + rightTooltip: + 'Upon error this step is retried with a delay and a maximum number of attempts as defined below.', + rightDocumentationLink: 'https://www.windmill.dev/docs/flows/retries' + }} + /> - {#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker} -
- {#snippet header()} - - Optional condition to determine when to retry. If not specified, will retry on any failure - within the configured attempt limits. - - {/snippet} - - { - if (!flowModuleRetry) { - return + {#if !retriesOff && !sameWorker} +
+ { + flowModuleRetry = undefined + if (e.detail === 'constant') { + setConstantRetries() + delayType = 'constant' + } else if (e.detail === 'exponential') { + setExponentialRetries() + delayType = 'exponential' } - if (isRetryConditionEnabled && flowModuleRetry.retry_if) { - const { retry_if, ...rest } = flowModuleRetry - flowModuleRetry = rest - } else { - flowModuleRetry = { - ...flowModuleRetry, - retry_if: { - expr: 'error && error.name !== "PERMANENT_FAILURE"' + }} + > + {#snippet children({ item })} + + + {/snippet} + + + {#snippet retryConditionToggle()} + { + if (!flowModuleRetry) { + return + } + if (isRetryConditionEnabled && flowModuleRetry.retry_if) { + const { retry_if, ...rest } = flowModuleRetry + flowModuleRetry = rest + } else { + flowModuleRetry = { + ...flowModuleRetry, + retry_if: { + expr: 'error && error.name !== "PERMANENT_FAILURE"' + } } } - } - }} - options={{ - right: 'Only retry if condition is met' - }} - /> + }} + options={{ + right: 'Conditional retry', + rightTooltip: + 'Optional condition to determine when to retry. Expression should return true to retry, false to skip retry. If not specified, retries on any failure within the configured attempt limits.' + }} + /> + {/snippet} -
- {#if flowModuleRetry?.retry_if} - Retry condition expression - Expression should return true to retry, false to skip retry -
- {#if stepPropPicker} - { - editor?.insertAtCursor(detail) - editor?.focus() - }} - > - - - {:else} + {#if stepPropPicker} + { + retryIfEditor?.insertAtCursor(detail) + retryIfEditor?.focus() + }} + > + flowModuleRetry?.retry_if, + (v) => { + if (flowModuleRetry) flowModuleRetry.retry_if = v + } + } + argName="retry_if" + argType="javascript" + collapsed={!isRetryConditionEnabled} + animateAppear + header={retryConditionToggle} + noDynamicToggle + schema={predicateSchema} + previousModuleId={undefined} + pickableProperties={stepPropPicker.pickableProperties} + extraLib={`declare const result = ${JSON.stringify(result)};` + + `\ndeclare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input || {})};`} + bind:editor={retryIfEditor} + /> + + {:else} +
+ {@render retryConditionToggle()} + {#if flowModuleRetry?.retry_if} +
- {/if} -
- {:else} - Retry condition expression - Expression should return true to retry, false to skip retry - - {/if} -
-
- {/if} +
+ {/if} +
+ {/if} - {#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker} -
-
- {#if delayType === 'constant'} - {#if flowModuleRetry?.constant} -
Attempts
-
+
+
+ {#if displayDelayType === 'constant'} +
Attempts
+
+ + +
+
Delay
+ + {:else if displayDelayType === 'exponential'} +
Attempts
+
+ + +
+
Multiplier
+ delay = multiplier * base ^ (number of attempt) - -
-
Delay
- - {/if} - {:else if delayType === 'exponential'} - {#if flowModuleRetry?.exponential} -
Attempts
-
- - -
-
Multiplier
- delay = multiplier * base ^ (number of attempt) - -
Base (in seconds)
- - {#if validationError} - {validationError} - {:else} - Must be ≥ 1. A base of 0 would cause immediate retries. - {/if} -
Randomization factor (percentage)
-
- {#if !$enterpriseLicense} - - {/if} +
Base (in seconds)
-
+ {#if validationError} + {validationError} + {:else} + Must be ≥ 1. A base of 0 would cause immediate retries. + {/if} +
Randomization factor (percentage)
+
+ {#if !$enterpriseLicense} + + {/if} +
+ +
-
- {/if} - {/if} -
-
- {#if true} - {@const { attempts: cAttempts, seconds: cSeconds } = flowModuleRetry?.constant || {}} - {@const { - attempts: eAttempts, - seconds: eSeconds, - multiplier, - random_factor - } = flowModuleRetry?.exponential || {}} - {@const cArray = Array.from({ length: Math.min(cAttempts || 0, 100) }, () => cSeconds)} - {@const eArray = Array.from( - { length: Math.min(eAttempts || 0, 100) }, - (_, i) => (multiplier || 0) * (eSeconds || 0) ** (i + cArray.length + 1) - )} - {@const array = [...cArray, ...eArray]} -
-
Retry attempts
- {#if array.length > 0} - - - - - - - - - {#each array.slice(1, 100) as delay, i} - {@const index = i + 2} - - - -
1:After {array[0]} second{array[0] === 1 ? '' : 's'} - {#if (random_factor ?? 0) > 0}(+/- {((array[0] ?? 0) * (random_factor ?? 0)) / - 100} - seconds){/if}
{index}: - {delay} second{delay === 1 ? '' : 's'} - {#if (random_factor ?? 0) > 0}(+/- {((delay ?? 0) * (random_factor ?? 0)) / - 100} - seconds){/if} - after attempt #{index - 1} - {#if i > cArray.length - 2} - - ({multiplier} * {eSeconds}{index}) - + {/if} + +
+ {#if true} + {@const { attempts: cAttempts, seconds: cSeconds } = previewRetry?.constant || {}} + {@const { + attempts: eAttempts, + seconds: eSeconds, + multiplier, + random_factor + } = previewRetry?.exponential || {}} + {@const cArray = Array.from({ length: Math.min(cAttempts || 0, 100) }, () => cSeconds)} + {@const eArray = Array.from( + { length: Math.min(eAttempts || 0, 100) }, + (_, i) => (multiplier || 0) * (eSeconds || 0) ** (i + cArray.length + 1) + )} + {@const array = [...cArray, ...eArray]} +
+
Retry attempts
+ {#if array.length > 0} + + + + + + + + + {#each array.slice(1, 100) as delay, i} + {@const index = i + 2} + + + + + {/each} + {#if (cAttempts ?? 0) > 100 || (eAttempts ?? 0) > 100} + + + + {/if} - - - {/each} - {#if (cAttempts ?? 0) > 100 || (eAttempts ?? 0) > 100} - - - - + +
1:After {array[0]} second{array[0] === 1 ? '' : 's'} + {#if (random_factor ?? 0) > 0}(+/- {((array[0] ?? 0) * + (random_factor ?? 0)) / + 100} + seconds){/if}
{index}: + {delay} second{delay === 1 ? '' : 's'} + {#if (random_factor ?? 0) > 0}(+/- {((delay ?? 0) * + (random_factor ?? 0)) / + 100} + seconds){/if} + after attempt #{index - 1} + {#if i > cArray.length - 2} + + ({multiplier} * {eSeconds}{index}) + + {/if} +
......
......
{/if} -
- {/if} +
+ {/if} +
- {/if}
-
{/if}
diff --git a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte new file mode 100644 index 0000000000..9c0c6d7de5 --- /dev/null +++ b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte @@ -0,0 +1,371 @@ + + +{#snippet sectionHeader(title: string)} +
+ {title} +
+{/snippet} + +
+ {#if !isFailure} +
+ {@render sectionHeader('Flow control')} + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ {/if} + +
+ {@render sectionHeader('Execution policy')} + +
+ {#if !loopSubset} +
+ +
+ +
+ +
+ + + {#if !isFailure} +
+ +
+ + {#if isRawScript || isWorkspaceScript} +
+ {#if flowModule.value.type === 'script'} + + {:else if flowModule.value.type === 'rawscript'} +
+ { + if (flowModule.value.type !== 'rawscript') return + flowModule.value.concurrent_limit = concurrencyOn ? undefined : 1 + }} + options={{ + right: 'Concurrency limit', + rightTooltip: 'Allowed concurrency within a given timeframe.', + rightDocumentationLink: + 'https://www.windmill.dev/docs/flows/concurrency_limit' + }} + /> + {#if concurrencyOn} +
+ + + +
+ {/if} +
+ {/if} +
+ {/if} + +
+ { + if (flowModule.priority !== undefined) { + flowModule.priority = undefined + } else { + flowModule.priority = stepSettingDefaults('priority') + } + }} + options={{ + right: 'High priority', + rightTooltip: + 'Jobs scheduled from this step take precedence over other jobs in the queue when the flow runs.' + }} + /> + {#if flowModule.priority !== undefined} +
+ +
+ {/if} + + {#if isCloudHosted()} + + Setting priority is not available on the cloud. + + {/if} +
+ +
+ +
+ +
+ +
+ {/if} + {/if} + + {#if !isFailure} +
+ +
+ {/if} + + {#if loopSubset} +
+ +
+ {/if} +
+ + {#if s3Language && onApplyS3Snippet && !isFailure} +
+ {@render sectionHeader('S3 snippets')} +

+ Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes. +

+
+ + {#snippet children({ item })} + {#if s3Language === 'deno'} + + {:else} + + + + {/if} + {/snippet} + + +
+ {#if s3Snippet} +
+ +
+ {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index 51a8ab5253..8e78a04269 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -116,7 +116,7 @@
-
+
diff --git a/frontend/src/lib/components/flows/content/McpToolEditor.svelte b/frontend/src/lib/components/flows/content/McpToolEditor.svelte index b73cc93ef9..027766d5d4 100644 --- a/frontend/src/lib/components/flows/content/McpToolEditor.svelte +++ b/frontend/src/lib/components/flows/content/McpToolEditor.svelte @@ -18,6 +18,7 @@ -
- - {#snippet children()} -

- MCP clients allow AI agents to access and execute a list of tools made available by an MCP - server. -
- Choose an MCP resource to make its tools available to the agent. -
-
- Note: Only HTTP streamable MCP servers are supported. -

- {/snippet} -
+ +
+ + {#snippet children()} +

+ MCP clients allow AI agents to access and execute a list of tools made available by an MCP + server. +
+ Choose an MCP resource to make its tools available to the agent. +
+
+ Note: Only HTTP streamable MCP servers are supported. +

+ {/snippet} +
-
- -
- - {#if !resourcePath} - {#if !showOAuthForm} - - {:else} - (showOAuthForm = false)} - /> - {/if} - {/if} - - {#if resourcePath?.length > 0}
-
-
- {#snippet action()} - - {/snippet} -
- {#if error} -
{`Failed to load tools from MCP server: ${error}`}
- {:else if tools.status === 'loading'} -
-
Loading tools...
-
- {:else if (tools.value ?? []).length === 0 && !error} -
-
- No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server. -
-
- {:else if (tools.value ?? []).length > 0} -
-
- {#each tools.value ?? [] as mcpTool} -
- {mcpTool.name} - {#if mcpTool.description} - — {mcpTool.description} - {/if} -
- {/each} -
-
- {/if} -
-
+ {:else} + (showOAuthForm = false)} + /> + {/if} + {/if} - {#if tool.value.include_tools && tool.value.exclude_tools} -
-
-
- -
-
- -
+ {#if resourcePath?.length > 0} +
+ +
+ +
+ {#snippet action()} + + {/snippet} +
+ {#if error} +
{`Failed to load tools from MCP server: ${error}`}
+ {:else if tools.status === 'loading'} +
+
Loading tools...
+
+ {:else if (tools.value ?? []).length === 0 && !error} +
+
+ No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server. +
+
+ {:else if (tools.value ?? []).length > 0} +
+
+ {#each tools.value ?? [] as mcpTool} +
+ {mcpTool.name} + {#if mcpTool.description} + — {mcpTool.description} + {/if} +
+ {/each} +
+
+ {/if}
+ + {#if tool.value.include_tools && tool.value.exclude_tools} +
+
+
+ +
+
+ +
+
+
+ {/if} {/if} - {/if} -
+
+
diff --git a/frontend/src/lib/components/flows/content/StepSettingsBadges.svelte b/frontend/src/lib/components/flows/content/StepSettingsBadges.svelte new file mode 100644 index 0000000000..b92a8a05a5 --- /dev/null +++ b/frontend/src/lib/components/flows/content/StepSettingsBadges.svelte @@ -0,0 +1,30 @@ + + +{#if configured.length > 0} +
+ {#each configured as s (s.key)} + {@const Icon = s.icon} + + + {#snippet text()} + {s.tooltip} + · {s.summary.text} + {/snippet} + + {/each} +
+{/if} diff --git a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte index 3ac2afe001..e351c2812c 100644 --- a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte +++ b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte @@ -16,21 +16,26 @@
- To add a form, go to the Form tab, inside the Advanced {'->'} Suspend tab, and add a form. - You can then get back the payloads using `resume` (single approver), or `resumes` (multiple approvers) - in the next step. Forms are an EE feature only. The approver list itself is fetchable using `approvers` + To add a form, open the Form tab of this Suspend until approval/resume setting + and click + Add a form. You can then get back the payloads using `resume` (single approver), or + `resumes` (multiple approvers) in the next step. Forms are an EE feature only. The approver + list itself is fetchable using `approvers`
A prompt is simply an approval step that can be self-approved. To do this, include the diff --git a/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte b/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte index 77e4d07c61..2d679ff665 100644 --- a/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte +++ b/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte @@ -1,10 +1,15 @@ -
- - Gives the AI Agent the ability to search the web. Only works for openai, anthropic and google - models for now. - -
+ +
+ + Gives the AI Agent the ability to search the web. Only works for openai, anthropic and google + models for now. + +
+
diff --git a/frontend/src/lib/components/flows/flowDeleteController.test.ts b/frontend/src/lib/components/flows/flowDeleteController.test.ts index b029ce702f..0a37e1ec7c 100644 --- a/frontend/src/lib/components/flows/flowDeleteController.test.ts +++ b/frontend/src/lib/components/flows/flowDeleteController.test.ts @@ -134,7 +134,11 @@ describe('flowDeleteController', () => { expect(flowStore.val.value.modules.map((module) => module.id)).toEqual(['dependent_step']) expect(flowStore.val.value.groups ?? []).toEqual([]) expect(Object.keys(flowStateStore.val)).toEqual(['dependent_step']) - expect(selectionManager.selectId).toHaveBeenCalledWith('dependent_step') + // The surviving step is selected as a side effect of the delete, so it must be + // marked as such — otherwise the modal step panel pops open on its own. + expect(selectionManager.selectId).toHaveBeenCalledWith('dependent_step', { + openPanel: false + }) expect(onDelete).toHaveBeenCalledWith('agent_step') }) }) diff --git a/frontend/src/lib/components/flows/flowDeleteController.ts b/frontend/src/lib/components/flows/flowDeleteController.ts index 5fea6a4cc3..a1933ba080 100644 --- a/frontend/src/lib/components/flows/flowDeleteController.ts +++ b/frontend/src/lib/components/flows/flowDeleteController.ts @@ -56,7 +56,9 @@ export function executeDeletePlan( if (plan.selection.kind === 'clear') { args.selectionManager.clearSelection() } else { - args.selectionManager.selectId(plan.selection.id) + // Whatever remains selected after a delete was not asked for, so it must not + // pop the modal panel open. + args.selectionManager.selectId(plan.selection.id, { openPanel: false }) } if (plan.targets.some((target) => target.kind === 'preprocessor')) { diff --git a/frontend/src/lib/components/flows/flowPanelMode.svelte.ts b/frontend/src/lib/components/flows/flowPanelMode.svelte.ts new file mode 100644 index 0000000000..2d9b505d89 --- /dev/null +++ b/frontend/src/lib/components/flows/flowPanelMode.svelte.ts @@ -0,0 +1,28 @@ +import { resolvePanelMode, type FlowPanelMode, type FlowPanelPreference } from './panelPlacement' + +/** + * Holds the step panel's placement preference and the editor's measured width, and reads + * the resolution off `resolvePanelMode`. The preference is not persisted: it lasts as long + * as the editor is open, so every flow opens on `auto` and a pin is a deliberate act each + * time. + */ +export function useFlowPanelMode(opts: { enabled: () => boolean }) { + let preference = $state('auto') + let width = $state(0) + + return { + get preference(): FlowPanelPreference { + return preference + }, + set preference(next: FlowPanelPreference) { + preference = next + }, + get mode(): FlowPanelMode { + return resolvePanelMode({ enabled: opts.enabled(), preference, width }) + }, + /** Fed by the editor root's measured width; drives `auto` in both directions. */ + measure(measured: number | null | undefined) { + width = measured ?? 0 + } + } +} diff --git a/frontend/src/lib/components/flows/flowStepSettings.test.ts b/frontend/src/lib/components/flows/flowStepSettings.test.ts new file mode 100644 index 0000000000..7ecb138214 --- /dev/null +++ b/frontend/src/lib/components/flows/flowStepSettings.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest' +import type { FlowModule } from '$lib/gen' +import { describeStepSettings, hasInlineConcurrency } from './flowStepSettings' + +const stepSettingsByKey = (...args: Parameters) => + Object.fromEntries(describeStepSettings(...args).map((v) => [v.key, v])) + +function step(overrides: Partial = {}): FlowModule { + return { + id: 'a', + value: { type: 'rawscript', language: 'bun', content: '', input_transforms: {} }, + ...overrides + } as FlowModule +} + +describe('describeStepSettings', () => { + it('reports an untouched step as configured nowhere', () => { + expect(describeStepSettings(step()).filter((s) => s.configured)).toEqual([]) + }) + + it('treats a sleep of 0 as configured and says so, rather than claiming it is off', () => { + // Switching Sleep on seeds `{ value: 0 }`; the row must not contradict the toggle. + const s = stepSettingsByKey(step({ sleep: { type: 'static', value: 0 } }))['sleep'] + expect(s?.configured).toBe(true) + expect(s?.summary.text).toBe('0s after') + expect(s?.summary.state).toBe('configured') + }) + + it('does not let an empty stop_after_if mask a configured stop_after_all_iters_if', () => { + // Both can be set on a sequential loop. + const s = stepSettingsByKey( + step({ + stop_after_if: { expr: '', skip_if_stopped: false }, + stop_after_all_iters_if: { expr: 'result.done', skip_if_stopped: false } + }) + )['early-stop'] + expect(s?.configured).toBe(true) + expect(s?.summary.text).toBe('result.done') + }) + + it('describes retries with zero attempts instead of reporting None', () => { + const s = stepSettingsByKey(step({ retry: { constant: { attempts: 0, seconds: 5 } } }))[ + 'retries' + ] + expect(s?.configured).toBe(true) + expect(s?.summary.text).toBe('0 attempts, constant') + }) + + it('reads concurrency and cache from the referenced script for workspace-script steps', () => { + const mod = step({ value: { type: 'script', path: 'u/me/s', input_transforms: {} } as any }) + const off = stepSettingsByKey(mod) + expect(off['concurrency']?.configured).toBe(false) + + const on = stepSettingsByKey(mod, { concurrent_limit: 3, cache_ttl: 60 }) + expect(on['concurrency']?.configured).toBe(true) + expect(on['concurrency']?.summary.text).toBe('Max 3') + expect(on['cache']?.configured).toBe(true) + }) + + it("prefers the module's own cache_ttl over the referenced script's, like the worker", () => { + const mod = step({ + cache_ttl: 3600, + value: { type: 'script', path: 'u/me/s', input_transforms: {} } as any + }) + // No referenced settings loaded yet (the graph badges never load them), so a + // module-level TTL has to stand on its own here. + expect(stepSettingsByKey(mod)['cache']?.configured).toBe(true) + expect(stepSettingsByKey(mod, { cache_ttl: 60 })['cache']?.summary.text).toBe('1 h') + }) + + it('reports a non-positive inline concurrency as invalid, not as unset', () => { + const s = stepSettingsByKey( + step({ + cache_ttl: -1, + value: { + type: 'rawscript', + language: 'bun', + content: '', + input_transforms: {}, + concurrent_limit: -1 + } + } as Partial) + ) + // `configured` means the step carries the config, so a value the user set counts + // even when the runtime ignores it — the summary is what says it is a no-op. + expect(s['concurrency']?.configured).toBe(true) + expect(s['concurrency']?.summary).toMatchObject({ text: 'Invalid limit', state: 'invalid' }) + expect(s['cache']?.configured).toBe(true) + expect(s['cache']?.summary).toMatchObject({ text: 'No TTL set', state: 'invalid' }) + }) + + it('treats a cleared concurrency input as present, not as unset', () => { + // Emptying a number input binds `null`, not `undefined`. Reading that as unset is + // what disabled the field the user was editing, so presence must be strict. + const mod = step({ + value: { + type: 'rawscript', + language: 'bun', + content: '', + input_transforms: {}, + concurrent_limit: null + } + } as unknown as Partial) + // Presence keeps the setting editor's controls live while the field is empty. + expect(hasInlineConcurrency(mod)).toBe(true) + expect(stepSettingsByKey(mod)['concurrency']?.summary).toMatchObject({ + text: 'Invalid limit', + state: 'invalid' + }) + }) + + it('omits settings that do not apply to the step type', () => { + const subflow = step({ value: { type: 'flow', path: 'u/me/f' } as any }) + expect(describeStepSettings(subflow).some((s) => s.key === 'concurrency')).toBe(false) + expect(describeStepSettings(step()).some((s) => s.key === 'concurrency')).toBe(true) + }) + + it('marks an invalid retry config as invalid rather than configured', () => { + const s = stepSettingsByKey( + step({ retry: { exponential: { attempts: 2, multiplier: 1, seconds: -1 } } }) + )['retries'] + expect(s?.summary.state).toBe('invalid') + }) + + it('labels early stop for trigger steps by what it means there', () => { + const trigger = step({ + value: { + type: 'rawscript', + language: 'bun', + content: '', + input_transforms: {}, + is_trigger: true + } as any + }) + expect(stepSettingsByKey(trigger)['early-stop']?.tooltip).toBe( + 'Stop early if there are no new events' + ) + expect(stepSettingsByKey(step())['early-stop']?.tooltip).toBe('Early stop / break') + }) +}) diff --git a/frontend/src/lib/components/flows/flowStepSettings.ts b/frontend/src/lib/components/flows/flowStepSettings.ts new file mode 100644 index 0000000000..9c995a93a9 --- /dev/null +++ b/frontend/src/lib/components/flows/flowStepSettings.ts @@ -0,0 +1,362 @@ +import { + ChevronsUp, + CircleStop, + Combine, + Database, + Gauge, + Hand, + Moon, + RefreshCw, + ShieldAlert, + SkipForward, + Timer, + Trash2 +} from 'lucide-svelte' +import type { FlowModule } from '$lib/gen' +import type { ScriptAdvancedSettingsFields } from '$lib/components/scriptSettings' +import { validateRetryConfig } from '$lib/utils' + +// Single source for the per-step runtime settings: which apply, which are configured, +// and how each reads back. Graph badges, the run-settings accordion and the setting +// editors all read it here so they cannot drift. Script-level twin: `scriptSettings.ts`. + +export type StepSettingKey = + | 'skip' + | 'early-stop' + | 'suspend' + | 'sleep' + | 'retries' + | 'error-handling' + | 'timeout' + | 'concurrency' + | 'priority' + | 'cache' + | 'debounce' + | 'lifetime' + +export type StepSettingSummary = { + text: string + state: 'configured' | 'default' | 'invalid' + /** Render the text as code (it is a user-written expression). */ + mono?: boolean +} + +export type StepSettingView = { + key: StepSettingKey + label: string + /** Longer wording for hover surfaces; falls back to `label`. */ + tooltip: string + icon: any + /** The setting's config is present on the step. Deliberately not "the runtime + * would behave differently" — a configured setting can still be a no-op + * (a sleep of 0), and `summary` says so rather than claiming it is off. */ + configured: boolean + summary: StepSettingSummary +} + +/** A step whose script polls an external system and returns the new items. */ +export function isTriggerStep(module: FlowModule | undefined): boolean { + return ( + module?.value != undefined && + (module.value.type === 'script' || module.value.type === 'rawscript') && + module.value.is_trigger === true + ) +} + +const def = (text: string): StepSettingSummary => ({ text, state: 'default' }) +const inv = (text: string): StepSettingSummary => ({ text, state: 'invalid' }) +const cfg = (text: string, mono = false): StepSettingSummary => ({ + text, + state: 'configured', + mono +}) + +function formatDur(s: number | undefined): string { + if (s == null) return '' + if (s < 60) return `${s}s` + if (s < 3600) return `${Math.round(s / 60)} min` + return `${Math.round(s / 3600)} h` +} + +/** Describe a user-written predicate. An empty expression is still configured — + * the setting is on, it just has nothing to evaluate yet. */ +function exprSummary(expr: string | undefined): StepSettingSummary { + const e = expr?.trim() + if (!e) return cfg('No expression') + return e.length <= 24 ? cfg(e, true) : cfg('Expression set') +} + +type Ctx = { referenced?: ScriptAdvancedSettingsFields } + +type SettingSpec = { + label: string + tooltip?: (mod: FlowModule) => string + icon: any + applies?: (mod: FlowModule) => boolean + configured: (mod: FlowModule, ctx: Ctx) => boolean + summarize: (mod: FlowModule, ctx: Ctx) => StepSettingSummary +} + +// A value the step itself carries counts as configured even when the runtime would +// ignore it — `summary` is what reports the no-op, and the setting editors keep their +// controls live on presence so clearing a field mid-edit can't disable it. Only values +// read off a referenced workspace script use the runtime's `> 0` test: nobody is typing +// into those, and 0 there simply means the script has no limit. +const isWorkspaceScript = (mod: FlowModule) => mod.value.type === 'script' +const inlineConcurrentLimit = (mod: FlowModule) => + mod.value.type === 'rawscript' ? mod.value.concurrent_limit : undefined +const effectiveCacheTtl = (mod: FlowModule, ctx: Ctx) => + mod.cache_ttl ?? (isWorkspaceScript(mod) ? ctx.referenced?.cache_ttl : undefined) + +/** Whether an inline step carries a concurrency limit at all. The setting editor keeps its + * controls live on presence, so clearing the field mid-edit can't disable the input. A + * present-but-non-positive limit is surfaced as invalid rather than silently as "None". + * Strict: an emptied number input binds to `null`, which is still a value being typed. */ +export function hasInlineConcurrency(mod: FlowModule): boolean { + return inlineConcurrentLimit(mod) !== undefined +} + +/** Canonical order — every surface lists settings in this sequence. */ +const SPECS: { key: StepSettingKey; spec: SettingSpec }[] = [ + { + key: 'skip', + spec: { + label: 'Skip if', + icon: SkipForward, + configured: (m) => Boolean(m.skip_if), + summarize: (m) => (m.skip_if ? exprSummary(m.skip_if.expr) : def('Off')) + } + }, + { + key: 'early-stop', + spec: { + label: 'Early stop / break', + tooltip: (m) => + isTriggerStep(m) ? 'Stop early if there are no new events' : 'Early stop / break', + icon: CircleStop, + configured: (m) => m.stop_after_if != undefined || m.stop_after_all_iters_if != undefined, + summarize: (m) => { + // Both can be set on a sequential loop, so pick the first that carries an + // expression instead of letting an empty one mask the other. + const exprs = [m.stop_after_if?.expr, m.stop_after_all_iters_if?.expr].filter( + (e) => e != undefined + ) + if (exprs.length === 0) return def('Off') + return exprSummary(exprs.find((e) => e?.trim()) ?? exprs[0]) + } + } + }, + { + key: 'suspend', + spec: { + label: 'Suspend until approval/resume', + icon: Hand, + configured: (m) => Boolean(m.suspend), + summarize: (m) => { + if (!m.suspend) return def('Off') + const n = m.suspend.required_events ?? 1 + return cfg(`${n} approval${n > 1 ? 's' : ''}`) + } + } + }, + { + key: 'sleep', + spec: { + label: 'Sleep', + icon: Moon, + configured: (m) => Boolean(m.sleep), + summarize: (m) => { + const s = m.sleep + if (!s) return def('Off') + if (s.type === 'static') { + const v = Number(s.value) + return Number.isFinite(v) ? cfg(`${formatDur(v)} after`) : cfg('Dynamic') + } + return cfg('Dynamic') + } + } + }, + { + key: 'retries', + spec: { + label: 'Retries', + icon: RefreshCw, + configured: (m) => m.retry?.constant != undefined || m.retry?.exponential != undefined, + summarize: (m) => { + const r = m.retry + if (r?.constant == undefined && r?.exponential == undefined) return def('None') + if (validateRetryConfig(r)) return { text: 'Invalid', state: 'invalid' } + const isConstant = r?.constant != undefined + const n = (isConstant ? r?.constant?.attempts : r?.exponential?.attempts) ?? 0 + const kind = isConstant ? 'constant' : 'exponential' + return cfg(`${n} attempt${n === 1 ? '' : 's'}, ${kind}`) + } + } + }, + { + key: 'error-handling', + spec: { + label: 'Error handling', + icon: ShieldAlert, + configured: (m) => Boolean(m.continue_on_error), + summarize: (m) => (m.continue_on_error ? cfg('Continue on error') : def('Off')) + } + }, + { + key: 'timeout', + spec: { + label: 'Timeout', + icon: Timer, + configured: (m) => m.timeout != null, + summarize: (m) => { + const t = m.timeout + if (t == null) return def('None') + if (typeof t === 'number') return cfg(formatDur(t)) + if (t.type === 'static') { + const v = Number(t.value) + return Number.isFinite(v) ? cfg(formatDur(v)) : cfg('Dynamic') + } + return cfg('Dynamic') + } + } + }, + { + key: 'concurrency', + spec: { + label: 'Concurrency limit', + icon: Gauge, + applies: (m) => m.value.type === 'rawscript' || m.value.type === 'script', + // Presence for the step's own limit (the user may be mid-edit, and `summary` says + // when it is a no-op), effectiveness for the referenced script's — a remote + // value nobody is typing into, where 0 just means the script has no limit. + configured: (m, ctx) => + isWorkspaceScript(m) + ? ctx.referenced?.concurrent_limit != undefined && ctx.referenced.concurrent_limit > 0 + : hasInlineConcurrency(m), + summarize: (m, ctx) => { + if (isWorkspaceScript(m)) { + const l = ctx.referenced?.concurrent_limit + return l != undefined && l > 0 ? cfg(`Max ${l}`) : def('None') + } + const l = inlineConcurrentLimit(m) + if (l === undefined) return def('None') + if (!(l > 0)) return inv('Invalid limit') + const key = m.value.type === 'rawscript' ? m.value.custom_concurrency_key : undefined + return cfg(`Max ${l}${key ? ' per key' : ''}`) + } + } + }, + { + key: 'priority', + spec: { + label: 'Priority', + icon: ChevronsUp, + configured: (m) => m.priority !== undefined, + summarize: (m) => { + if (m.priority === undefined) return def('Off') + // 0 is how the runtime spells "no priority". + return m.priority > 0 ? cfg('High priority') : inv('No priority set') + } + } + }, + { + key: 'cache', + spec: { + label: 'Cache results', + icon: Database, + // The worker takes the module's cache_ttl over the referenced script's, so a + // module-level TTL must show here even for a workspace-script step. + configured: (m, ctx) => + m.cache_ttl !== undefined || (isWorkspaceScript(m) && (ctx.referenced?.cache_ttl ?? 0) > 0), + summarize: (m, ctx) => { + const ttl = effectiveCacheTtl(m, ctx) + if (ttl === undefined) return def('Off') + return ttl > 0 ? cfg(formatDur(ttl)) : inv('No TTL set') + } + } + }, + { + key: 'debounce', + spec: { + label: 'Debounce', + icon: Combine, + configured: (m) => m.debouncing?.debounce_delay_s !== undefined, + summarize: (m) => { + const d = m.debouncing?.debounce_delay_s + if (d === undefined) return def('Off') + return d > 0 ? cfg(`${formatDur(d)} debounce`) : inv('No delay set') + } + } + }, + { + key: 'lifetime', + spec: { + label: 'Lifetime', + icon: Trash2, + configured: (m) => m.delete_after_secs != null, + summarize: (m) => { + const s = m.delete_after_secs + if (s == null) return def('Off') + return s === 0 ? cfg('Delete now') : cfg(`Delete after ${formatDur(s)}`) + } + } + } +] + +/** The settings that apply to this step, in canonical order. + * `referenced` supplies the workspace script's own settings for `script` steps, + * whose concurrency and cache live on the script rather than on the step. */ +export function describeStepSettings( + mod: FlowModule, + referenced?: ScriptAdvancedSettingsFields +): StepSettingView[] { + const ctx: Ctx = { referenced } + return SPECS.filter(({ spec }) => spec.applies?.(mod) ?? true).map(({ key, spec }) => ({ + key, + label: spec.label, + tooltip: spec.tooltip?.(mod) ?? spec.label, + icon: spec.icon, + configured: spec.configured(mod, ctx), + summary: spec.summarize(mod, ctx) + })) +} + +/** How a trigger step decides it has nothing to process. Stored on the step at + * creation, so changing it only affects newly created steps. */ +// Falsy-or-empty, not `result == undefined`: a trigger returning nothing new may say so +// with any empty value, and stopping is always the right response. +const TRIGGER_STOP_EXPR = '!result || (Array.isArray(result) && result.length == 0)' + +/** The config a setting is seeded with when it is switched on. Read by the setting + * editors and by every path that creates a step, so both agree. Settings absent from + * this map have no seeded config (the editor writes the value directly). */ +const DEFAULTS = { + skip: () => ({ expr: 'false' }), + 'early-stop': (kind?: 'trigger' | 'end') => + kind === 'trigger' + ? { expr: TRIGGER_STOP_EXPR, skip_if_stopped: true } + : kind === 'end' + ? { expr: 'true', skip_if_stopped: false } + : { + expr: 'result == undefined', + skip_if_stopped: false, + error_message: undefined, + error_include_result: false + }, + suspend: () => ({ required_events: 1, timeout: 1800 }), + sleep: () => ({ type: 'static' as const, value: 0 }), + cache: () => 600, + lifetime: () => 0, + priority: () => 100 +} satisfies Partial unknown>> + +export type SeededSettingKey = keyof typeof DEFAULTS + +/** Seeded config for a setting. Typed per key, so an unhandled key is a compile + * error rather than a silent `undefined`. */ +export function stepSettingDefaults( + key: K, + kind?: 'trigger' | 'end' +): ReturnType<(typeof DEFAULTS)[K]> { + return DEFAULTS[key](kind) as ReturnType<(typeof DEFAULTS)[K]> +} diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index dbebc82f70..0e92ee8a5e 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -169,7 +169,12 @@ {#if !loading} - + @@ -103,10 +110,7 @@ title="Delete failure script" type="button" class="ml-1" - onclick={() => { - flowStore.val.value.failure_module = undefined - selectionManager.selectId('settings-metadata') - }} + onclick={deleteFailureModule} > @@ -117,10 +121,7 @@ title="Delete failure script" type="button" class="absolute -top-1.5 -right-1.5 rounded-full bg-surface border border-border p-0.5 hover:bg-surface-hover" - onclick={() => { - flowStore.val.value.failure_module = undefined - selectionManager.selectId('settings-metadata') - }} + onclick={deleteFailureModule} > diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index de949a6dd9..6aeaa14f35 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -4,23 +4,8 @@ import Popover from '$lib/components/Popover.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { classNames, type Item, type StateStore } from '$lib/utils' - import { - Bed, - Database, - Gauge, - EllipsisVertical, - PhoneIncoming, - Repeat, - Square, - SkipForward, - Pin, - X, - Play, - Loader2, - TriangleAlert, - Timer, - Maximize2 - } from 'lucide-svelte' + import { EllipsisVertical, Pin, X, Play, Loader2, TriangleAlert, Maximize2 } from 'lucide-svelte' + import type { StepSettingView } from '../flowStepSettings' import { createEventDispatcher, getContext, untrack } from 'svelte' import { fade } from 'svelte/transition' import type { FlowEditorContext } from '../types' @@ -55,12 +40,8 @@ selected?: boolean deletable?: boolean moduleAction: ModuleActionInfo | undefined - retry?: boolean - cache?: boolean - earlyStop?: boolean - skip?: boolean - suspend?: boolean - sleep?: boolean + /** Configured settings to badge, from flowStepSettings. */ + settings?: StepSettingView[] mock?: | { enabled?: boolean @@ -72,12 +53,8 @@ label: string path?: string nodeState?: FlowNodeState - concurrency?: boolean - // TODO: Implement for this one. See how concurrency is implemented. - debouncing?: boolean retries?: number | undefined warningMessage?: string | undefined - isTrigger?: boolean editMode?: boolean alwaysShowOutputPicker?: boolean loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined @@ -97,23 +74,15 @@ selected = false, deletable = false, moduleAction = undefined, - retry = false, - cache = false, - earlyStop = false, - skip = false, - suspend = false, - sleep = false, + settings = [], mock = { enabled: false }, bold = false, id = undefined, label, path = '', nodeState, - concurrency = false, - debouncing = false, retries = undefined, warningMessage = undefined, - isTrigger = false, editMode = false, alwaysShowOutputPicker = false, loopStatus = undefined, @@ -304,112 +273,23 @@ class="absolute text-sm right-2 flex flex-row gap-1 z-10 transition-all duration-100" style={`bottom: ${outputPickerBarOpen ? '-38px' : '-12px'}`} > - {#if retry} + {#each settings as s (s.key)} + {@const Icon = s.icon}
- {#if retries}{retries}{/if} - + {#if s.key === 'retries' && retries}{retries}{/if} +
{#snippet text()} - Retries + {s.tooltip} + · {s.summary.text} {/snippet}
- {/if} - - {#if concurrency} - -
- -
- {#snippet text()} - Concurrency Limits - {/snippet} -
- {/if} - {#if debouncing} - -
- -
- {#snippet text()} - Debouncing - {/snippet} -
- {/if} - {#if cache} - -
- -
- {#snippet text()} - Cached - {/snippet} -
- {/if} - {#if earlyStop} - -
- -
- {#snippet text()} - {isTrigger ? 'Stop early if there are no new events' : 'Early stop/break'} - {/snippet} -
- {/if} - {#if skip} - -
- -
- {#snippet text()} - Skip - {/snippet} -
- {/if} - {#if suspend} - -
- -
- {#snippet text()} - Suspend - {/snippet} -
- {/if} - {#if sleep} - -
- -
- {#snippet text()} - Sleep - {/snippet} -
- {/if} + {/each} {#if mock?.enabled} - {/snippet} - {#snippet content({ close })} - { - close() - }} - on:new={(e) => { - data.eventHandlers.insert({ - index: -1, // ignored when agentId is set - agentId: data.agentModuleId, - ...e.detail - }) - close() - }} - on:insert={(e) => { - data.eventHandlers.insert({ - index: -1, // ignored when agentId is set - agentId: data.agentModuleId, - ...e.detail - }) - close() - }} - on:pickScript={(e) => { - data.eventHandlers.insert({ - index: -1, // ignored when agentId is set - agentId: data.agentModuleId, - kind: e.detail.kind, - script: { - ...e.detail, - summary: e.detail.summary - ? e.detail.summary.replace(/\s/, '_').replace(/[^a-zA-Z0-9_]/g, '') - : e.detail.path.split('/').pop() - } - }) - close() - }} - on:pickMcpTool={(e) => { - data.eventHandlers.insert({ - index: -1, - agentId: data.agentModuleId, - kind: 'mcpTool' - }) - close() - }} - on:pickWebsearchTool={(e) => { - data.eventHandlers.insert({ - index: -1, - agentId: data.agentModuleId, - kind: 'websearchTool' - }) - close() - }} - on:pickAiAgentTool={(e) => { - data.eventHandlers.insert({ - index: -1, - agentId: data.agentModuleId, - kind: 'aiAgentTool' - }) - close() - }} - /> - {/snippet} - + + + {#snippet trigger()} + + {/snippet} + {#snippet content({ close })} + { + close() + }} + on:new={(e) => { + data.eventHandlers.insert({ + index: -1, // ignored when agentId is set + agentId: data.agentModuleId, + ...e.detail + }) + close() + }} + on:insert={(e) => { + data.eventHandlers.insert({ + index: -1, // ignored when agentId is set + agentId: data.agentModuleId, + ...e.detail + }) + close() + }} + on:pickScript={(e) => { + data.eventHandlers.insert({ + index: -1, // ignored when agentId is set + agentId: data.agentModuleId, + kind: e.detail.kind, + script: { + ...e.detail, + summary: e.detail.summary + ? e.detail.summary.replace(/\s/, '_').replace(/[^a-zA-Z0-9_]/g, '') + : e.detail.path.split('/').pop() + } + }) + close() + }} + on:pickMcpTool={(e) => { + data.eventHandlers.insert({ + index: -1, + agentId: data.agentModuleId, + kind: 'mcpTool' + }) + close() + }} + on:pickWebsearchTool={(e) => { + data.eventHandlers.insert({ + index: -1, + agentId: data.agentModuleId, + kind: 'websearchTool' + }) + close() + }} + on:pickAiAgentTool={(e) => { + data.eventHandlers.insert({ + index: -1, + agentId: data.agentModuleId, + kind: 'aiAgentTool' + }) + close() + }} + /> + {/snippet} + {/if} diff --git a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte index 58f95d4b33..43af1fef48 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte @@ -84,13 +84,15 @@ }} on:select={() => data?.eventHandlers?.select('Trigger')} onSelect={async (triggerIndex: number) => { - data?.eventHandlers?.select('Trigger') + data?.eventHandlers?.select('Trigger', { openPanel: true }) await tick() triggersState.selectedTriggerIndex = triggerIndex }} onAddDraftTrigger={async (type: TriggerType) => { const newTrigger = triggersState.addDraftTrigger(triggersCount, type) - data?.eventHandlers?.select('Trigger') + // A scheduled poll continues in the trigger-script picker that opens + // alongside this, so revealing the panel would cover it. + data?.eventHandlers?.select('Trigger', { openPanel: type !== 'poll' }) await tick() triggersState.selectedTriggerIndex = newTrigger }} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte index 4857729539..3755b5cf77 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte @@ -1,6 +1,7 @@
@@ -101,25 +113,35 @@
-{#if showTriggerScriptPicker} - -
- { - showTriggerScriptPicker = false - dispatch('new', e.detail) - }} - on:pickScript={(e) => { - showTriggerScriptPicker = false - dispatch('pickScript', e.detail) - }} - kind="trigger" - /> -
-
-{/if} + + + {#snippet children()} + {#if showTriggerScriptPicker} + + +
(showTriggerScriptPicker = false) }} + > + { + showTriggerScriptPicker = false + dispatch('new', e.detail) + }} + on:pickScript={(e) => { + showTriggerScriptPicker = false + dispatch('pickScript', e.detail) + }} + kind="trigger" + /> +
+
+ {/if} + {/snippet} +
diff --git a/frontend/src/lib/components/graph/selectionUtils.svelte.ts b/frontend/src/lib/components/graph/selectionUtils.svelte.ts index daa9de3ecf..eddbae4fca 100644 --- a/frontend/src/lib/components/graph/selectionUtils.svelte.ts +++ b/frontend/src/lib/components/graph/selectionUtils.svelte.ts @@ -1,9 +1,34 @@ import type { Node } from '@xyflow/svelte' +/** Intent attached to a `selectId` call. `true` opens the panel even for ids that + * would not normally trigger it; `false` marks an incidental selection (what remains + * after a delete) and keeps it shut; omitted uses the default rules. */ +export type SelectIntentOptions = { + openPanel?: boolean +} + +/** Panels reached from toolbar buttons or dedicated graph nodes rather than step + * modules. They open on a single selection; step modules deliberately do not. */ +const FLOW_LEVEL_PANEL_IDS = new Set([ + 'constants', + 'failure', + 'preprocessor', + 'Input', + 'Result', + 'Trigger' +]) + +export function isFlowLevelPanelTarget(id: string): boolean { + // 'settings-' prefixed, not 'settings' prefixed: step ids are user-editable, so a + // step renamed settings_v2 must not be mistaken for the flow's settings panel. + return id === 'settings' || id.startsWith('settings-') || FLOW_LEVEL_PANEL_IDS.has(id) +} + export class SelectionManager { #selectedNodes = $state([]) #selectionMode = $state<'normal' | 'rect-select'>('normal') #clearGraphSelection: () => void = () => {} + #onSelectIntent: ((id: string, opts?: SelectIntentOptions) => void) | undefined = undefined constructor() {} @@ -11,7 +36,15 @@ export class SelectionManager { this.#clearGraphSelection = clearGraphSelection } - selectId(id: string) { + /** Fires on every `selectId` call, BEFORE the same-id dedup early-return — so a + * consumer can react even when the id is re-selected (e.g. clicking the already + * selected "Settings" toolbar button to re-open a modal panel). */ + setOnSelectIntent(cb: ((id: string, opts?: SelectIntentOptions) => void) | undefined) { + this.#onSelectIntent = cb + } + + selectId(id: string, opts?: SelectIntentOptions) { + this.#onSelectIntent?.(id, opts) if (this.#selectedNodes.length === 1 && this.#selectedNodes[0].id === id) { return } @@ -77,6 +110,12 @@ export class SelectionManager { return } + // Before the same-id early return, like `selectId`: re-selecting an already + // selected node must still be able to reopen its panel. + if (nodes.length === 1) { + this.#onSelectIntent?.(nodes[0].id) + } + // If the new selection is the same as the current selection, do nothing const newIds = nodes.map((n) => n.id).join(',') const currentIds = this.#selectedNodes.map((n) => n.id).join(',') diff --git a/frontend/src/lib/components/prop_picker.ts b/frontend/src/lib/components/prop_picker.ts index ced6a5e890..729d60409c 100644 --- a/frontend/src/lib/components/prop_picker.ts +++ b/frontend/src/lib/components/prop_picker.ts @@ -9,4 +9,7 @@ export type FlowPropPickerConfig = { export type PropPickerContext = { flowPropPickerConfig: Writable pickablePropertiesFiltered: Writable + /** True when the panel is a modal, which covers the graph. Connecting there could never + * be completed by clicking a step node, so the graph stays out of it. */ + inModalPanel?: () => boolean } diff --git a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte index 3d677d8f54..26b48788a5 100644 --- a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte +++ b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte @@ -255,7 +255,6 @@ {#if job} > } - let { job, workspaceId, isOwner, suspendStatus }: Props = $props() + let { job, isOwner, suspendStatus }: Props = $props() const isWaitingForEvents = $derived( job?.flow_status?.modules?.[job?.flow_status?.step]?.type === 'WaitingForEvents' @@ -28,7 +27,7 @@ transition:slide={{ duration: 150 }} > {#if isWaitingForEvents} - + {:else if isSuspended}
{#each Object.values(suspendStatus.val) as suspendCount (suspendCount.job.id)} @@ -36,7 +35,11 @@
Flow suspended, waiting for {suspendCount.nb} events
- +
{/each}
diff --git a/frontend/src/lib/components/runs/JobRunsPreview.svelte b/frontend/src/lib/components/runs/JobRunsPreview.svelte index 4e45b18f06..ae61ca2d8e 100644 --- a/frontend/src/lib/components/runs/JobRunsPreview.svelte +++ b/frontend/src/lib/components/runs/JobRunsPreview.svelte @@ -173,7 +173,6 @@ {#if isFlow} void + /** Render only the editing drawer: the caller shows its own view of the schema and + * opens the drawer through `openDrawer()`. */ + drawerOnly?: boolean } let { schema = $bindable(), jsonView = $bindable(false), - hiddenArgs = undefined + hiddenArgs = undefined, + drawerOnly = false }: Props = $props() + export function openDrawer() { + schemaFormDrawer?.openDrawer() + } + // let schema = $state(structuredClone($state.snapshot(schema))) let schemaString: string = $state(JSON.stringify(schema, null, '\t')) @@ -82,108 +91,7 @@ const rnd = generateRandomString() -
- { - if (jsonView) { - schemaString = JSON.stringify(schema, null, '\t') - editor?.setCode(schemaString) - } - }} - bind:schema - bind:this={addPropertyComponent} - /> - - { - schemaString = JSON.stringify(schema, null, '\t') - editor?.setCode(schemaString) - }} - /> -
- -{#if !jsonView} - {#key rnd} -
- {#if items?.length > 0} - {#each items as item (item.id)} - -
- {#if schema.properties?.[item.value]} -
- {`${item.value}${ - schema.properties?.[item.value]?.title - ? ` (title: ${schema.properties?.[item.value]?.title})` - : '' - } `} - - -
-
-
- - {#if schema.properties[item.value]?.type === 'object' && !(schema.properties[item.value].oneOf && schema.properties[item.value].oneOf.length >= 2)} -
- -
- {/if} - {:else} -
Value is undefined
- {/if} -
- {/each} - {/if} -
- {/key} +{#snippet editorDrawer()} {#snippet children()} schemaFormDrawer?.closeDrawer()}> @@ -192,11 +100,8 @@ bind:this={editableSchemaForm} bind:schema isAppInput - on:edit={(e) => { - addPropertyComponent?.openDrawer(e.detail) - }} on:delete={(e) => { - addPropertyComponent?.handleDeleteArgument([e.detail]) + ;(addPropertyComponent ?? drawerAddProperty)?.handleDeleteArgument([e.detail]) }} {hiddenArgs} editTab="inputEditor" @@ -204,6 +109,7 @@ {#snippet addProperty()} { editableSchemaForm?.openField(argName) }} @@ -221,29 +127,147 @@ {/snippet} +{/snippet} + +{#if drawerOnly} + {@render editorDrawer()} {:else} -
- + { - try { - schema = JSON.parse(schemaString) - error = '' - } catch (err) { - error = err.message - } + schemaString = JSON.stringify(schema, null, '\t') + editor?.setCode(schemaString) }} - bind:code={schemaString} - lang="json" - autoHeight - automaticLayout />
- {#if !emptyString(error)} -
{error}
+ + {#if !jsonView} + {#key rnd} +
+ {#if items?.length > 0} + {#each items as item (item.id)} + +
+ {#if schema.properties?.[item.value]} +
+ {`${item.value}${ + schema.properties?.[item.value]?.title + ? ` (title: ${schema.properties?.[item.value]?.title})` + : '' + } `} + + +
+
+
+ + {#if schema.properties[item.value]?.type === 'object' && !(schema.properties[item.value].oneOf && schema.properties[item.value].oneOf.length >= 2)} +
+ +
+ {/if} + {:else} +
Value is undefined
+ {/if} +
+ {/each} + {/if} +
+ {/key} + + { + if (jsonView) { + schemaString = JSON.stringify(schema, null, '\t') + editor?.setCode(schemaString) + } + }} + > + {#snippet trigger()} +
+ +
+ {/snippet} +
+ + {@render editorDrawer()} {:else} -

+
+ { + try { + schema = JSON.parse(schemaString) + error = '' + } catch (err) { + error = err.message + } + }} + bind:code={schemaString} + lang="json" + autoHeight + automaticLayout + /> +
+ {#if !emptyString(error)} +
{error}
+ {:else} +

+ {/if} {/if} {/if} diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index 44f565d086..4b7733ac33 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -364,7 +364,7 @@
{/if} - +
diff --git a/frontend/src/lib/transitions.ts b/frontend/src/lib/transitions.ts new file mode 100644 index 0000000000..d1d5812951 --- /dev/null +++ b/frontend/src/lib/transitions.ts @@ -0,0 +1,44 @@ +import { cubicOut } from 'svelte/easing' +import type { EasingFunction, TransitionConfig } from 'svelte/transition' + +/** `slide`, but re-measuring the content on every frame. + * + * `slide` snapshots the height once when the transition starts, so content that settles + * after mount (a Monaco editor sizing itself to its lines) animates towards a stale + * target and snaps to its real height at the end. */ +export function slideDynamic( + node: HTMLElement, + { + delay = 0, + duration = 150, + easing = cubicOut + }: { delay?: number; duration?: number; easing?: EasingFunction } = {} +): TransitionConfig { + const style = getComputedStyle(node) + const paddingTop = parseFloat(style.paddingTop) + const paddingBottom = parseFloat(style.paddingBottom) + const initial = { + overflow: node.style.overflow, + height: node.style.height, + paddingTop: node.style.paddingTop, + paddingBottom: node.style.paddingBottom + } + return { + delay, + duration, + easing, + tick: (t: number) => { + if (t === 1) { + Object.assign(node.style, initial) + return + } + node.style.overflow = 'hidden' + // Padding shrinks with the box, or `border-box` would floor the height at it. + node.style.paddingTop = `${t * paddingTop}px` + node.style.paddingBottom = `${t * paddingBottom}px` + // scrollHeight ignores the height clamp but does count the padding just written. + const content = node.scrollHeight - t * (paddingTop + paddingBottom) + node.style.height = `${t * (content + paddingTop + paddingBottom)}px` + } + } +} diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 397394a14e..740371d021 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -961,7 +961,6 @@ {job} {isOwner} {suspendStatus} - workspaceId={job?.workspace_id} innerModules={job?.flow_status?.modules} /> {/if} From 7d153d5750db0ea17812d6b1f9a63ceaefddc2b9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 11:43:12 +0000 Subject: [PATCH 172/400] fix(debugger): pass python index settings to prepare-deps and report failures (#10533) * fix: honor python index settings in prepare-deps and report install failures * fix: forward python registry env to the debugger's prepare-deps * fix: scope registry credentials to the prepare-deps subprocess * fix: install python debug dependencies from the service, not the session * fix: bound the debugger dependency install and keep the proxy bypass default * docs: name the nsjail config that isolates debug sessions --- backend/windmill-worker/src/prepare_deps.rs | 159 ++++++++++++++++-- .../windmill-worker/src/python_executor.rs | 19 +-- .../windmill-worker/src/python_versions.rs | 6 +- backend/windmill-worker/src/worker.rs | 20 +++ debugger/README.md | 42 +++++ debugger/dap_debug_service.ts | 138 ++++++++++++++- debugger/dap_websocket_server.py | 32 +++- 7 files changed, 374 insertions(+), 42 deletions(-) diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 70b5718cf7..2e16e565c2 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -12,7 +12,11 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::process::Command; -use crate::{BUN_CACHE_DIR, BUN_PATH, HOME_ENV, PATH_ENV, PROXY_ENVS, UV_CACHE_DIR}; +use crate::worker::non_empty_env; +use crate::{ + BUN_CACHE_DIR, BUN_PATH, HOME_ENV, INDEX_CERT, NATIVE_CERT, PATH_ENV, PROXY_ENVS, TRUSTED_HOST, + UV_CACHE_DIR, UV_HTTP_TIMEOUT, +}; use windmill_common::worker::write_file; const LOADER_BUILDER_CONTENT: &str = include_str!("../loader_builder.bun.js"); @@ -87,6 +91,15 @@ lazy_static::lazy_static! { /// UV binary path static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string()); + + /// This process has no database, so the `pip_index_url` / `pip_extra_index_url` instance + /// settings the job path resolves are unreachable here: their env-var equivalents are the + /// only registry configuration the debugger can see. + static ref PY_INDEX_URL: Option = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL")); + static ref PY_EXTRA_INDEX_URL: Option = non_empty_env("PY_EXTRA_INDEX_URL").or_else(|| non_empty_env("PIP_EXTRA_INDEX_URL")); + /// uv defaults to `first-index`; the job path overrides it so a package missing from the + /// first index is still resolved from the others. Same default here. + static ref PY_INDEX_STRATEGY: String = non_empty_env("UV_INDEX_STRATEGY").unwrap_or_else(|| "unsafe-best-match".to_string()); } /// Simple loader that doesn't require Windmill API for relative imports @@ -114,6 +127,11 @@ pub struct PrepareResponse { pub job_dir: String, pub success: bool, pub error: Option, + /// Raw stderr of the dependency installer when it exited non-zero, so a caller can show the + /// registry/TLS failure verbatim instead of the bare ModuleNotFoundError that follows. + /// Omitted from the JSON when absent, so callers that only know `success`/`error` are unaffected. + #[serde(skip_serializing_if = "Option::is_none")] + pub install_stderr: Option, } /// Parse Python imports and return a list of package names that need to be installed. @@ -160,6 +178,28 @@ fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap { envs } +/// uv registry arguments, mirroring what the job path passes in `python_executor`. +fn uv_registry_args() -> Vec { + let mut args: Vec = vec![]; + if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() { + for url in urls.split(',') { + args.extend(["--extra-index-url".to_string(), url.to_string()]); + } + } + if let Some(url) = PY_INDEX_URL.as_ref() { + args.extend(["--index-url".to_string(), url.to_string()]); + } + if let Some(hosts) = TRUSTED_HOST.as_ref() { + for host in hosts.split_whitespace() { + args.extend(["--trusted-host".to_string(), host.to_string()]); + } + } + if *NATIVE_CERT { + args.push("--native-tls".to_string()); + } + args +} + /// Prepare Python dependencies using uv async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { // Parse imports from the code @@ -172,6 +212,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: String::new(), success: true, error: None, + install_stderr: None, }; } @@ -189,17 +230,37 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to create job directory: {}", e)), + install_stderr: None, }; } - let common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR))); + let mut common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR))); + common_uv_envs.insert( + "UV_INDEX_STRATEGY".to_string(), + PY_INDEX_STRATEGY.to_string(), + ); + if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() { + common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string()); + } + if let Some(cert_path) = INDEX_CERT.as_ref() { + // uv has no `--cert` on `venv`/`pip install` (astral-sh/uv#6715), so a custom CA bundle + // reaches it through SSL_CERT_FILE, as in the job path. + common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path.to_string()); + } + + let registry_args = uv_registry_args(); // Step 1: Create virtual environment using uv + // `--seed` resolves pip/setuptools from the index, so the venv also needs the registry + // arguments: on a network that only reaches a private mirror it fails without them. + let mut venv_args = vec!["venv".to_string(), venv_dir.clone(), "--seed".to_string()]; + venv_args.extend(registry_args.iter().cloned()); + let output = Command::new(UV_PATH.as_str()) .current_dir(&job_dir) .env_clear() .envs(common_uv_envs.clone()) - .args(["venv", &venv_dir, "--seed"]) + .args(&venv_args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() @@ -212,26 +273,33 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to create venv: {}", e)), + install_stderr: None, }; } let out = output.unwrap(); if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); return PrepareResponse { node_modules_path: None, venv_path: None, job_dir: job_dir.clone(), success: false, error: Some(format!("uv venv failed: {}", stderr)), + install_stderr: Some(stderr), }; } // Step 2: Install packages using uv pip install let python_path = format!("{}/bin/python", venv_dir); - let mut args = vec!["pip", "install", "--python", &python_path]; - let package_refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect(); - args.extend(package_refs.iter()); + let mut args = vec![ + "pip".to_string(), + "install".to_string(), + "--python".to_string(), + python_path, + ]; + args.extend(packages.iter().cloned()); + args.extend(registry_args); let output = Command::new(UV_PATH.as_str()) .current_dir(&job_dir) @@ -246,11 +314,19 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { match output { Ok(out) => { if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - // Installation might fail for some packages (e.g., wrong package name) - // Log the error but continue - the script might still work if the - // package is actually installed elsewhere or the import is optional - tracing::warn!("uv pip install warning: {}", stderr); + // uv installs the whole set atomically, so a failure here means an empty venv: + // returning success would leave the caller with a bare ModuleNotFoundError and + // no way to see the registry/TLS/package-name error that caused it. + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + tracing::warn!("uv pip install failed: {}", stderr); + return PrepareResponse { + node_modules_path: None, + venv_path: None, + job_dir: job_dir.clone(), + success: false, + error: Some(format!("uv pip install failed: {}", stderr)), + install_stderr: Some(stderr), + }; } } Err(e) => { @@ -260,6 +336,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to run uv pip install: {}", e)), + install_stderr: None, }; } } @@ -292,6 +369,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir, success: true, error: None, + install_stderr: None, } } @@ -321,6 +399,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo "Unsupported language for dependency preparation: {}", language )), + install_stderr: None, }; } } @@ -336,6 +415,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to create job directory: {}", e)), + install_stderr: None, }; } @@ -347,6 +427,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to write main.ts: {}", e)), + install_stderr: None, }; } @@ -366,6 +447,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to write build.js: {}", e)), + install_stderr: None, }; } @@ -398,6 +480,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to write empty package.json: {}", e)), + install_stderr: None, }; } } @@ -411,6 +494,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to run build.js: {}", e)), + install_stderr: None, }; } } @@ -427,6 +511,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: true, error: None, + install_stderr: None, }; } }; @@ -441,6 +526,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to parse package.json: {}", e)), + install_stderr: None, }; } }; @@ -454,6 +540,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: true, error: None, + install_stderr: None, }; } @@ -471,13 +558,14 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo match output { Ok(out) => { if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); return PrepareResponse { node_modules_path: None, venv_path: None, job_dir: job_dir.clone(), success: false, error: Some(format!("bun install failed: {}", stderr)), + install_stderr: Some(stderr), }; } } @@ -488,6 +576,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to run bun install: {}", e)), + install_stderr: None, }; } } @@ -500,6 +589,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir, success: true, error: None, + install_stderr: None, } } else { PrepareResponse { @@ -508,6 +598,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir, success: true, error: None, + install_stderr: None, } } } @@ -531,6 +622,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { job_dir: String::new(), success: false, error: Some(format!("Failed to read stdin: {}", e)), + install_stderr: None, }; println!("{}", serde_json::to_string(&response)?); return Ok(()); @@ -550,6 +642,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { "Failed to parse JSON input: {}. Expected {{\"code\": \"...\", \"language\": \"bun\" or \"python3\"}}", e )), + install_stderr: None, }; println!("{}", serde_json::to_string(&response)?); return Ok(()); @@ -561,3 +654,43 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::PrepareResponse; + + /// The debugger (`debugger/dap_websocket_server.py`) parses this JSON out of the CLI's + /// stdout, so `install_stderr` has to stay additive: a response without an install failure + /// must serialize to the shape callers already know. + #[test] + fn test_install_stderr_is_additive() { + let ok = PrepareResponse { + node_modules_path: None, + venv_path: Some("/tmp/windmill-deps/x/venv".to_string()), + job_dir: "/tmp/windmill-deps/x".to_string(), + success: true, + error: None, + install_stderr: None, + }; + assert_eq!( + serde_json::to_value(&ok).unwrap(), + serde_json::json!({ + "node_modules_path": null, + "venv_path": "/tmp/windmill-deps/x/venv", + "job_dir": "/tmp/windmill-deps/x", + "success": true, + "error": null, + }) + ); + + let failed = PrepareResponse { + install_stderr: Some("error: no such package".to_string()), + success: false, + error: Some("uv pip install failed: error: no such package".to_string()), + ..ok + }; + let failed = serde_json::to_value(&failed).unwrap(); + assert_eq!(failed["install_stderr"], "error: no such package"); + assert_eq!(failed["success"], false); + } +} diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 397367742a..883b035a32 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -62,20 +62,8 @@ lazy_static::lazy_static! { static ref PY_CONCURRENT_DOWNLOADS: usize = var("PY_CONCURRENT_DOWNLOADS").ok().map(|flag| flag.parse().unwrap_or(20)).unwrap_or(20); - // uv's HTTP request timeout (seconds). spawn_uv_install uses env_clear(), so a - // UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly. - // Only forwarded when set; otherwise uv keeps its own default. Lets operators - // raise it for slow/contended private registries ("operation timed out"). - static ref UV_HTTP_TIMEOUT: Option = - var("UV_HTTP_TIMEOUT").ok().filter(|v| !v.is_empty()); - - static ref NON_ALPHANUM_CHAR: Regex = regex::Regex::new(r"[^0-9A-Za-z=.-]").unwrap(); - static ref TRUSTED_HOST: Option = var("PY_TRUSTED_HOST").ok().or(var("PIP_TRUSTED_HOST").ok()); - pub static ref INDEX_CERT: Option = var("PY_INDEX_CERT").ok().or(var("PIP_INDEX_CERT").ok()); - pub static ref NATIVE_CERT: bool = var("PY_NATIVE_CERT").ok().or(var("UV_NATIVE_TLS").ok()).map(|flag| flag == "true").unwrap_or(false); - static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(((u|f)\.)|\.)"#).unwrap(); static ref EPHEMERAL_TOKEN_CMD: Option = var("EPHEMERAL_TOKEN_CMD").ok(); @@ -163,9 +151,10 @@ use crate::{ handle_child::handle_child, is_sandboxing_enabled, read_ee_registry_with_workspace_override, worker_utils::ping_job_status, - PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, - PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, - TZ_ENV, UV_CACHE_DIR, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, + PyV, DISABLE_NUSER, HOME_ENV, INDEX_CERT, NATIVE_CERT, NSJAIL_AVAILABLE, NSJAIL_PATH, + NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, + PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TRUSTED_HOST, TZ_ENV, UV_CACHE_DIR, + UV_EXCLUDE_NEWER, UV_HTTP_TIMEOUT, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, }; use windmill_common::client::AuthedClient; diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs index 3a1f35a302..11f673e8f4 100644 --- a/backend/windmill-worker/src/python_versions.rs +++ b/backend/windmill-worker/src/python_versions.rs @@ -23,9 +23,9 @@ use crate::python_executor::UV_PATH; use crate::{ common::{start_child_process, OccupancyMetrics}, handle_child::handle_child, - python_executor::{INDEX_CERT, NATIVE_CERT, PYTHON_PATH}, - HOME_ENV, INSTANCE_PYTHON_VERSION, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, UV_CACHE_DIR, - UV_PYTHON_INSTALL_MIRROR, WIN_ENVS, + python_executor::PYTHON_PATH, + HOME_ENV, INDEX_CERT, INSTANCE_PYTHON_VERSION, NATIVE_CERT, PATH_ENV, PROXY_ENVS, + PY_INSTALL_DIR, UV_CACHE_DIR, UV_PYTHON_INSTALL_MIRROR, WIN_ENVS, }; impl From for PyVAlias { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 08bb085059..10e436d326 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -704,6 +704,26 @@ lazy_static::lazy_static! { pub static ref FLOW_RUNNER_RUNNING: Mutex = Mutex::new(false); } +lazy_static::lazy_static! { + /// Registry TLS/timeout settings for uv. Env-only (they have no instance setting), and read + /// both by the job path and by the DB-less `prepare-deps` CLI, which has no other source of + /// registry configuration. + pub static ref TRUSTED_HOST: Option = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST")); + pub static ref INDEX_CERT: Option = non_empty_env("PY_INDEX_CERT").or_else(|| non_empty_env("PIP_INDEX_CERT")); + pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false); + /// uv's HTTP request timeout (seconds). The uv invocations use env_clear(), so a + /// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly. + /// Only forwarded when set; otherwise uv keeps its own default. Lets operators + /// raise it for slow/contended private registries ("operation timed out"). + pub static ref UV_HTTP_TIMEOUT: Option = non_empty_env("UV_HTTP_TIMEOUT"); +} + +/// A variable declared but left empty (a common shape in compose/k8s manifests) must not +/// shadow the fallback name it is checked against. +pub(crate) fn non_empty_env(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + lazy_static::lazy_static! { /// Optional override for the size of the `/tmp` tmpfs mount in nsjail sandboxes (in megabytes). /// When `None` (or non-positive), executors fall back to the unified diff --git a/debugger/README.md b/debugger/README.md index dbeb213351..51c2838f92 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -75,6 +75,48 @@ Options: | `DAP_NSJAIL_PATH` | nsjail binary path | nsjail | | `DAP_NSJAIL_CONFIG` | nsjail config file path | - | +### Python dependency preparation + +Before debugging a Python script, its imports are installed through `windmill prepare-deps`, which +runs `uv` without a database connection. It cannot read the instance settings, so it takes its +registry configuration from the environment of the debug service instead, and the Python server is +handed the resulting venv with `--venv-path`. The install runs in the service rather than in the +session because a private index URL usually embeds credentials and the Python server executes the +debugged script inside its own interpreter, where anything it holds is readable by that script. + +Set these on the debug service. Where two names are listed the first wins; a worker reads the +`PIP_*` / `PY_*` names in the same way, except for the index URLs, whose worker env fallbacks are +only `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` (the `PY_*` spellings are accepted here for symmetry +with the other settings): + +| Variable | Description | Default | +|----------|-------------|---------| +| `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI | +| `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - | +| `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | +| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE` | - | +| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | +| `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match | +| `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | +| `DAP_PREPARE_DEPS_TIMEOUT_MS` | How long to wait for the install before starting the session without it | 120000 | + +When the install fails, the CLI answers `success: false` and carries the installer's stderr in both +`error` and `install_stderr`; the service reports it to the client as an `output` event, so the +reason (unreachable mirror, untrusted certificate, unknown package) reaches the user instead of a +bare `ModuleNotFoundError` at the first import. + +Proxy variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, in either case) are forwarded from the +service into each session, since the debugged script needs them for its own outbound calls, exactly +as a job's script does on a worker. When a proxy is set without a bypass list, `NO_PROXY` defaults +to `localhost,127.0.0.1` so calls to `BASE_INTERNAL_URL` are not proxied. + +Keeping the settings out of the session's environment only bounds what the debugged script can read +from itself. An unsandboxed session runs under the same user as the service and can still read the +service's environment through `/proc`, the same way a job can read a worker's when the worker runs +unsandboxed. Isolating sessions from the service takes `--nsjail --nsjail-config +nsjail.debug.config.proto`: it is that config's PID namespace and `mount_proc` that put the service +out of reach, not the flag on its own. + ### Frontend Integration ```svelte diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 2b9ce46272..52490cdc9a 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -352,6 +352,45 @@ interface SpawnOptions { stderr?: 'pipe' | 'inherit' } +/** + * Proxy settings forwarded to a debug session, matching what a worker gives a job's script. + * spawnProcess intentionally does not inherit this process's environment, so an outbound proxy + * is unreachable from a session unless these are passed explicitly. Registry settings are + * deliberately absent: they carry credentials and are consumed by the service itself (see + * PythonDebugSession.prepareDependencies). + */ +const SESSION_PROXY_ENV_VARS = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + // The lowercase spellings take precedence in the worker, so forward both. + 'http_proxy', + 'https_proxy', + 'no_proxy' +] + +/** + * How long `windmill prepare-deps` may take before the session gives up on it and starts without + * the dependencies. Raise it for slow private mirrors, where a large install can outlast the default. + */ +const PREPARE_DEPS_TIMEOUT_MS = Number(process.env.DAP_PREPARE_DEPS_TIMEOUT_MS) || 120_000 + +function sessionProxyEnv(): Record { + const env: Record = {} + for (const key of SESSION_PROXY_ENV_VARS) { + const value = process.env[key] + if (value) { + env[key] = value + } + } + // A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it; + // the worker defaults the same way (PROXY_ENVS in windmill-worker). + if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) { + env.NO_PROXY = 'localhost,127.0.0.1' + } + return env +} + /** * Spawn a process, optionally wrapped with nsjail. * This is the key function for sandboxed execution. @@ -502,6 +541,7 @@ class PythonDebugSession extends BaseDebugSession { private scriptResult: unknown = undefined private envVars: Record = {} private windmillPath?: string + private venvPath?: string private debugMode: boolean constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) { @@ -627,6 +667,89 @@ class PythonDebugSession extends BaseDebugSession { } } + /** + * Install the script's imports through `windmill prepare-deps` and return the venv to add to + * the debugged script's sys.path. + * + * This runs here rather than in the Python server because the registry settings the CLI reads + * (`PY_INDEX_URL` and friends) routinely embed private-registry credentials, and the Python + * server executes the submitted script inside its own interpreter: anything in that process is + * recoverable by the script. The service never executes user code, so the credentials stop here. + * + * The trade-off is that the install itself is not jailed, so a source distribution's build + * backend runs outside nsjail, as it already does for Bun sessions. + */ + private async prepareDependencies(code: string): Promise { + if (!this.windmillPath) { + logger.info('No windmill binary path configured, skipping dependency preparation') + return null + } + + const warn = (reason: string): null => { + logger.error(`prepare-deps failed: ${reason}`) + this.sendEvent('output', { + category: 'stderr', + output: `Failed to prepare dependencies: ${reason}\n` + }) + return null + } + + try { + const proc = spawn({ + cmd: [this.windmillPath, 'prepare-deps'], + stdin: new Blob([JSON.stringify({ code, language: 'python3' }) + '\n']), + stdout: 'pipe', + stderr: 'pipe' + }) + + // The launch response is already sent, so an install that never returns would leave the + // client waiting on a session that never starts, with nothing on screen. The deadline + // races the read rather than only killing the child: a grandchild holding the pipe open + // keeps the read pending long after the child itself is gone. + let timer: ReturnType | undefined + const read = (async () => ({ + output: await new Response(proc.stdout).text(), + stderr: await new Response(proc.stderr).text() + }))() + const result = await Promise.race([ + read, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), PREPARE_DEPS_TIMEOUT_MS) + }) + ]) + clearTimeout(timer) + + if (!result) { + proc.kill() + return warn( + `dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` + ) + } + const { output, stderr } = result + + const lastLine = output.trim().split('\n').pop() || '' + if (!lastLine.startsWith('{')) { + return warn(stderr.trim() || 'windmill binary produced no response') + } + + const response = JSON.parse(lastLine) + if (!response.success) { + // install_stderr is the installer's raw output; `error` already contains it, so + // prefer whichever the CLI version at hand provides. + return warn(response.install_stderr || response.error || 'unknown error') + } + + if (response.venv_path) { + logger.info(`Dependencies installed at: ${response.venv_path}`) + } else { + logger.info('No external dependencies to install') + } + return response.venv_path || null + } catch (error) { + return warn(String(error)) + } + } + private async startPythonProcess(cwd: string): Promise { if (!this.scriptPath) { throw new Error('No script path') @@ -648,10 +771,11 @@ class PythonDebugSession extends BaseDebugSession { '--host', '127.0.0.1' ] - // Pass windmill path for dependency auto-installation if configured - if (this.windmillPath) { - cmd.push('--windmill', this.windmillPath) - logger.info(`Python session: autoinstall enabled with windmill at ${this.windmillPath}`) + // Dependencies are installed by the service (see prepareDependencies), so the server is + // handed the resulting venv instead of the windmill binary it would install with. + if (this.venvPath) { + cmd.push('--venv-path', this.venvPath) + logger.info(`Python session: using dependencies at ${this.venvPath}`) } // Pass debug flag to Python subprocess @@ -662,7 +786,7 @@ class PythonDebugSession extends BaseDebugSession { this.process = spawnProcess({ cmd, cwd, - env: { PYTHONUNBUFFERED: '1', ...this.envVars } + env: { PYTHONUNBUFFERED: '1', ...sessionProxyEnv(), ...this.envVars } }) // Read stderr to capture startup messages @@ -987,6 +1111,10 @@ sys.stdout.flush() this.sendResponse(request) try { + if (code) { + this.venvPath = (await this.prepareDependencies(code)) ?? undefined + } + await this.startPythonProcess(cwd) // Re-apply breakpoints to the Python server using the actual script path diff --git a/debugger/dap_websocket_server.py b/debugger/dap_websocket_server.py index 624b339894..9407849695 100644 --- a/debugger/dap_websocket_server.py +++ b/debugger/dap_websocket_server.py @@ -280,9 +280,10 @@ class WindmillDebugger(bdb.Bdb): class DebugSession: """Manages a single debug session.""" - def __init__(self, websocket, windmill_path: str | None = None): + def __init__(self, websocket, windmill_path: str | None = None, prepared_venv_path: str | None = None): self.websocket = websocket self.windmill_path = windmill_path + self._prepared_venv_path = prepared_venv_path self.seq = 1 self.initialized = False self.configured = False @@ -309,6 +310,12 @@ class DebugSession: Prepare Python dependencies by calling the windmill CLI. Returns the path to the venv's site-packages directory, or None if no dependencies needed. """ + if self._prepared_venv_path: + # The debug service installs dependencies itself so that the registry credentials + # the CLI needs never enter this interpreter, which executes the debugged script. + logger.info(f"Using dependencies prepared by the debug service: {self._prepared_venv_path}") + return self._prepared_venv_path + if not self.windmill_path: logger.info("No windmill binary path configured, skipping dependency preparation") return None @@ -894,13 +901,17 @@ class DebugSession: ) -# Module-level variable to store windmill binary path +# Module-level variables to store the windmill binary path and, when the debug service +# already installed the script's dependencies, the venv to use instead of installing here. _windmill_path: str | None = None +_prepared_venv_path: str | None = None async def handle_connection(websocket) -> None: """Handle a WebSocket connection.""" - session = DebugSession(websocket, windmill_path=_windmill_path) + session = DebugSession( + websocket, windmill_path=_windmill_path, prepared_venv_path=_prepared_venv_path + ) logger.info(f"New connection from {websocket.remote_address}") try: @@ -924,13 +935,21 @@ async def handle_connection(websocket) -> None: session._cleanup_temp_file() -async def main(host: str = "localhost", port: int = 5679, windmill_path: str | None = None) -> None: +async def main( + host: str = "localhost", + port: int = 5679, + windmill_path: str | None = None, + prepared_venv_path: str | None = None, +) -> None: """Start the DAP WebSocket server.""" - global _windmill_path + global _windmill_path, _prepared_venv_path _windmill_path = windmill_path + _prepared_venv_path = prepared_venv_path if windmill_path: logger.info(f"Windmill binary path: {windmill_path}") + if prepared_venv_path: + logger.info(f"Dependencies prepared by the debug service: {prepared_venv_path}") logger.info(f"Starting DAP WebSocket server on ws://{host}:{port}") async with serve(handle_connection, host, port): @@ -944,6 +963,7 @@ if __name__ == "__main__": parser.add_argument("--host", default="localhost", help="Host to bind to") parser.add_argument("--port", type=int, default=5679, help="Port to listen on") parser.add_argument("--windmill", help="Path to windmill binary for dependency preparation (or set WINDMILL_PATH env var)") + parser.add_argument("--venv-path", help="Site-packages directory of a venv the caller already prepared; skips dependency installation") parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() @@ -957,6 +977,6 @@ if __name__ == "__main__": windmill_path = args.windmill or os.environ.get("WINDMILL_PATH") try: - asyncio.run(main(args.host, args.port, windmill_path)) + asyncio.run(main(args.host, args.port, windmill_path, args.venv_path)) except KeyboardInterrupt: logger.info("Server stopped") From 29e179f7879845f01c6813ad5dfbb19d08dc030e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 11:46:36 +0000 Subject: [PATCH 173/400] fix(debugger): report python debugger dependency install failures instead of timing out (#10531) * fix: report python debugger dependency install failures instead of timing out Co-Authored-By: Claude Opus 5 (1M context) * fix: surface swallowed installer errors and stream debugger prepare progress Co-Authored-By: Claude Opus 5 (1M context) * fix: reap the python debugger on a failed launch and bound prepare-deps Co-Authored-By: Claude Opus 5 (1M context) * fix: match uv failure output by stripping progress instead of matching errors Co-Authored-By: Claude Opus 5 (1M context) * fix: treat uv build, download and warning lines as install progress Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- debugger/dap_debug_service.ts | 28 ++- debugger/dap_websocket_server.py | 178 ++++++++++++++++-- debugger/dap_websocket_server_bun.ts | 37 ++++ debugger/test_dap_server.py | 6 +- .../src/lib/components/debug/dapClient.ts | 20 +- 5 files changed, 249 insertions(+), 20 deletions(-) diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 52490cdc9a..405cf15fde 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -528,6 +528,15 @@ abstract class BaseDebugSession { // Python Debug Session // ============================================================================ +const DEFAULT_DEBUGPY_TIMEOUT_MS = 10_000 + +// `launch` waits on dependency preparation in the Python server, which allows `windmill +// prepare-deps` up to 120s; anything shorter here reports a timeout while the install is +// still legitimately running. +const DEBUGPY_TIMEOUT_MS_BY_COMMAND: Record = { + launch: 180_000 +} + class PythonDebugSession extends BaseDebugSession { private debugpyWs: WebSocket | null = null private debugpySeq = 1 @@ -571,11 +580,13 @@ class PythonDebugSession extends BaseDebugSession { arguments: args } + const timeoutMs = DEBUGPY_TIMEOUT_MS_BY_COMMAND[command] ?? DEFAULT_DEBUGPY_TIMEOUT_MS + return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingDebugpyRequests.delete(seq) - reject(new Error(`Debugpy command timeout: ${command}`)) - }, 10000) + reject(new Error(`Debugpy command timeout: ${command} (after ${timeoutMs}ms)`)) + }, timeoutMs) this.pendingDebugpyRequests.set(seq, { resolve: (value) => { @@ -918,6 +929,13 @@ class PythonDebugSession extends BaseDebugSession { this.debugpyWs.onclose = () => { logger.info('Debugpy WebSocket closed') this.debugpyWs = null + // A Python server that dies mid-request must fail it now; otherwise the caller + // waits out the command timeout, which for `launch` is minutes. + const aborted = Array.from(this.pendingDebugpyRequests.values()) + this.pendingDebugpyRequests.clear() + for (const pending of aborted) { + pending.reject(new Error('Debugpy connection closed')) + } } }) } @@ -1140,7 +1158,13 @@ sys.stdout.flush() }) } catch (error) { this.sendEvent('output', { category: 'stderr', output: `Failed to start Python: ${error}\n` }) + // Claim the terminated event before cleanup kills the process, otherwise the + // `exited` handler sends a second one whose empty body erases this error. + this.terminatedSent = true this.sendEvent('terminated', { error: String(error) }) + // A Python server that refused the launch stays in its connection loop, so + // nothing else ever reaps it, its websocket or the temp dir. + await this.cleanup() } } diff --git a/debugger/dap_websocket_server.py b/debugger/dap_websocket_server.py index 9407849695..7388c8e76e 100644 --- a/debugger/dap_websocket_server.py +++ b/debugger/dap_websocket_server.py @@ -277,6 +277,88 @@ class WindmillDebugger(bdb.Bdb): return {} +PREPARE_DEPS_TIMEOUT_SECONDS = 120 +PREPARE_DEPS_PROGRESS_INTERVAL_SECONDS = 5 + + +@dataclass +class PrepareResult: + """ + Outcome of dependency preparation. + + `error` holds anything worth telling the user, including a problem reported by an + otherwise successful preparation. Only `fatal` means the packages are known to be + missing: failing to reach the CLI at all says nothing about the script's imports and + must not block a session that would otherwise run. + """ + + venv_path: str | None = None + error: str | None = None + fatal: bool = False + + +def _prepare_error_detail(response: dict) -> str: + """ + Build the failure reason from a prepare-deps response. + + `stderr` carries the installer's own output and is only present on newer workers, so + fall back to `error` alone when it is missing. + """ + parts = [ + str(response[key]).strip() + for key in ("error", "stderr") + if response.get(key) and str(response[key]).strip() + ] + return "\n".join(parts) or "unknown error" + + +# Prefixes uv uses for routine resolve/install progress, which it writes to stderr on a +# perfectly successful run. `warning:` belongs here because uv's warnings are non-fatal by +# construction (the hardlink fallback fires whenever the cache and the venv are on +# different filesystems, which is the normal layout). The `+`/`-` forms are the +# per-package change list. +_INSTALLER_PROGRESS_PREFIXES = ( + "resolved ", + "prepared ", + "installed ", + "uninstalled ", + "downloading ", + "downloaded ", + "building ", + "built ", + "updated ", + "audited ", + "using ", + "creating ", + "warning:", + "+ ", + "- ", +) + + +def _installer_diagnostics(stderr: str) -> str: + """ + Strip an installer's routine progress from its stderr, keeping anything unexplained. + + uv renders failures several ways (`error:`, `× No solution found` with tree glyphs), so + matching failure shapes misses some of them. Matching progress instead errs toward a + spurious warning rather than toward the silence this exists to prevent. All of this + goes away once the response carries an explicit failure flag to key on. + """ + kept = [ + line + for line in stderr.splitlines() + if line.strip() and not line.strip().lower().startswith(_INSTALLER_PROGRESS_PREFIXES) + ] + return "\n".join(kept).strip() + + +def _first_line(detail: str, limit: int = 300) -> str: + """Condense a multi-line failure into the single line a DAP response message allows.""" + line = next((s.strip() for s in detail.splitlines() if s.strip()), detail.strip()) + return line[:limit] + + class DebugSession: """Manages a single debug session.""" @@ -305,10 +387,12 @@ class DebugSession: self.seq += 1 return seq - def prepare_dependencies(self, code: str) -> str | None: + def prepare_dependencies(self, code: str) -> PrepareResult: """ Prepare Python dependencies by calling the windmill CLI. - Returns the path to the venv's site-packages directory, or None if no dependencies needed. + + Blocks for as long as the install takes, so it must run off the event loop; use + `_prepare_dependencies_with_progress` instead of calling this directly. """ if self._prepared_venv_path: # The debug service installs dependencies itself so that the registry credentials @@ -318,7 +402,7 @@ class DebugSession: if not self.windmill_path: logger.info("No windmill binary path configured, skipping dependency preparation") - return None + return PrepareResult() logger.info(f"Preparing dependencies using {self.windmill_path}") @@ -335,7 +419,7 @@ class DebugSession: input=input_data, capture_output=True, text=True, - timeout=120, # 2 minute timeout for dependency installation + timeout=PREPARE_DEPS_TIMEOUT_SECONDS, ) elapsed = time.time() - start_time @@ -344,7 +428,11 @@ class DebugSession: if result.returncode != 0: logger.error(f"prepare-deps failed (stderr): {result.stderr}") logger.error(f"prepare-deps failed (stdout): {result.stdout}") - return None + detail = (result.stderr or "").strip() or (result.stdout or "").strip() + return PrepareResult( + error=detail or f"windmill prepare-deps exited with code {result.returncode}", + fatal=True, + ) # Log raw output for debugging logger.debug(f"prepare-deps stdout: {result.stdout[:500] if result.stdout else '(empty)'}") @@ -357,15 +445,18 @@ class DebugSession: json_start = output.find('{') if json_start == -1: logger.error(f"No JSON in prepare-deps output: {output}") - return None + return PrepareResult( + error=f"No JSON in prepare-deps output: {output[:500] or '(empty)'}" + ) json_str = output[json_start:] response = json.loads(json_str) logger.debug(f"prepare-deps response: {response}") if not response.get("success"): - logger.error(f"prepare-deps error: {response.get('error')}") - return None + detail = _prepare_error_detail(response) + logger.error(f"prepare-deps error: {detail}") + return PrepareResult(error=detail, fatal=True) venv_path = response.get("venv_path") cached = response.get("cached", False) @@ -378,18 +469,57 @@ class DebugSession: else: logger.info("No external dependencies detected in code") - return venv_path + # `uv pip install` failing for individual packages does not fail the whole + # response, so a "successful" preparation can still carry the reason an import + # is about to fail. + installer_error = _installer_diagnostics(str(response.get("stderr") or "")) + if installer_error: + logger.warning(f"prepare-deps reported an installer error: {installer_error}") + + return PrepareResult(venv_path=venv_path, error=installer_error or None) except subprocess.TimeoutExpired: - logger.error("prepare-deps timed out after 120s") - return None + message = f"prepare-deps timed out after {PREPARE_DEPS_TIMEOUT_SECONDS}s" + logger.error(message) + return PrepareResult(error=message, fatal=True) except json.JSONDecodeError as e: + raw = output[:500] if 'output' in dir() else '(not available)' logger.error(f"Failed to parse prepare-deps JSON output: {e}") - logger.error(f"Raw output was: {output[:500] if 'output' in dir() else '(not available)'}") - return None + logger.error(f"Raw output was: {raw}") + return PrepareResult(error=f"Failed to parse prepare-deps output: {e}\n{raw}") except Exception as e: logger.exception(f"Error preparing dependencies: {e}") - return None + return PrepareResult(error=f"Error preparing dependencies: {e}") + + async def _prepare_dependencies_with_progress(self, code: str) -> PrepareResult: + """ + Run dependency preparation on a worker thread, reporting progress while it runs. + + The install can take minutes on a cold cache; on the event loop it would stall + websocket keepalive until it returns and block the progress events below. + """ + await self.send_event( + "output", {"category": "stdout", "output": "Preparing dependencies...\n"} + ) + + task = asyncio.create_task(asyncio.to_thread(self.prepare_dependencies, code)) + waited = 0 + while True: + done, _ = await asyncio.wait( + {task}, timeout=PREPARE_DEPS_PROGRESS_INTERVAL_SECONDS + ) + if done: + break + waited += PREPARE_DEPS_PROGRESS_INTERVAL_SECONDS + await self.send_event( + "output", + { + "category": "stdout", + "output": f"Still preparing dependencies... ({waited}s)\n", + }, + ) + + return task.result() def _next_var_ref(self) -> int: ref = self._variables_ref_counter @@ -528,7 +658,25 @@ class DebugSession: # Prepare dependencies before modifying the code if code: - self._venv_path = self.prepare_dependencies(code) + prepared = await self._prepare_dependencies_with_progress(code) + if prepared.error: + prefix = ( + "Failed to prepare dependencies" + if prepared.fatal + else "Warning: dependency preparation reported a problem, running anyway" + ) + await self.send_event( + "output", + {"category": "stderr", "output": f"{prefix}:\n{prepared.error}\n"}, + ) + if prepared.fatal: + await self.send_response( + request, + success=False, + message=f"Failed to prepare dependencies: {_first_line(prepared.error)}", + ) + return + self._venv_path = prepared.venv_path # If callMain is True, append a call to main() with the provided args if self._call_main and code: diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts index d6ed8967ad..ff060f3da6 100644 --- a/debugger/dap_websocket_server_bun.ts +++ b/debugger/dap_websocket_server_bun.ts @@ -222,6 +222,8 @@ function generateMainCallArgs(code: string, args: Record): stri const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000 const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false' +const PREPARE_DEPS_TIMEOUT_MS = 120_000 + // Opt-in cross-origin protection (CSWSH defense-in-depth); see // dap_debug_service.ts for the rationale. Only enforced for this file's // standalone Bun.serve entrypoint (the windmill-extra runtime imports the @@ -1579,6 +1581,20 @@ export class DebugSession { logger.info(`Preparing dependencies using ${this.windmillPath}`) + // The launch response is only sent once this returns, so without progress a cold + // cache looks like a frozen debugger for as long as the install takes. + this.sendEvent('output', { category: 'console', output: 'Preparing dependencies...\n' }) + let waited = 0 + const progress = setInterval(() => { + waited += 5 + this.sendEvent('output', { + category: 'console', + output: `Still preparing dependencies... (${waited}s)\n` + }) + }, 5000) + let killTimer: ReturnType | undefined + let timedOut = false + try { const input = JSON.stringify({ code, language }) + '\n' logger.info(`prepare-deps input length: ${input.length}`) @@ -1591,9 +1607,27 @@ export class DebugSession { stderr: 'pipe' }) + // Bound the wait: the only other ceiling is the DAP client's launch timeout, + // which is minutes, so a wedged installer would hang the session that long. + killTimer = setTimeout(() => { + timedOut = true + logger.error(`prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS}ms`) + proc.kill() + }, PREPARE_DEPS_TIMEOUT_MS) + // Wait for completion const output = await new Response(proc.stdout).text() const stderr = await new Response(proc.stderr).text() + + if (timedOut) { + const errorMsg = `prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` + this.sendEvent('output', { + category: 'console', + output: `Warning: Failed to prepare dependencies: ${errorMsg}\n` + }) + return null + } + logger.info(`prepare-deps output: ${output.substring(0, 200)}`) logger.info(`prepare-deps stderr: ${stderr.substring(0, 200)}`) @@ -1648,6 +1682,9 @@ export class DebugSession { output: `Warning: Failed to prepare dependencies: ${error}\n` }) return null + } finally { + clearInterval(progress) + clearTimeout(killTimer) } } diff --git a/debugger/test_dap_server.py b/debugger/test_dap_server.py index 45e138f2a3..17430ea25f 100644 --- a/debugger/test_dap_server.py +++ b/debugger/test_dap_server.py @@ -45,6 +45,10 @@ def main(x: str, count: int = 1): # Breakpoints for the main() test: lines 3 and 4 (inside main function) MAIN_BREAKPOINT_LINES = [3, 4] +# `launch` waits on dependency installation, so the import test below needs far more than +# the default budget on a cold cache. +REQUEST_TIMEOUTS = {"launch": 180.0} + class DAPTestClient: def __init__(self, url: str = "ws://localhost:5679"): @@ -103,7 +107,7 @@ class DAPTestClient: # Wait for response with timeout try: - response = await asyncio.wait_for(future, timeout=10.0) + response = await asyncio.wait_for(future, timeout=REQUEST_TIMEOUTS.get(command, 10.0)) return response except asyncio.TimeoutError: print(f"Timeout waiting for response to {command}") diff --git a/frontend/src/lib/components/debug/dapClient.ts b/frontend/src/lib/components/debug/dapClient.ts index 71783b4ec0..d124637b9b 100644 --- a/frontend/src/lib/components/debug/dapClient.ts +++ b/frontend/src/lib/components/debug/dapClient.ts @@ -82,6 +82,14 @@ const initialState: DebugState = { export const debugState = writable({ ...initialState }) +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000 + +// `launch` waits on dependency installation in the debug server, which can take minutes on a +// cold cache; anything shorter here reports a timeout while the install is still running. +const REQUEST_TIMEOUT_MS_BY_COMMAND: Record = { + launch: 180_000 +} + export class DAPClient { private ws: WebSocket | null = null private seq = 1 @@ -120,7 +128,13 @@ export class DAPClient { logs: s.logs, output: s.output })) + // Reject rather than drop: a dropped `launch` leaves its caller awaiting + // until the timeout below fires, which is minutes rather than seconds. + const aborted = Array.from(this.pendingRequests.values()) this.pendingRequests.clear() + for (const pending of aborted) { + pending.reject(new Error('DAP connection closed')) + } } this.ws.onerror = (error) => { @@ -164,11 +178,13 @@ export class DAPClient { arguments: args } + const timeoutMs = REQUEST_TIMEOUT_MS_BY_COMMAND[command] ?? DEFAULT_REQUEST_TIMEOUT_MS + return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(seq) - reject(new Error(`Request timeout: ${command}`)) - }, 10000) + reject(new Error(`Request timeout: ${command} (after ${timeoutMs}ms)`)) + }, timeoutMs) this.pendingRequests.set(seq, { resolve: (value) => { From 9f3f4fb6d06a6f00194ce2fc6bb55658a0d6a907 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 11:49:35 +0000 Subject: [PATCH 174/400] stop swallowing Ctrl/Cmd+Shift+S in the editors (#10530) * fix(frontend): stop swallowing Ctrl/Cmd+Shift+S in the editors Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): make Ctrl/Cmd+S from a focused Monaco flush the draft Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): broadcast the Monaco save shortcut after the effect flush Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/components/Editor.svelte | 10 +++-- .../src/lib/components/FlowBuilder.svelte | 13 ++++++- .../src/lib/components/ScriptBuilder.svelte | 19 +++++++--- .../src/lib/components/SimpleEditor.svelte | 12 +++--- .../src/lib/components/TemplateEditor.svelte | 10 ++++- .../apps/editor/AppEditorHeader.svelte | 37 ++++++++++++++----- frontend/src/lib/components/vscode.ts | 11 +++++- 7 files changed, 84 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 583ad460cc..c3125dd9ce 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -10,7 +10,7 @@ import { buildWsUrl } from '$lib/wsUrl' import { sendUserToast } from '$lib/toast' - import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte' + import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte' // import libStdContent from '$lib/es6.d.ts.txt?raw' // import domContent from '$lib/dom.d.ts.txt?raw' @@ -1670,9 +1670,11 @@ // Monaco swallows the keydown (addCommand prevents default and // stops propagation), so page-level Ctrl/Cmd+S handlers never // see it. Re-broadcast as a window event so editors that flush - // a draft on the shortcut (raw apps) can react regardless of - // which Monaco has focus. - window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')) + // a draft on the shortcut can react regardless of which Monaco + // has focus. Only after `tick()`: the autosave payload is parked + // by a `$effect`, so dispatching synchronously would make every + // listener flush the state from before `updateCode()`. + void tick().then(() => window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))) }) editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () { diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 5b0a4e127e..7889a14248 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -351,6 +351,15 @@ }) } + // Monaco swallows the keydown, so an editor with focus never reaches the + // window handler; Editor/SimpleEditor/TemplateEditor re-broadcast it + // (untyped event, hence the manual listener). A step's code editor also + // flushes through its `formatAction`, and a redundant flush is a no-op. + $effect(() => { + window.addEventListener('wm-monaco-save-shortcut', saveDraft) + return () => window.removeEventListener('wm-monaco-save-shortcut', saveDraft) + }) + // Materialize a brand-new flow's draft before the session preview loads it by // path — an untouched new flow never autosaved, so forcePersist is the only // thing that creates the row. Gated to never-deployed: forcePersist skips the @@ -928,7 +937,9 @@ } break case 's': - if (event.ctrlKey || event.metaKey) { + // Shift excluded: the switch lowercases so Ctrl+Shift+S lands here + // too, and swallowing it would steal the browser/OS shortcut. + if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { saveDraft() event.preventDefault() } diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index fcdb51fe32..67b289056a 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -918,9 +918,11 @@ } function onKeyDown(event: KeyboardEvent) { - switch (event.key) { + // Lowercased so Caps Lock (which yields `S`) still saves. Shift excluded: + // Ctrl+Shift+S must reach the browser/OS. + switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) { case 's': - if (event.ctrlKey || event.metaKey) { + if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { saveDraft() event.preventDefault() } @@ -928,6 +930,14 @@ } } + // Monaco swallows the keydown, so a code editor with focus never reaches the + // window handler above; Editor/SimpleEditor/TemplateEditor re-broadcast it + // (untyped event, hence the manual listener). + $effect(() => { + window.addEventListener('wm-monaco-save-shortcut', saveDraft) + return () => window.removeEventListener('wm-monaco-save-shortcut', saveDraft) + }) + let path: Path | undefined = $state(undefined) // Seed "path is already chosen" so the summary→path auto-slug (which only // runs for new scripts with initialPath == '') doesn't clobber a path the @@ -1445,7 +1455,7 @@ - {#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true && !isDbt} + {#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true && !isDbt}
{#snippet header()} window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))) }) editor.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () { @@ -490,8 +491,9 @@ updateCode() shouldBindKey && format && format() // See Editor.svelte — re-broadcast the swallowed shortcut for - // page-level draft-flush handlers. - window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')) + // page-level draft-flush handlers, after `tick()` so they see + // the value `updateCode()` just materialized. + void tick().then(() => window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))) }) editor.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () { diff --git a/frontend/src/lib/components/TemplateEditor.svelte b/frontend/src/lib/components/TemplateEditor.svelte index 2fef927c9a..ea4407aecc 100644 --- a/frontend/src/lib/components/TemplateEditor.svelte +++ b/frontend/src/lib/components/TemplateEditor.svelte @@ -21,7 +21,7 @@ import libStdContent from '$lib/es6.d.ts.txt?raw' import { editor as meditor, Uri as mUri, languages, Range, KeyMod, KeyCode } from 'monaco-editor' - import { createEventDispatcher, getContext, onDestroy, onMount, untrack } from 'svelte' + import { createEventDispatcher, getContext, onDestroy, onMount, tick, untrack } from 'svelte' import type { AppViewerContext } from './apps/types' import { writable } from 'svelte/store' // import '@codingame/monaco-vscode-standalone-languages' @@ -505,7 +505,13 @@ editor.onDidFocusEditorText(() => { dispatch('focus') - editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () {}) + editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () { + updateCode() + // See Editor.svelte — re-broadcast the swallowed shortcut for + // page-level draft-flush handlers, after `tick()` so they see + // the value `updateCode()` just materialized. + void tick().then(() => window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))) + }) editor?.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () {}) }) diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 0871833613..5acf279357 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -465,6 +465,28 @@ } } + /** Flush the pending autosave (also covers the toggle-off parked case). + * Returns whether there was a draft to flush — false in the AI session pane, + * which owns no handle. */ + function flushDraft(): boolean { + if (inSessionPane || !$workspaceStore || !userDraftPath) return false + void UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'app', + path: userDraftPath + }) + return true + } + + // Monaco swallows the keydown, so an inline script or template editor with + // focus never reaches the window handler below; Editor/SimpleEditor/ + // TemplateEditor re-broadcast it (untyped event, hence the manual listener). + $effect(() => { + const onMonacoSave = () => void flushDraft() + window.addEventListener('wm-monaco-save-shortcut', onMonacoSave) + return () => window.removeEventListener('wm-monaco-save-shortcut', onMonacoSave) + }) + let lock = false function onKeyDown(event: KeyboardEvent) { if (lock) return @@ -492,17 +514,12 @@ } break case 's': - if (event.ctrlKey || event.metaKey) { + // Shift excluded: the switch lowercases so Ctrl+Shift+S lands here + // too, and swallowing it would steal the browser/OS shortcut. + // Swallowed only when there is a draft to flush, so the contexts + // that can't act on it (AI session pane) leave the key alone. + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && flushDraft()) { event.preventDefault() - // Flush the pending autosave (also covers the toggle-off parked - // case); no-op in the AI session pane (no handle there). - if (!inSessionPane && $workspaceStore && userDraftPath) { - void UserDraftDbSyncer.flush({ - workspace: $workspaceStore, - itemKind: 'app', - path: userDraftPath - }) - } } break // case 'ArrowDown': { diff --git a/frontend/src/lib/components/vscode.ts b/frontend/src/lib/components/vscode.ts index f30ffcd780..7ec61be145 100644 --- a/frontend/src/lib/components/vscode.ts +++ b/frontend/src/lib/components/vscode.ts @@ -1,6 +1,6 @@ import '@codingame/monaco-vscode-standalone-typescript-language-features' -import { editor as meditor, Uri as mUri } from 'monaco-editor' +import { editor as meditor, KeyCode, KeyMod, Uri as mUri } from 'monaco-editor' import { getAppliedDarkModeVariant } from '$lib/darkModeVariant' export let isInitialized = false @@ -157,6 +157,15 @@ export async function initializeVscode(caller?: string, htmlContainer?: HTMLElem await apiWrapper.start() isInitialized = true + + // vscode-api ships VS Code's keybindings, including Ctrl/Cmd+Shift+S + // (Save As) which has no meaning here. Left bound, every Monaco + // instance swallows the shortcut and the browser/OS one never fires. + meditor.addKeybindingRule({ + keybinding: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyS, + command: null + }) + meditor.defineTheme('nord', { base: 'vs-dark', inherit: true, From 4fe4fac358e67ee6e20dbff8bfa1de0e92e5f3c7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 11:52:09 +0000 Subject: [PATCH 175/400] feat(mcp): serve the 2026-07-28 spec alongside the legacy protocol (#10535) * feat(mcp): serve the 2026-07-28 spec alongside the legacy protocol * fix(mcp): keep oauth discovery strict and preserve request limits * fix(mcp): allow the protocol's own headers through CORS * fix(mcp): expose the auth challenge to browser clients * chore: update ee-repo-ref to c1665a881b61616f96ffe7702b44840905304660 This commit updates the EE repository reference after PR #711 was merged in windmill-ee-private. Previous ee-repo-ref: bc1c001e3e386342415dfb8ac31c6b97f6629320 New ee-repo-ref: c1665a881b61616f96ffe7702b44840905304660 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 164 +++++++------ backend/Cargo.toml | 9 +- backend/ee-repo-ref.txt | 2 +- .../tests/mcp_protocol_versions.rs | 221 ++++++++++++++++++ .../tests/resources.rs | 41 ++-- backend/windmill-api/src/lib.rs | 27 ++- backend/windmill-api/src/mcp/core.rs | 24 +- backend/windmill-mcp/Cargo.toml | 2 +- backend/windmill-mcp/src/client/mod.rs | 35 +-- .../windmill-mcp/src/client_registration.rs | 5 +- backend/windmill-mcp/src/lib.rs | 65 +++++- backend/windmill-mcp/src/server/endpoints.rs | 63 ++--- backend/windmill-mcp/src/server/mod.rs | 8 +- backend/windmill-mcp/src/server/runner.rs | 101 +++++--- backend/windmill-mcp/src/server/tools.rs | 30 ++- 15 files changed, 570 insertions(+), 227 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0752d0051b..204bafda83 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1671,6 +1671,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -2270,6 +2276,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -6662,9 +6674,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -8286,6 +8298,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -10081,16 +10099,16 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.9", ] [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -10127,7 +10145,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -10140,7 +10158,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.5.0", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "thiserror 2.0.19", "tower-service", @@ -10158,7 +10176,7 @@ dependencies = [ "getrandom 0.2.17", "http 1.5.0", "hyper 1.11.0", - "reqwest 0.13.1", + "reqwest 0.13.4", "reqwest-middleware", "retry-policies", "thiserror 2.0.19", @@ -10252,13 +10270,12 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.15.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695" dependencies = [ "async-trait", - "axum 0.8.9", - "base64 0.22.1", + "base64 0.23.1", "bytes", "chrono", "futures", @@ -10268,8 +10285,8 @@ dependencies = [ "oauth2", "pastey", "pin-project-lite", - "rand 0.9.0", - "reqwest 0.12.28", + "rand 0.10.2", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.2", "serde", @@ -10287,9 +10304,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.15.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +checksum = "41bc748630c2be2a71b614c2f40d27bc0df0060696d224e1692c72345b7e0b79" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -14220,9 +14237,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -14232,27 +14249,14 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -14261,9 +14265,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -14271,50 +14275,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.119", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.53" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee0a0f5343de9221a0d233b04520ed8dc2e6728dce180b1dcd9288ec9d9fa3c" +checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d" dependencies = [ + "async-trait", + "cast", "js-sys", + "libm", "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", ] [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.53" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a369369e4360c2884c3168d22bded735c43cccae97bbc147586d4b480edd138d" +checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" + [[package]] name = "wasm-streams" version = "0.4.2" @@ -14328,6 +14347,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm_dep_analyzer" version = "0.3.0" @@ -14354,9 +14386,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.80" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -14522,7 +14554,7 @@ dependencies = [ "prometheus", "rand 0.9.0", "rdkafka", - "reqwest 0.13.1", + "reqwest 0.13.4", "rumqttc", "rustls 0.23.35", "serde", @@ -14599,7 +14631,7 @@ dependencies = [ "http 1.5.0", "lazy_static", "mime_guess", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sqlx", @@ -14679,7 +14711,7 @@ dependencies = [ "quick_cache", "rand 0.9.0", "regex", - "reqwest 0.13.1", + "reqwest 0.13.4", "rsa", "rust-embed", "rustls 0.23.35", @@ -14819,7 +14851,7 @@ dependencies = [ "jsonwebtoken 8.3.0", "lazy_static", "quick_cache", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sqlx", @@ -14893,7 +14925,7 @@ dependencies = [ "candle-transformers", "hf-hub", "lazy_static", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sqlx", @@ -14994,7 +15026,7 @@ dependencies = [ "hmac", "rand 0.9.0", "rdkafka", - "reqwest 0.13.1", + "reqwest 0.13.4", "rmcp", "rumqttc", "serde", @@ -15044,7 +15076,7 @@ version = "1.780.0" dependencies = [ "axum 0.8.9", "flate2", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sqlx", @@ -15111,7 +15143,7 @@ dependencies = [ "lazy_static", "prometheus", "quick_cache", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sql-builder", @@ -15346,7 +15378,7 @@ dependencies = [ "quick_cache", "rand 0.9.0", "regex", - "reqwest 0.13.1", + "reqwest 0.13.4", "reqwest-middleware", "reqwest-retry", "rsa", @@ -15488,7 +15520,7 @@ dependencies = [ "futures", "http 1.5.0", "oauth2", - "reqwest 0.12.28", + "reqwest 0.13.4", "rmcp", "serde", "serde_json", @@ -15513,7 +15545,7 @@ dependencies = [ "http 1.5.0", "itertools 0.14.0", "lazy_static", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.10.9", @@ -15575,7 +15607,7 @@ dependencies = [ "lazy_static", "object_store", "quick_cache", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sqlx", @@ -15898,7 +15930,7 @@ dependencies = [ "quick_cache", "rand 0.9.0", "regex", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "serde_urlencoded", @@ -15940,7 +15972,7 @@ dependencies = [ "lazy_static", "rcgen", "regex", - "reqwest 0.13.1", + "reqwest 0.13.4", "rustls 0.23.35", "serde", "serde_json", @@ -15981,7 +16013,7 @@ dependencies = [ "lazy_static", "magic-crypt", "quick_cache", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.10.9", @@ -16036,7 +16068,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "rand 0.9.0", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sql-builder", @@ -16100,7 +16132,7 @@ dependencies = [ "lazy_static", "quick_cache", "rand 0.9.0", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.10.9", @@ -16153,7 +16185,7 @@ dependencies = [ "jsonwebtoken 8.3.0", "lazy_static", "quick_cache", - "reqwest 0.13.1", + "reqwest 0.13.4", "serde", "serde_json", "sqlx", @@ -16440,7 +16472,7 @@ dependencies = [ "rand 0.9.0", "rcgen", "regex", - "reqwest 0.13.1", + "reqwest 0.13.4", "reqwest-middleware", "rsa", "rust_decimal", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 38e38f5bed..0096cff28b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -568,7 +568,12 @@ dashmap = "6.1.0" gosyn = "0.2.6" bytes = "1.4.0" gethostname = "0.4.3" -wasm-bindgen = "=0.2.103" +# Not pinned exactly: the excluded `parsers/windmill-parser-wasm` workspace pins +# =0.2.103 to match its vendored `cli/wasm/*` artifacts, yet path-depends on +# sibling parser crates that inherit this requirement from here. Two exact pins +# on the same semver range cannot both resolve, so keep this a range and let each +# workspace's lockfile settle it (here, js-sys forces 0.2.108). +wasm-bindgen = "0.2" serde-wasm-bindgen = "^0" wasm-bindgen-test = "^0" convert_case = "0.6.0" @@ -612,7 +617,7 @@ nkeys = "0.4.4" nu-parser = { version = "0.101.0", default-features = false } globset = "0.4.16" croner = "2.2.0" -rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] } +rmcp = { version = "=3.1.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] } rquickjs = { version = "0.11", features = ["futures", "parallel", "macro"] } process-wrap = { version = "8.2.1", features = ["tokio1"] } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c69a2444aa..c882e591c0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0373b4bfdaf8dd51533552e2e4de63ceb3c18b4d +c1665a881b61616f96ffe7702b44840905304660 diff --git a/backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs b/backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs new file mode 100644 index 0000000000..e684e3ee14 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs @@ -0,0 +1,221 @@ +//! Protocol-version negotiation for the MCP endpoint. +//! +//! The endpoint is dual-era: legacy revisions keep the `initialize` handshake, +//! while `2026-07-28` carries its version as per-request metadata and is served +//! statelessly. Both are answered on the same URL, so a bump of the rmcp SDK +//! must not silently drop either side. +#![cfg(feature = "mcp")] + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +/// Every revision the server advertises, oldest first. +const SUPPORTED: [&str; 5] = [ + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", + "2026-07-28", +]; + +const MODERN: &str = "2026-07-28"; + +async fn insert_mcp_token(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) + VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])", + ) + .execute(db) + .await?; + Ok(()) +} + +/// POST one JSON-RPC message and return the HTTP status plus the decoded body. +/// The endpoint answers either `application/json` or a single-event SSE stream, +/// so strip the `data: ` framing before parsing. +async fn post( + port: u16, + headers: &[(&str, &str)], + body: Value, +) -> anyhow::Result<(reqwest::StatusCode, Value)> { + let mut req = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/mcp/w/test-workspace/mcp" + )) + .header("Authorization", "Bearer MCP_TOKEN") + .header("Accept", "application/json, text/event-stream") + .json(&body); + for (k, v) in headers { + req = req.header(*k, *v); + } + let resp = req.send().await?; + let status = resp.status(); + let text = resp.text().await?; + let payload = text + .lines() + .find_map(|l| l.strip_prefix("data: ")) + .unwrap_or(text.trim()); + let parsed = serde_json::from_str(payload) + .map_err(|e| anyhow::anyhow!("status {status}, unparseable body {text:?}: {e}"))?; + Ok((status, parsed)) +} + +fn modern_meta() -> Value { + json!({ + "io.modelcontextprotocol/protocolVersion": MODERN, + "io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "0.0.1" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_mcp_legacy_initialize_negotiates_requested_version( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + insert_mcp_token(&db).await?; + let server = ApiServer::start_mcp(db.clone()).await?; + let port = server.addr.port(); + + // A legacy client must be answered with the revision it asked for, not with + // whatever the SDK happens to call `LATEST`. + for version in SUPPORTED.iter().filter(|v| **v != MODERN) { + let (status, body) = post( + port, + &[("MCP-Protocol-Version", version)], + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": version, + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "0.0.1" }, + } + }), + ) + .await?; + + assert_eq!(status, 200, "initialize {version} failed: {body}"); + assert_eq!( + body["result"]["protocolVersion"], *version, + "initialize {version} negotiated the wrong revision: {body}" + ); + // `Implementation::from_build_env()` expands its `env!` inside rmcp, so + // the obvious constructor makes the server introduce itself as the SDK. + assert_eq!( + body["result"]["serverInfo"]["name"], "windmill", + "server must identify itself, not the SDK: {body}" + ); + } + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_mcp_modern_requests_are_served_without_initialize( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + insert_mcp_token(&db).await?; + let server = ApiServer::start_mcp(db.clone()).await?; + let port = server.addr.port(); + + // `server/discover` is the modern replacement for the handshake: it must + // exist and advertise exactly the revisions the server implements. + let (status, body) = post( + port, + &[ + ("MCP-Protocol-Version", MODERN), + ("Mcp-Method", "server/discover"), + ], + json!({ + "jsonrpc": "2.0", "id": 1, "method": "server/discover", + "params": { "_meta": modern_meta() } + }), + ) + .await?; + assert_eq!(status, 200, "server/discover failed: {body}"); + assert_eq!( + body["result"]["supportedVersions"], + json!(SUPPORTED), + "server/discover advertised the wrong revisions: {body}" + ); + + // A modern call carries its version in `_meta` and needs no prior session. + let (status, body) = post( + port, + &[ + ("MCP-Protocol-Version", MODERN), + ("Mcp-Method", "tools/list"), + ], + json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", + "params": { "_meta": modern_meta() } + }), + ) + .await?; + assert_eq!(status, 200, "modern tools/list failed: {body}"); + assert!( + body["result"]["tools"] + .as_array() + .is_some_and(|t| !t.is_empty()), + "modern tools/list returned no tools: {body}" + ); + + // SEP-2549 cache hints are required at 2026-07-28 and rmcp omits them unless + // set, which makes strict clients (e.g. the Python SDK) reject the whole + // response rather than degrade. + assert!( + body["result"]["ttlMs"].is_number(), + "modern tools/list is missing ttlMs: {body}" + ); + assert_eq!( + body["result"]["cacheScope"], "private", + "tools/list must not be cached across callers: {body}" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_mcp_unsupported_version_lists_supported_ones( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + insert_mcp_token(&db).await?; + let server = ApiServer::start_mcp(db.clone()).await?; + let port = server.addr.port(); + + // The client's only way forward is the `supported` list, so an unknown + // version must fail with it rather than with a generic error. + let (status, body) = post( + port, + &[ + ("MCP-Protocol-Version", "1900-01-01"), + ("Mcp-Method", "tools/list"), + ], + json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": { "_meta": { + "io.modelcontextprotocol/protocolVersion": "1900-01-01", + "io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "0.0.1" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }} + }), + ) + .await?; + + assert_eq!(status, 400, "expected 400 for unknown version: {body}"); + assert_eq!(body["error"]["code"], -32022, "wrong error code: {body}"); + assert_eq!( + body["error"]["data"]["supported"], + json!(SUPPORTED), + "error did not advertise the supported revisions: {body}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 3cc59be473..07afbb3442 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -756,37 +756,26 @@ async fn test_mcp_client_get_job_and_logs(db: Pool) -> anyhow::Result< .auth_header("MCP_TOKEN"); let transport = StreamableHttpClientTransport::from_config(config); - let client_info = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "test-client".to_string(), - title: None, - version: "0.0.1".to_string(), - description: None, - website_url: None, - icons: None, - }, - meta: None, - }; + let client_info = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("test-client", "0.0.1"), + ); let client: RunningService = client_info.serve(transport).await?; // --- Test getJob --- let result = client - .call_tool(CallToolRequestParams { - name: "getJob".into(), - arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?), - task: None, - meta: None, - }) + .call_tool( + CallToolRequestParams::new("getJob") + .with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?), + ) .await?; let text = result .content .first() - .and_then(|c| c.raw.as_text()) + .and_then(|c| c.as_text()) .expect("getJob should return text content"); let job: serde_json::Value = serde_json::from_str(&text.text)?; assert_eq!(job["id"], job_id.to_string()); @@ -800,18 +789,16 @@ async fn test_mcp_client_get_job_and_logs(db: Pool) -> anyhow::Result< // --- Test getJobLogs --- let result = client - .call_tool(CallToolRequestParams { - name: "getJobLogs".into(), - arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?), - task: None, - meta: None, - }) + .call_tool( + CallToolRequestParams::new("getJobLogs") + .with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?), + ) .await?; let text = result .content .first() - .and_then(|c| c.raw.as_text()) + .and_then(|c| c.as_text()) .expect("getJobLogs should return text content"); // The logs endpoint returns text/plain, which gets wrapped as a JSON string by call_endpoint let logs: String = serde_json::from_str(&text.text)?; diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index e4ffa16c9e..e67420c53c 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -424,6 +424,27 @@ pub async fn run_server( .allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION]) .allow_origin(Any); + // MCP carries protocol state in its own headers: `MCP-Protocol-Version` from + // revision 2025-06-18 onward, plus `Mcp-Method` and `Mcp-Name` at 2026-07-28. + // None of them are CORS-simple, so a browser-based MCP client fails preflight + // unless they are allowed — hence a separate layer rather than widening the + // one every other route shares. (`Mcp-Param-*` is only sent for tool inputs + // annotated with `x-mcp-header`, which no tool here declares.) + let mcp_cors = CorsLayer::new() + .allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE]) + .allow_headers([ + http::header::CONTENT_TYPE, + http::header::AUTHORIZATION, + http::HeaderName::from_static("mcp-protocol-version"), + http::HeaderName::from_static("mcp-method"), + http::HeaderName::from_static("mcp-name"), + ]) + // The 401 challenge is how a client discovers where to authorize (RFC 9728), + // and it is not a safelisted response header, so without this a browser + // client sees an empty one and has no way to begin the OAuth flow. + .expose_headers([http::header::WWW_AUTHENTICATE]) + .allow_origin(Any); + let sp_extension = Arc::new(saml_oss::build_sp_extension().await?); if server_mode { @@ -820,13 +841,13 @@ pub async fn run_server( // Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead .nest( "/mcp/w/{workspace_id}/sse", - mcp_router.clone().layer(cors.clone()), + mcp_router.clone().layer(mcp_cors.clone()), ) .nest( "/mcp/w/{workspace_id}/mcp", - mcp_router.clone().layer(cors.clone()), + mcp_router.clone().layer(mcp_cors.clone()), ) - .nest("/mcp/gateway", gateway_mcp_router.layer(cors.clone())) + .nest("/mcp/gateway", gateway_mcp_router.layer(mcp_cors.clone())) .nest("/agent_workers", { #[cfg(feature = "agent_worker_server")] { diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 5d78536d86..af7e22fcba 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -570,12 +570,24 @@ pub async fn setup_mcp_server( let backend = WindmillBackend::new(db, user_db, base_internal_url, auth_cache); let runner = Runner::new(backend); - let service_config = StreamableHttpServerConfig { - sse_keep_alive: Some(Duration::from_secs(15)), - stateful_mode: false, - cancellation_token: cancellation_token.clone(), - sse_retry: Some(Duration::from_secs(15)), - }; + let service_config = StreamableHttpServerConfig::default() + .with_sse_keep_alive(Some(Duration::from_secs(15))) + .with_sse_retry(Some(Duration::from_secs(15))) + .with_cancellation_token(cancellation_token.clone()) + // Sessionless: every request re-resolves auth from its own bearer token, so + // there is no session to bind. This also makes legacy `initialize` clients + // take the same stateless path as 2026-07-28 ones. + .with_legacy_session_mode(false) + // rmcp's Host allowlist defaults to localhost, which guards an unauthenticated + // locally-bound server against DNS rebinding. This endpoint instead sits behind + // Windmill's own authentication, and is reached under whatever hostname the + // instance is served on, so keeping that default would reject every remote MCP + // client while adding nothing. + .disable_allowed_hosts() + // MCP bodies are ordinary API payloads — `createApp`/`updateApp` carry whole app + // sources — so they follow the instance's request size limit rather than rmcp's + // much smaller default, which would 413 them with no way to raise it. + .with_max_request_body_bytes(*crate::REQUEST_SIZE_LIMIT.read().await); let service = StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config); diff --git a/backend/windmill-mcp/Cargo.toml b/backend/windmill-mcp/Cargo.toml index 36e0d03d18..8ac23e885d 100644 --- a/backend/windmill-mcp/Cargo.toml +++ b/backend/windmill-mcp/Cargo.toml @@ -17,7 +17,7 @@ auth = ["rmcp/auth", "dep:oauth2", "dep:sqlx", "dep:chrono"] oauth2 = { version = "5.0", optional = true } windmill-common = { workspace = true, default-features = false } anyhow.workspace = true -reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] } +reqwest.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index f61a1c3f85..863d6b0355 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -85,10 +85,7 @@ impl McpClient { // and does not legitimately rely on redirects. .redirect(reqwest::redirect::Policy::none()); // Pin DNS to the address validated above so the connect cannot rebind to - // an internal IP between the check and the request. `apply_dns_pinning` - // lives on windmill-common's reqwest, but this crate resolves a - // different reqwest version (via rmcp), so pin directly with the - // std-typed host/addrs the validation surfaced. Empty addrs (IP literal + // an internal IP between the check and the request. Empty addrs (IP literal // or ALLOW_PRIVATE_MCP_SERVER_URLS) leave resolution untouched. if !validated.addrs.is_empty() { client_builder = client_builder.resolve_to_addrs(&validated.host, &validated.addrs); @@ -102,19 +99,11 @@ impl McpClient { let transport = StreamableHttpClientTransport::with_client(reqwest_client, config); // Set up client info - let client_info = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "windmill-ai-agent".to_string(), - title: Some("Windmill AI Agent".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - description: None, - website_url: None, - icons: None, - }, - meta: None, - }; + let client_info = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("windmill-ai-agent", env!("CARGO_PKG_VERSION")) + .with_title("Windmill AI Agent"), + ); // Initialize the connection let client = client_info @@ -143,14 +132,14 @@ impl McpClient { let mcp_args = Self::openai_args_to_mcp_args(arguments).context("Failed to parse tool arguments")?; + let mut params = CallToolRequestParams::new(name.to_string()); + if let Some(args) = mcp_args { + params = params.with_arguments(args); + } + let result = self .client - .call_tool(CallToolRequestParams { - name: name.to_string().into(), - arguments: mcp_args, - task: None, - meta: None, - }) + .call_tool(params) .await .context(format!("Failed to call MCP tool: {}", name))?; diff --git a/backend/windmill-mcp/src/client_registration.rs b/backend/windmill-mcp/src/client_registration.rs index 637b76df80..3aa39e840b 100644 --- a/backend/windmill-mcp/src/client_registration.rs +++ b/backend/windmill-mcp/src/client_registration.rs @@ -170,7 +170,7 @@ pub async fn get_or_refresh_mcp_client( .map_err(|e| error::Error::BadRequest(format!("Failed to create auth manager: {e}")))?; // Discovery hits the well-known endpoint on the MCP server host validated // above; pin to that address so it cannot rebind between check and connect. - // Limitation: rmcp's discover_metadata may additionally follow server-supplied + // Limitation: rmcp's resolve_metadata may additionally follow server-supplied // metadata URLs (resource_metadata / authorization_servers) on other hosts, // which this per-host pin does not cover — a pre-existing gap in rmcp discovery // that a validating resolver would need to close, out of scope for this pin. @@ -181,8 +181,7 @@ pub async fn get_or_refresh_mcp_client( .with_client(discovery_client) .map_err(|e| error::Error::BadRequest(format!("Failed to configure auth manager: {e}")))?; - let metadata = manager - .discover_metadata() + let metadata = crate::oauth::discover_authorization_metadata(&manager) .await .map_err(|e| error::Error::BadRequest(format!("OAuth discovery failed: {e}")))?; diff --git a/backend/windmill-mcp/src/lib.rs b/backend/windmill-mcp/src/lib.rs index d5eb0b9597..33a20be2f0 100644 --- a/backend/windmill-mcp/src/lib.rs +++ b/backend/windmill-mcp/src/lib.rs @@ -40,10 +40,30 @@ pub mod oauth { use std::time::Duration; - pub use rmcp::transport::auth::AuthorizationManager; + use rmcp::transport::auth::AuthorizationMetadataSource; + pub use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadata}; const DEFAULT_OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30); + /// Discover the MCP server's OAuth metadata, refusing endpoints the server + /// never advertised. + /// + /// When a server publishes no metadata at all, rmcp's `resolve_metadata` + /// falls back to inventing `/authorize`, `/token` and `/register` on the + /// server's own host. Dynamic client registration and the token exchange + /// both carry secrets, so they must only ever reach endpoints the server + /// actually published — a guessed path would send them somewhere the + /// operator never designated as an authorization server. + pub async fn discover_authorization_metadata( + manager: &AuthorizationManager, + ) -> anyhow::Result { + let resolution = manager.resolve_metadata().await?; + if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback { + anyhow::bail!("MCP server does not publish OAuth authorization metadata"); + } + Ok(resolution.metadata) + } + pub fn no_redirect_http_client() -> Result { no_redirect_http_client_with_timeout(DEFAULT_OAUTH_HTTP_TIMEOUT) } @@ -61,11 +81,8 @@ pub mod oauth { /// guard validated for the request URL so the connect cannot rebind to an /// internal IP after the check (TOCTOU). The OAuth DCR/discovery/token /// requests target author-controlled URLs and carry secrets, so they must - /// go through this rather than the unpinned client. `apply_dns_pinning` - /// lives on windmill-common's reqwest, which this crate resolves at a - /// different version (via rmcp), so pin directly with the std-typed - /// host/addrs. Empty `addrs` (IP literal or ALLOW_PRIVATE_MCP_SERVER_URLS) - /// leaves resolution untouched. + /// go through this rather than the unpinned client. Empty `addrs` (IP literal + /// or ALLOW_PRIVATE_MCP_SERVER_URLS) leaves resolution untouched. pub fn no_redirect_http_client_pinned( target: &windmill_common::ssrf::ValidatedTarget, ) -> Result { @@ -124,5 +141,41 @@ pub mod oauth { handle.join().unwrap(); } + + /// A server publishing no OAuth metadata must be rejected, not have its + /// endpoints guessed: rmcp's own fallback would invent `/authorize`, + /// `/token` and `/register` on that host, and DCR and the token exchange + /// send secrets to whatever comes back. + #[tokio::test] + async fn discovery_refuses_endpoints_the_server_never_published() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + let handle = thread::spawn(move || { + // Every discovery probe 404s, which is what a plain MCP server + // with no authorization server looks like. + while let Ok((mut stream, _)) = listener.accept() { + let mut buffer = [0u8; 2048]; + let _ = stream.read(&mut buffer); + let _ = std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ); + } + }); + + let manager = AuthorizationManager::new(format!("http://{addr}/mcp")) + .await + .expect("manager should construct"); + let err = discover_authorization_metadata(&manager) + .await + .expect_err("must not fall back to guessed endpoints"); + assert!( + err.to_string().contains("does not publish OAuth"), + "unexpected error: {err}" + ); + + drop(handle); + } } } diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 9e3adffc0b..cea2896caa 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -59,17 +59,13 @@ pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool { // Create annotations based on HTTP method and endpoint characteristics let annotations = create_endpoint_annotations(tool); - Tool { - name: tool.name.clone(), - description: Some(description.into()), - input_schema: Arc::new(combined_schema.as_object().unwrap().clone()), - title: Some(tool.name.to_string()), - output_schema: None, - icons: None, - annotations: Some(annotations), - meta: None, - execution: None, - } + Tool::new( + tool.name.clone(), + description, + Arc::new(combined_schema.as_object().unwrap().clone()), + ) + .with_title(tool.name.to_string()) + .with_annotations(annotations) } /// Convert an endpoint tool to an MCP tool for multi-workspace mode. @@ -130,26 +126,19 @@ pub fn list_workspaces_tool() -> Tool { "required": [] }); - Tool { - name: Cow::Borrowed("list_workspaces"), - description: Some( - "List the Windmill workspaces this token can access. Use the returned workspace ids as the `workspace_id` argument of the other tools." - .into(), - ), - input_schema: Arc::new(schema.as_object().unwrap().clone()), - title: Some("List accessible workspaces".to_string()), - output_schema: None, - icons: None, - annotations: Some(ToolAnnotations { - title: Some("List accessible workspaces".to_string()), - read_only_hint: Some(true), - destructive_hint: Some(false), - idempotent_hint: Some(true), - open_world_hint: Some(false), - }), - meta: None, - execution: None, - } + Tool::new( + Cow::Borrowed("list_workspaces"), + "List the Windmill workspaces this token can access. Use the returned workspace ids as the `workspace_id` argument of the other tools.", + Arc::new(schema.as_object().unwrap().clone()), + ) + .with_title("List accessible workspaces") + .with_annotations( + ToolAnnotations::with_title("List accessible workspaces") + .read_only(true) + .destructive(false) + .idempotent(true) + .open_world(false), + ) } /// Create appropriate annotations for endpoint tools based on HTTP method @@ -166,13 +155,11 @@ fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations { _ => (false, true, false, true), // Default: assume can modify and be destructive }; - ToolAnnotations { - title: Some(format!("{} {}", method, tool.path)), - read_only_hint: Some(read_only), - destructive_hint: Some(destructive), - idempotent_hint: Some(idempotent), - open_world_hint: Some(open_world), - } + ToolAnnotations::with_title(format!("{} {}", method, tool.path)) + .read_only(read_only) + .destructive(destructive) + .idempotent(idempotent) + .open_world(open_world) } /// Merge schema into combined properties and required fields diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index da7032418b..19c7e43df6 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -23,10 +23,10 @@ pub use tools::create_tool_from_item; // Re-export rmcp types for convenience pub use rmcp::handler::server::ServerHandler; pub use rmcp::model::{ - Annotated, CallToolRequestParams, CallToolResult, Content, Implementation, - InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, - ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, RawContent, - RawTextContent, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, + CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams, + InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, + ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, + ToolAnnotations, }; pub use rmcp::service::{RequestContext, RoleServer}; pub use rmcp::transport::streamable_http_server::{ diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 0e7c01699b..e8248920ed 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -17,16 +17,42 @@ use crate::server::endpoints::{ use crate::server::tools::create_tool_from_item; use rmcp::handler::server::ServerHandler; use rmcp::model::{ - CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams, - InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, - ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, + CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, + ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, + ServerCapabilities, ServerInfo, }; use rmcp::service::{RequestContext, RoleServer}; use rmcp::ErrorData; use serde_json::Value; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +/// Protocol revisions this server is willing to speak. `2026-07-28` is served +/// statelessly with per-request metadata; the older revisions keep the +/// `initialize` handshake, so both eras are answered on the same endpoint. +const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ + ProtocolVersion::V_2024_11_05, + ProtocolVersion::V_2025_03_26, + ProtocolVersion::V_2025_06_18, + ProtocolVersion::V_2025_11_25, + ProtocolVersion::V_2026_07_28, +]; + +/// SEP-2549 cache hints, required on every list result at `2026-07-28` — rmcp +/// leaves them unset, and a strict client rejects the response without them. +/// +/// Zero because nothing here is cacheable: the listing is rebuilt from the +/// workspace's scripts and flows, which change at any time, and this server +/// advertises no `listChanged` capability, so a client that cached a stale list +/// would have no way to learn it had gone stale. +const LIST_TTL_MS: u64 = 0; +/// Every listing is filtered by the caller's token scopes and workspace +/// membership, so no two callers necessarily see the same tools — a shared +/// cache entry would leak one token's view to another. +const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; + // Re-export from http crate for extracting request parts use http::request::Parts as HttpParts; @@ -307,24 +333,26 @@ fn find_matching_path(candidates: Vec, request_name: &str) - impl ServerHandler for Runner { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::default(), - capabilities: ServerCapabilities::builder().enable_tools().build(), - server_info: Implementation::from_build_env(), - instructions: Some( + // Not `Implementation::from_build_env()`: its `env!` expands inside rmcp, so it + // would name the SDK crate rather than this server. + let server_info = Implementation::new("windmill", env!("CARGO_PKG_VERSION")) + .with_title("Windmill") + .with_website_url("https://windmill.dev"); + + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(server_info) + .with_instructions( "This server provides a list of scripts and flows the user can run on Windmill. \ - Each flow and script is a tool callable with their respective arguments." - .to_string(), - ), - } + Each flow and script is a tool callable with their respective arguments.", + ) } - async fn initialize( - &self, - _request: InitializeRequestParams, - _context: RequestContext, - ) -> Result { - Ok(self.get_info()) + /// Pinned rather than left to rmcp's default (every version the SDK knows), so a + /// future SDK revision cannot start advertising a version this server has not been + /// exercised against. Bounds `initialize` negotiation, `server/discover`, and + /// per-request version validation alike. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) } async fn list_tools( @@ -359,7 +387,7 @@ impl ServerHandler for Runner { &self, request: CallToolRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { let (auth, mode) = Self::extract_context(&context)?; // Parse MCP scopes for authorization @@ -370,7 +398,9 @@ impl ServerHandler for Runner { let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); - match mode { + // Every tool here runs to completion in one round trip: none of them ask the + // client for input, so the MRTR variants of `CallToolResponse` are never built. + let result = match mode { McpMode::Single(workspace_id) => { self.call_tool_single( &auth, @@ -386,7 +416,8 @@ impl ServerHandler for Runner { self.call_tool_multi(&auth, &token, &scope_config, read_only, request.name, args) .await } - } + }?; + Ok(result.into()) } async fn list_resources( @@ -394,7 +425,9 @@ impl ServerHandler for Runner { _request: Option, _context: RequestContext, ) -> Result { - Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None }) + Ok(ListResourcesResult::with_all_items(vec![]) + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE)) } async fn list_prompts( @@ -402,7 +435,9 @@ impl ServerHandler for Runner { _request: Option, _context: RequestContext, ) -> Result { - Ok(ListPromptsResult::default()) + Ok(ListPromptsResult::default() + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE)) } async fn list_resource_templates( @@ -410,7 +445,9 @@ impl ServerHandler for Runner { _request: Option, _context: RequestContext, ) -> Result { - Ok(ListResourceTemplatesResult::default()) + Ok(ListResourceTemplatesResult::default() + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE)) } } @@ -547,7 +584,9 @@ impl Runner { tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool)); } - Ok(ListToolsResult { tools, next_cursor: None, meta: None }) + Ok(ListToolsResult::with_all_items(tools) + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE)) } /// Handle a tool call for a single, bound workspace. @@ -575,7 +614,7 @@ impl Runner { .await .map_err(|e| ErrorData::internal_error(e.message, None))?; - return Ok(CallToolResult::success(vec![Content::text( + return Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()), ), @@ -699,7 +738,7 @@ impl Runner { }; match result { - Ok(value) => Ok(CallToolResult::success(vec![Content::text( + Ok(value) => Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()), ), @@ -733,7 +772,9 @@ impl Runner { tools.push(endpoint_tool_to_mcp_tool_multi(&endpoint_tool)); } - ListToolsResult { tools, next_cursor: None, meta: None } + ListToolsResult::with_all_items(tools) + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE) } /// Handle a tool call for a multi-workspace session. `base_auth` is the @@ -754,7 +795,7 @@ impl Runner { .list_accessible_workspaces(base_auth) .await .map_err(|e| ErrorData::internal_error(e.message, None))?; - return Ok(CallToolResult::success(vec![Content::text( + return Ok(CallToolResult::success(vec![ContentBlock::text( serde_json::to_string_pretty(&workspaces).unwrap_or_else(|_| "[]".to_string()), )])); } @@ -827,7 +868,7 @@ impl Runner { .await .map_err(|e| ErrorData::internal_error(e.message, None))?; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()), ), diff --git a/backend/windmill-mcp/src/server/tools.rs b/backend/windmill-mcp/src/server/tools.rs index 50ed03426b..efdf86ed22 100644 --- a/backend/windmill-mcp/src/server/tools.rs +++ b/backend/windmill-mcp/src/server/tools.rs @@ -190,21 +190,17 @@ pub fn create_tool_from_item( } }; - Tool { - name: Cow::Owned(path), - description: Some(Cow::Owned(description)), - input_schema: Arc::new(input_schema_map), - title: Some(title.clone()), - output_schema: None, - icons: None, - annotations: Some(ToolAnnotations { - title: Some(title), - read_only_hint: Some(false), // Can modify environment - destructive_hint: Some(true), // Can potentially be destructive - idempotent_hint: Some(false), // Are not guaranteed to be idempotent - open_world_hint: Some(true), // Can interact with external services - }), - meta: None, - execution: None, - } + Tool::new( + Cow::Owned(path), + Cow::Owned(description), + Arc::new(input_schema_map), + ) + .with_title(title.clone()) + .with_annotations( + ToolAnnotations::with_title(title) + .read_only(false) // Can modify environment + .destructive(true) // Can potentially be destructive + .idempotent(false) // Are not guaranteed to be idempotent + .open_world(true), // Can interact with external services + ) } From 616d4fe167e1c99f8f5104c7b66dbc5fd502e0be Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 5 Aug 2026 14:21:14 +0200 Subject: [PATCH 176/400] fix: edit-in-dev-workspace dead-ends, wraps, and misses the tree view (#10354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stop the homepage edit-in-fork button from wrapping * fix: show the full edit-in-fork label anywhere on the button * fix: thread showEditButton through the homepage tree view * fix: match the edit-in-fork button styling to the normal edit button * fix: edit in dev workspace dead-ends on items the dev workspace lacks Co-Authored-By: Claude Opus 5 (1M context) * fix: pull the item's folder before copying it into the dev workspace * fix: speak the compare page's update vocabulary in the dev-workspace prompt * fix: raw app with no stylesheet was undeployable across workspaces * fix: drop the raw-app stylesheet workaround now that the backend serves one The frontend wrapped `getRawAppData` to report a missing `.css` as empty, because a raw app with no stylesheet stores no css blob and the shared deploy treats the resulting 404 as fatal. #10364 fixed that at the source: the backend now serves an empty body for a missing stylesheet, so the wrapper guards a 404 that no longer happens. Co-Authored-By: Claude Opus 5 (1M context) * fix: drop the fork icon from the edit-in-dev-workspace affordances The row button carried both a pen and a fork, and the menu entries and detail page buttons carried a fork alone — where the menus already used that same icon for Duplicate/Fork, so the two entries were indistinguishable. The action is an edit, so it takes the pen everywhere, matching the ordinary Edit button. Co-Authored-By: Claude Opus 5 (1M context) * fix: send edit in dev workspace to the item's editor The affordance landed on the item's page in the dev workspace and left the user to open the editor from there. It says "Edit", so it goes to the editor: `/scripts/edit/...?workspace=` and the equivalent for flows and both app kinds. `?workspace=` still does the workspace switch, which the logged layout applies on any route. Co-Authored-By: Claude Opus 5 (1M context) * feat: choose the on-behalf-of user when updating the dev workspace The prompt deployed the item with no say over the identity it would run under, so an item that ran on behalf of someone in prod silently became the deploying user's in the dev workspace. It now offers the same choice the compare page does, under the same rules: shown only when the source item carries an on_behalf_of, picking anyone but yourself gated on admin/wm_deployers in the target, and confirming blocked until a choice is made — including while the lookup that decides whether one is needed is still in flight. The prompt also stops offering the compare page inline; the confirm button still leads there when the user can't deploy into the dev workspace. Two fixes the reused selector needed to work inside a dialog: - ConfirmationModal takes `confirmDisabled`, which also blocks the Enter binding. - The popover's z-index is now overridable, and the user picker is portalled. A ConfirmationModal renders above the popover layer, and its card is transformed for the open transition, which makes it the containing block for the picker's `fixed` positioning — so both opened behind, and the picker was laid out inside the card instead of the viewport. Co-Authored-By: Claude Opus 5 (1M context) * fix: check deploy rights per item before prompting to update the dev workspace * fix: read the compare page link before closing the dev-workspace prompt The link is derived from the request the prompt is answering, so closing first left an empty string to navigate to: refusing users saw the dialog dismiss and stay put, with no way through to the compare page. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the new-tab promise and speak up when a popup is blocked Three defects found by successive cold reviews of the click-time resolution added earlier in this branch, each one only reachable once the previous fix existed: - Safari refuses `window.open` from any promise continuation however fast it resolves, so the tab the editor dropdown opens after its existence probe never appeared there. `claimTab` takes the tab inside the click's own transient activation and points it at the answer once it lands, releasing it when there is nothing to show. - That left the two halves of the same action disagreeing: the entry promises never to navigate the editor away, but when the item turned out to be missing the prompt took over and navigated in place. The request now carries `openInNewTab`, and every destination the prompt can reach honours it. - With popups blocked the fallback called `window.open` without checking, so a successful deploy closed the prompt and did nothing, silently. It now names what it could not open. `openEditInFork` also takes the workspace explicitly. The four editor dropdowns computed their label from `opWorkspace` but resolved the action from the navigation store, and `prodWorkspaceId` feeds `deployItem({ workspaceFrom })` — so a session pane would have deployed from the wrong workspace. `checkPathWritePermission` is exported with an injectable folder probe and covered by table-driven cases, chiefly to pin its two fail-open branches, which otherwise read as dead code inviting deletion. The two unrelated whitespace hunks in ScriptBuilder.svelte are the repo's format-on-save hook fixing pre-existing violations in a file this touched. Co-Authored-By: Claude Opus 5 (1M context) * fix: create the dev workspace's missing folder without overwriting it `ensureFolder` delegated to the shared `deployItem`, which re-probes and switches to `updateFolder` when the folder turns out to exist. Nobody asked for that folder to be deployed — it is created only so the item has somewhere to land — so a folder created between the two probes had its owners, ACL, summary and labels silently replaced with the source workspace's. Creating is now create-only, and losing that race counts as success: the folder exists, which is all the caller needed. The same delegation dropped `default_permissioned_as` and `labels`, which the shared folder deploy does not send. A folder copied without its create-time identity rules applies none, so an item landing inside it with no on_behalf_of of its own resolves to whoever deployed it rather than to the principal the source folder would have chosen — the exact substitution the rest of this branch exists to prevent. Both are now carried across. Also check `window.open` in the no-dev-workspace branch of `openEditInFork`. The branch beneath it already reported a blocked popup; this one returned as if it had opened something. Co-Authored-By: Claude Opus 5 (1M context) * fix: translate copied folder identity rules into the target workspace A `u/` names a workspace-local account, so copying a folder's `default_permissioned_as` verbatim was wrong in two directions: the same username in the dev workspace can be a different person, who would then be granted the item; and a username with no account there at all passes the folder-create check, which is structural, only to fail every subsequent item deploy on the existence check, including the retry — the folder now exists, so `ensureFolder` short-circuits and the deploy fails identically, with no way out of the prompt. Rules are now resolved source username -> email -> target username, since email is the only identifier stable across workspaces, and a rule whose principal has no account in the target is dropped rather than carried. Dropping one makes the copied folder less restrictive than its source, which is not something to discover later from an item running as the wrong user, so it is reported. Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse to overwrite a concurrent item, and translate every folder principal Four findings from CI review, all on the implicit half of this flow — the writes the user did not explicitly ask for. The item write is now create-only. The shared `deployItem` re-probes and silently switches to an update, so the caller that acts on an item being *absent* could still overwrite whoever landed it between the two probes. Rather than reimplementing the per-kind deploys, the frontend's own provider refuses exactly the three writes that branch reaches for — `updateFlow`, `updateApp`/ `updateAppRaw`, and a `createScript` carrying a `parent_hash`, which is what makes an otherwise identical create an update. A refusal reports `conflict`, and the prompt opens their version instead of replacing it. Folder principals are translated rather than copied. `u/` is workspace-local, so a verbatim copy either names nobody or names a different account that happens to share the username. Users now resolve source username -> email -> target username, and the two kinds of unresolvable principal are separated because they fail differently: an owner or ACL entry is dropped, which can only narrow the folder and leaves the creator owning it; an identity rule refuses the copy outright, because dropping it runs the item as the deployer and keeping it creates a folder the server then rejects every deploy into. Groups resolve against `listGroups` rather than `listGroupNames`, which unions in instance groups that folder rules do not resolve against — a same-named instance group would otherwise let an unusable rule through. Co-Authored-By: Claude Opus 5 (1M context) * fix: read every page of workspace groups before judging a folder principal `listGroups` paginates, and the `perPage: 100` it was called with is narrower than the server's own default of 1000 — so a group past the first page read as having no account in the target. Since an unresolvable identity rule now refuses the whole folder copy, that turned into a refusal naming a group that does exist, and an owner or ACL entry on a later page was dropped silently. Read until a page comes back short, with a size check as the backstop for a server that ignores `page`. `list_users` is unpaginated, so the user half of the same lookup was never affected. Also move `makeProvider`'s doc block back onto `makeProvider`; adding `DeployConflict` had left it documenting the type instead. Co-Authored-By: Claude Opus 5 (1M context) * docs: reattach principalTranslator's doc block to principalTranslator Adding `workspaceGroupNames` above it left the block documenting the helper, the same way adding `DeployConflict` had displaced `makeProvider`'s. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../lib/components/CompareWorkspaces.svelte | 11 +- .../src/lib/components/FlowBuilder.svelte | 4 +- .../lib/components/OnBehalfOfSelector.svelte | 146 +++++--- .../src/lib/components/ScriptBuilder.svelte | 4 +- .../components/UpdateDevWorkspaceModal.svelte | 348 ++++++++++++++++++ .../apps/editor/AppEditorHeader.svelte | 4 +- .../components/common/button/Button.svelte | 6 +- .../ConfirmationModal.svelte | 24 +- .../lib/components/common/table/AppRow.svelte | 21 +- .../common/table/EditInForkButton.svelte | 53 +++ .../components/common/table/FlowRow.svelte | 21 +- .../components/common/table/ScriptRow.svelte | 21 +- .../src/lib/components/home/ItemsList.svelte | 1 + .../src/lib/components/home/TreeView.svelte | 4 + .../lib/components/home/TreeViewRoot.svelte | 5 +- .../components/meltComponents/Popover.svelte | 6 +- .../raw_apps/RawAppEditorHeader.svelte | 4 +- frontend/src/lib/utils/editInFork.ts | 180 ++++++++- .../src/lib/utils/editInForkModal.svelte.ts | 28 ++ .../src/lib/utils_workspace_deploy.test.ts | 48 +++ frontend/src/lib/utils_workspace_deploy.ts | 325 +++++++++++++++- .../src/routes/(root)/(logged)/+layout.svelte | 3 + .../(logged)/flows/get/[...path]/+page.svelte | 10 +- .../(logged)/forks/compare/+page.svelte | 4 +- .../(root)/(logged)/run/[...run]/+page.svelte | 13 +- .../scripts/get/[...hash]/+page.svelte | 10 +- frontend/src/routes/(root)/+layout.svelte | 18 +- 27 files changed, 1164 insertions(+), 158 deletions(-) create mode 100644 frontend/src/lib/components/UpdateDevWorkspaceModal.svelte create mode 100644 frontend/src/lib/components/common/table/EditInForkButton.svelte create mode 100644 frontend/src/lib/utils/editInForkModal.svelte.ts diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index c40e2585f3..2b644e900d 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -118,6 +118,10 @@ /** False while the (async) chatMask is still loading. The select-all default * waits for this so it doesn't race the mask. Defaults to true. */ chatMaskReady?: boolean + /** Whether the mask also scopes the update direction (parent→fork). True only + * for an explicit `?items=` deep link, which can legitimately name items to + * pull in — a session's mask never can (see the preselect rule below). */ + maskAppliesToUpdate?: boolean /** Selecting `draft` asks the page to swap us out for CompareDrafts; * deploy_to/update are handled internally but reported so the page can * remember the direction. */ @@ -146,6 +150,7 @@ draftKeys = new Set(), chatMask, chatMaskReady = true, + maskAppliesToUpdate = false, onModeSelected, onChanged }: Props = $props() @@ -901,8 +906,10 @@ // Items with a pending draft are also left out by default: the deployed // version (not the draft) is what moves, so we make the user opt in. // The update direction (parent→fork) is never something the chat caused, so - // when scoped to a chat's items (chatMask set) preselect nothing there. - if (chatMask && !mergeIntoParent) { + // when scoped to a chat's items (chatMask set) preselect nothing there — + // unless the mask came in as an explicit `?items=` deep link, which names + // what to pull. + if (chatMask && !mergeIntoParent && !maskAppliesToUpdate) { selectedItems = [] return } diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 7889a14248..a469efa957 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -111,7 +111,7 @@ import type { FlowBuilderProps } from './flow_builder' import { ModulesTestStates } from './modulesTest.svelte' import FlowAssetsHandler, { initFlowGraphAssetsCtx } from './flows/FlowAssetsHandler.svelte' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from './sessions/openInSessionContext' @@ -1012,7 +1012,7 @@ ) { dropdownItems.push({ label: editInForkLabel(opWorkspace, $userWorkspaces), - onClick: () => window.open(buildForkEditUrl('flow', initialPath)) + onClick: () => openEditInFork('flow', initialPath, opWorkspace) }) } } diff --git a/frontend/src/lib/components/OnBehalfOfSelector.svelte b/frontend/src/lib/components/OnBehalfOfSelector.svelte index 651b76f94a..67648936ae 100644 --- a/frontend/src/lib/components/OnBehalfOfSelector.svelte +++ b/frontend/src/lib/components/OnBehalfOfSelector.svelte @@ -28,6 +28,7 @@ - e.detail && loadUsers()}> + e.detail && loadUsers()} +> {#snippet trigger()} @@ -228,56 +259,65 @@ {/snippet} - - -
-
- {#if isTrigger} - Choose the user this trigger will be permissioned as {isDeployment - ? 'in the target workspace' - : 'in this workspace'}. The selected user's permissions will be used when the trigger - fires. - {:else} - Choose the user this {kind} will run on behalf of {isDeployment - ? 'in the target workspace' - : 'in this workspace'}. The selected user's permissions will be used when executing. - {/if} - - Learn more - - -
- - - -
- {#each filteredUsers as user (user.email)} - + {:else} +
+ {#if !usersLoaded} + Loading users… + {:else} + No users found + {/if}
- {#if selected === 'custom' && (customValue === `u/${user.username}` || customValue === user.email)} - - {/if} - - {:else} -
- {#if !usersLoaded} - Loading users… - {:else} - No users found - {/if} -
- {/each} + {/each} +
-
- + + diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 67b289056a..9ce8c17022 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -108,7 +108,7 @@ import WorkerTagSelect from './WorkerTagSelect.svelte' import type { ButtonType } from './common/button/model' import DebounceLimit from './flows/DebounceLimit.svelte' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' import WacExportDrawer from './scripts/WacExportDrawer.svelte' import { UserDraft } from '$lib/userDraft.svelte' @@ -866,7 +866,7 @@ { label: editInForkLabel(opWorkspace, $userWorkspaces), onClick: () => { - window.open(buildForkEditUrl('script', initialPath)) + openEditInFork('script', initialPath, opWorkspace) } } ] diff --git a/frontend/src/lib/components/UpdateDevWorkspaceModal.svelte b/frontend/src/lib/components/UpdateDevWorkspaceModal.svelte new file mode 100644 index 0000000000..c53b1436c8 --- /dev/null +++ b/frontend/src/lib/components/UpdateDevWorkspaceModal.svelte @@ -0,0 +1,348 @@ + + + + {#if pending} +

+ {pending.itemPath} + exists in {pending.prodWorkspaceId} but not in its dev workspace + {pending.devWorkspaceName}. +

+ {#if canDeploy} +

+ Update {pending.devWorkspaceName} with it to edit it there. +

+ {#if sourceOnBehalfOfFailed} +
+ + Its "run on behalf of" user is unknown, so updating could silently reassign the item to + you. Retry from the compare page. + +
+ {:else if targetIdentityUnknown} +
+ + This item needs a "run on behalf of" user and none can be applied without it. Retry from + the compare page. + +
+ {:else if showOnBehalfOf} +
+ Runs on behalf of + { + onBehalfOfChoice = choice + if (details) customOnBehalfOf = details + }} + kind={pending.itemType} + canPreserve={access?.value.canPreserveOnBehalfOf ?? false} + customValue={customOnBehalfOf?.permissionedAs} + aboveConfirmationModal + onPickerOpenChange={(open) => (pickerOpen = open)} + myPermissionedAs={access?.value.me?.permissionedAs} + /> +
+ {#if onBehalfOfUnset} + + You must set the "on behalf of" user before updating + + The "run on behalf of" field defines which user's permissions will be applied during + execution. Make sure this is set to an appropriate user before updating. + + + {/if} + {/if} + {:else if permission} +
+ + {permission.reason} + +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 5acf279357..a57dc87622 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -79,7 +79,7 @@ import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte' import { computeSecretUrl } from './appDeploy.svelte' import { updatePolicy } from './appPolicy' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' interface Props { @@ -1159,7 +1159,7 @@ { label: editInForkLabel($workspaceStore, $userWorkspaces), onClick: () => { - window.open(buildForkEditUrl('app', $appPath)) + openEditInFork('app', $appPath, $workspaceStore) } } ] diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index b739076cee..2081e12e29 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -355,8 +355,12 @@ onblur={bubble('blur')} onmouseenter={bubble('mouseenter')} onmouseleave={bubble('mouseleave')} - onclick={() => { + onclick={(event) => { loading = true + // A link button can still want to intercept its own click (e.g. to resolve + // the real destination first and preventDefault), so `onClick` must run here + // too — the button branch below is not the only one that takes a handler. + onClick?.(event) dispatch('click', event) if (!loadUntilNav) { loading = false diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index 0d798412f9..56244632ef 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -13,6 +13,8 @@ confirmationText: string keyListen?: boolean loading?: boolean + /** Blocks confirming (button and Enter) while a required choice in `children` is unmade. */ + confirmDisabled?: boolean open?: boolean type?: 'danger' | 'reload' | 'info' showIcon?: boolean @@ -31,6 +33,7 @@ confirmationText, keyListen = true, loading = false, + confirmDisabled = false, open = false, type: _type, showIcon = true, @@ -64,16 +67,35 @@ if (event.metaKey || event.ctrlKey || event.altKey) { return } + const popover = (event.target as HTMLElement | null)?.closest?.('[data-popover]') + // Content carries no `aria-controls`; a trigger's resolves only while its content is + // mounted, which is the only reliable open/closed signal — the trigger's own aria state + // is stale because visibility is driven outside melt. + const controls = popover?.getAttribute('aria-controls') + const popoverOpen = !!popover && (!controls || !!document.getElementById(controls)) + switch (event.key) { + // Both keys are gated on the same state as the button they stand for, which is why + // they swallow the event first and only then decide. Ungated, Enter re-enters an + // in-flight confirm and Escape dismisses the modal out from under one — leaving the + // action to finish against a caller that believes it was cancelled. case 'Enter': + // A popover needs Enter both to open from its trigger and to choose from its + // content, so leave it alone whether or not it is open. + if (popover) return event.stopPropagation() event.preventDefault() + if (loading || confirmDisabled) break dispatch('confirmed') onConfirmed?.() break case 'Escape': + // Only an open popover has something to dismiss; on a closed trigger Escape is + // still the dialog's. + if (popoverOpen) return event.stopPropagation() event.preventDefault() + if (loading) break dispatch('canceled') onCanceled?.() break @@ -170,7 +192,7 @@
{/if} {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)} -
- -
+ {/if} {/if} @@ -239,8 +230,10 @@ }, { displayName: editInForkLabel($workspaceStore, $userWorkspaces), - icon: GitFork, - href: buildForkEditUrl(app.raw_app ? 'raw_app' : 'app', path), + icon: Pen, + // No `href`: the handler resolves the destination asynchronously, and a melt + // menu item's anchor navigates before a delegated onclick can preventDefault it. + action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path), hide: $userStore?.operator || isCloudHosted() || diff --git a/frontend/src/lib/components/common/table/EditInForkButton.svelte b/frontend/src/lib/components/common/table/EditInForkButton.svelte new file mode 100644 index 0000000000..fe5c6bc193 --- /dev/null +++ b/frontend/src/lib/components/common/table/EditInForkButton.svelte @@ -0,0 +1,53 @@ + + + +
+ +
diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index df671cc11a..a155ca3a97 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -37,7 +37,8 @@ import FlowHistory from '$lib/components/flows/FlowHistory.svelte' import InheritedLabels from '$lib/components/InheritedLabels.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' + import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' interface Props { @@ -195,17 +196,7 @@
{/if} {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)} -
- -
+ {/if} {/if} @@ -256,8 +247,10 @@ }, { displayName: editInForkLabel($workspaceStore, $userWorkspaces), - icon: GitFork, - href: buildForkEditUrl('flow', path), + icon: Pen, + // No `href`: the handler resolves the destination asynchronously, and a melt + // menu item's anchor navigates before a delegated onclick can preventDefault it. + action: (e) => onEditInForkClick(e, 'flow', path), hide: $userStore?.operator || isCloudHosted() || diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 9724decd02..25b557690e 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -47,7 +47,8 @@ import Popover from '$lib/components/Popover.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' + import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' interface Props { @@ -253,17 +254,7 @@ {/if} {/if} {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !script.canWrite)} -
- -
+ {/if} {/if} @@ -336,8 +327,10 @@ }, { displayName: editInForkLabel($workspaceStore, $userWorkspaces), - icon: GitFork, - href: buildForkEditUrl('script', script.path), + icon: Pen, + // No `href`: the handler resolves the destination asynchronously, and a melt + // menu item's anchor navigates before a delegated onclick can preventDefault it. + action: (e) => onEditInForkClick(e, 'script', script.path), hide: $userStore?.operator || isCloudHosted() || diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 42f63054af..52d53242be 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -1727,6 +1727,7 @@ on:rawAppChanged={reloadItemsAndCounts} on:reload={reloadItemsAndCounts} {showCode} + showEditButton={showEditButtons} /> {/key} {:else} diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 4b3d39d05d..cf34998bf2 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -33,6 +33,7 @@ // Position of this node among the rendered root nodes; "expand all" only // auto-loads the first EXPAND_ALL_LOAD_LIMIT of them (see the effect below). rootIndex?: number + showEditButton?: boolean // Path prefix of the parent node, so this one can name its own (`ownerLoad` and // the listing endpoint are both keyed by full prefix). Unset at the top level. parentPrefix?: string @@ -54,6 +55,7 @@ onExpandOwner, onCollapseOwner, rootIndex = 0, + showEditButton = true, parentPrefix, ancestorHasMore = false }: Props = $props() @@ -309,6 +311,7 @@ on:rawAppChanged on:reload {showCode} + {showEditButton} depth={depth + 1} /> {/each} @@ -373,6 +376,7 @@ onExpandOwner?: (owner: string, more?: boolean) => void onCollapseOwner?: (owner: string) => void + showEditButton?: boolean } let { @@ -55,7 +56,8 @@ selfUsername, ownerLoad, onExpandOwner, - onCollapseOwner + onCollapseOwner, + showEditButton = true }: Props = $props() // How many root nodes render at once. A root node is a collapsed owner row that @@ -205,6 +207,7 @@ on:rawAppChanged on:reload {showCode} + {showEditButton} /> {/if} {/each} diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index 629e86a658..f850e53f6c 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -280,8 +280,10 @@ fullScreen ? `${fullScreenHost ? 'absolute' : 'fixed'} !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !resize-none` : 'w-fit', - contentClasses, - `z-[5001]` + // Last so `contentClasses` can raise it: a popover inside a ConfirmationModal has to + // clear that modal's own z-index, which sits above this layer. + `z-[5001]`, + contentClasses )} data-popover {...extraProps} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 926328751c..58329fac6c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -72,7 +72,7 @@ import { AIBtnClasses } from '../copilot/chat/AIButtonStyle' import { stripRawAppDiffNoise } from './utils' import type { RawAppData } from './dataTableRefUtils' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' // async function hash(message) { @@ -892,7 +892,7 @@ { label: editInForkLabel(opWorkspace, $userWorkspaces), onClick: () => { - window.open(buildForkEditUrl('raw_app', appPath)) + openEditInFork('raw_app', appPath, opWorkspace) } } ] diff --git a/frontend/src/lib/utils/editInFork.ts b/frontend/src/lib/utils/editInFork.ts index c446732b7d..4e0344471f 100644 --- a/frontend/src/lib/utils/editInFork.ts +++ b/frontend/src/lib/utils/editInFork.ts @@ -9,8 +9,12 @@ import { } from '$lib/stores' import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' import { isRuleActive, canUserBypassRuleKind } from '$lib/workspaceProtectionRules.svelte' +import { goto } from '$lib/navigation' +import { sendUserToast } from '$lib/toast' +import { checkItemExists } from '$lib/utils_workspace_deploy' +import { updateDevWorkspaceModal } from '$lib/utils/editInForkModal.svelte' -type ItemType = 'script' | 'flow' | 'app' | 'raw_app' +export type ItemType = 'script' | 'flow' | 'app' | 'raw_app' /** * Whether to show the "edit in fork / dev workspace" affordance. Allowed when forking isn't disabled, @@ -64,26 +68,164 @@ function editPathFor(itemType: ItemType, itemPath: string): string { } } -function viewPathFor(itemType: ItemType, itemPath: string): string { - switch (itemType) { - case 'script': - return `${base}/scripts/get/${itemPath}` - case 'flow': - return `${base}/flows/get/${itemPath}` - case 'app': - return `${base}/apps/get/${itemPath}` - case 'raw_app': - return `${base}/apps_raw/get/${itemPath}` +export function buildForkEditUrl(itemType: ItemType, itemPath: string): string { + // When the current ("prod") workspace has a canonical dev workspace, edits are funneled there. + const dev = findCanonicalDevWorkspace(get(workspaceStore), get(userWorkspaces)) + return dev + ? devWorkspaceEditUrl(itemType, itemPath, dev.id) + : forkWorkspaceUrl(itemType, itemPath) +} + +/** Fork-creation flow, coming back to the item's editor once the fork exists. */ +export function forkWorkspaceUrl(itemType: ItemType, itemPath: string): string { + return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPathFor(itemType, itemPath))}` +} + +/** The item's editor in the dev workspace — the target `buildForkEditUrl` produces when a dev exists. */ +export function devWorkspaceEditUrl( + itemType: ItemType, + itemPath: string, + devWorkspaceId: string +): string { + // `?workspace=` switches the workspace store (handled in the logged layout), so the editor + // opens against the dev workspace rather than whichever one the tab was on. + return `${editPathFor(itemType, itemPath)}?workspace=${encodeURIComponent(devWorkspaceId)}` +} + +/** + * A dev workspace can be behind its prod, so the URL built at render time dead-ends on a not-found + * page for any item prod has and dev doesn't. Resolve the destination at click time instead: + * return it when the item is there, else raise the prompt offering to update the dev workspace with + * it and return undefined. Shared by the row buttons and the editors' "Edit in " dropdown + * entries. + */ +let latestResolve = 0 + +async function resolveEditInForkTarget( + itemType: ItemType, + itemPath: string, + prod: string, + dev: UserWorkspace, + openInNewTab = false +): Promise { + const seq = ++latestResolve + const from = { path: window.location.pathname, workspace: get(workspaceStore) } + let exists: boolean + try { + exists = await checkItemExists(itemType, itemPath, dev.id) + } catch { + // Inconclusive — go anyway and let the editor report whatever is actually wrong. + exists = true + } + // Only act if the user is still where they asked from. A later click supersedes this one, and + // navigating or switching workspace abandons it — the modal is layout-global and `goto` is + // unconditional, so a late answer would otherwise hijack whatever they moved on to. + if (seq !== latestResolve) return undefined + if (window.location.pathname !== from.path || get(workspaceStore) !== from.workspace) + return undefined + if (exists) return devWorkspaceEditUrl(itemType, itemPath, dev.id) + updateDevWorkspaceModal.val = { + itemType, + itemPath, + devWorkspaceId: dev.id, + devWorkspaceName: dev.name, + prodWorkspaceId: prod, + openInNewTab + } + return undefined +} + +function currentDevWorkspace( + prodWorkspace?: string +): { prod: string; dev: UserWorkspace } | undefined { + const prod = prodWorkspace ?? get(workspaceStore) + const dev = findCanonicalDevWorkspace(prod, get(userWorkspaces)) + if (!dev || !prod) return undefined + return { prod, dev } +} + +/** + * Click handler for the "Edit in " affordance. Menu entries carry no href — the + * destination is only known after an async probe — so this navigates itself by default. Link + * callers pass `hasHref` so modifier/middle clicks still open the raw href in a new tab, and so the + * no-dev-workspace case is left to the anchor rather than being navigated twice. + */ +export async function onEditInForkClick( + e: Event | undefined, + itemType: ItemType, + itemPath: string, + { hasHref = false }: { hasHref?: boolean } = {} +): Promise { + const click = e as MouseEvent | undefined + if ( + hasHref && + (click?.ctrlKey || click?.metaKey || click?.shiftKey || click?.altKey || click?.button) + ) + return + const target = currentDevWorkspace() + if (!target) { + // Nothing to probe: the destination is the fork-creation flow, which the anchor already points at. + if (!hasHref) await goto(forkWorkspaceUrl(itemType, itemPath)) + return + } + e?.preventDefault() + const url = await resolveEditInForkTarget(itemType, itemPath, target.prod, target.dev) + if (url) await goto(url) +} + +export type ClaimedTab = { show: (url: string) => void; discard: () => void } + +/** + * Take a tab now, to point somewhere once an async step resolves. Safari refuses `window.open` from + * any promise continuation however fast it resolves, so a tab opened after an `await` never appears + * there — it has to be claimed inside the click's own transient activation. `discard` releases it + * when the answer turns out to be "nowhere to go". Returns undefined if the popup was blocked, which + * leaves the caller to decide between a late `window.open` and saying so. + */ +export function claimTab(): ClaimedTab | undefined { + const tab = window.open('about:blank') + if (!tab) return undefined + return { + show: (url: string) => { + tab.location.href = url + }, + discard: () => tab.close() } } -export function buildForkEditUrl(itemType: ItemType, itemPath: string): string { - // When the current ("prod") workspace has a canonical dev workspace, edits are funneled there: - // land on the item's page in the dev workspace (not straight in the editor) so the workspace - // switch is legible and the user opens the editor deliberately from there. - const dev = findCanonicalDevWorkspace(get(workspaceStore), get(userWorkspaces)) - if (dev) { - return `${viewPathFor(itemType, itemPath)}?workspace=${encodeURIComponent(dev.id)}` +/** + * "Edit in " from an editor's dropdown, which opens a new tab rather than navigating + * away from work in progress. `prodWorkspace` is the workspace the editor is operating on, which in + * a session pane is not the one the navigation store holds — pass the same value the surrounding + * `editInForkAllowed` / `editInForkLabel` are given, so the action can't resolve against a different + * workspace than the label above it names. + */ +export async function openEditInFork( + itemType: ItemType, + itemPath: string, + prodWorkspace?: string +): Promise { + const target = currentDevWorkspace(prodWorkspace) + if (!target) { + // No dev workspace to probe for: the destination is the fork-creation flow. + if (!window.open(forkWorkspaceUrl(itemType, itemPath))) { + sendUserToast('Allow popups to fork this workspace', true) + } + return + } + // Navigating in place would throw away whatever this editor is holding — the whole reason this + // entry opens a tab. The cost is a blank tab that flashes and closes when the item turns out to + // be missing and the prompt takes over; the prompt then opens its own tab on confirm. + const tab = claimTab() + const url = await resolveEditInForkTarget(itemType, itemPath, target.prod, target.dev, true) + if (!url) { + // Superseded, abandoned, or answered by the prompt in the original tab — nothing to show. + tab?.discard() + return + } + if (tab) { + tab.show(url) + } else if (!window.open(url)) { + sendUserToast(`Allow popups to open ${itemPath} in ${target.dev.name}`, true) } - return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPathFor(itemType, itemPath))}` } diff --git a/frontend/src/lib/utils/editInForkModal.svelte.ts b/frontend/src/lib/utils/editInForkModal.svelte.ts new file mode 100644 index 0000000000..a331ba23ab --- /dev/null +++ b/frontend/src/lib/utils/editInForkModal.svelte.ts @@ -0,0 +1,28 @@ +import { createState } from '$lib/svelte5Utils.svelte' +import type { StateStore } from '$lib/utils' +import type { ItemType } from './editInFork' + +/** + * An "Edit in " click that landed on an item the dev workspace + * doesn't have yet. Held globally so the confirmation renders once in the logged + * layout instead of per item row. + */ +export type UpdateDevWorkspaceModalState = { + itemType: ItemType + itemPath: string + devWorkspaceId: string + devWorkspaceName: string + prodWorkspaceId: string + /** + * The click that raised this came from an editor's dropdown, which opens a tab rather than + * navigating away from work in progress. Answering it has to keep that promise: without this + * the "item is present" branch opens a tab while the "item is missing" branch — this prompt — + * would leave the editor the user was told would be preserved. + */ + openInNewTab?: boolean +} + +export let updateDevWorkspaceModal: StateStore = + createState({ + val: undefined + }) diff --git a/frontend/src/lib/utils_workspace_deploy.test.ts b/frontend/src/lib/utils_workspace_deploy.test.ts index 41ea2d1af0..08a877d012 100644 --- a/frontend/src/lib/utils_workspace_deploy.test.ts +++ b/frontend/src/lib/utils_workspace_deploy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { + checkPathWritePermission, diffActionableInDirection, diffCreatesInTarget, diffRemovesInTarget @@ -68,3 +69,50 @@ describe('deploy direction of a one-sided diff row', () => { expect(diffRemovesInTarget(bothSides, false)).toBe(false) }) }) + +describe('per-item write permission in the deploy target', () => { + const member = { is_admin: false, username: 'alice', folders: ['shared'] } + const never = async () => { + throw new Error('folder probe should not run') + } + + it('lets a workspace admin write anywhere', async () => { + const admin = { is_admin: true, username: 'root', folders: [] } + expect(await checkPathWritePermission('dev', 'u/someone/x', admin, never)).toEqual({ ok: true }) + expect(await checkPathWritePermission('dev', 'f/locked/x', admin, never)).toEqual({ ok: true }) + }) + + it('allows a user their own path and refuses someone else’s', async () => { + expect(await checkPathWritePermission('dev', 'u/alice/x', member, never)).toEqual({ ok: true }) + const refused = await checkPathWritePermission('dev', 'u/bob/x', member, never) + expect(refused.ok).toBe(false) + expect(refused.reason).toContain('u/bob') + }) + + it('allows a folder in the write set without probing for it', async () => { + expect(await checkPathWritePermission('dev', 'f/shared/x', member, never)).toEqual({ ok: true }) + }) + + it('refuses a folder that exists in the target but is not writable', async () => { + const refused = await checkPathWritePermission('dev', 'f/locked/x', member, async () => true) + expect(refused.ok).toBe(false) + expect(refused.reason).toContain('locked') + }) + + // The two fail-open paths. Turning either into a refusal would block a deploy the server + // would have accepted, so they are asserted rather than left to the `catch` reading as dead. + it('allows a folder the target does not have yet, since the deploy creates it', async () => { + expect( + await checkPathWritePermission('dev', 'f/brand_new/x', member, async () => false) + ).toEqual({ ok: true }) + }) + + it('allows when the folder probe itself fails', async () => { + const probeFailed = async () => { + throw new Error('network') + } + expect(await checkPathWritePermission('dev', 'f/locked/x', member, probeFailed)).toEqual({ + ok: true + }) + }) +}) diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index b9a397a32f..c330a2210b 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -14,10 +14,12 @@ import { ScheduleService, ScriptService, SqsTriggerService, + GroupService, UserService, VariableService, WebsocketTriggerService, - WorkspaceService + WorkspaceService, + type User } from '$lib/gen' import { fetchProtectionRulesForWorkspace, @@ -167,6 +169,16 @@ function legacyTriggerKind(kind: TriggerDeployKind) { return map[kind] } +/** An identity in both formats the app policy stores it in. */ +export type AppIdentity = { email: string; permissionedAs: string } + +/** + * Set when a create-only deploy was refused because the target turned out to already have the + * item. Carried on an object rather than matched out of the error text: `deployItem` swallows + * every throw into `{ success: false, error }`, so the flag is the only reliable signal. + */ +export type DeployConflict = { hit: boolean } + /** * `deployItem` overrides only the email half of the identity, while the body it builds * spreads the *source* item — which carries the source workspace's permissioned_as, valid @@ -176,11 +188,26 @@ function legacyTriggerKind(kind: TriggerDeployKind) { * clears it too, but this app consumes the published package, so the clear has to exist * on both sides until that version ships. */ -function makeProvider(onBehalfOfPrincipal?: string): DeployProvider { +function makeProvider( + onBehalfOfPrincipal?: string, + appIdentity?: AppIdentity, + /** + * Refuse the writes the shared `deployItem` reaches for only when the item already exists in + * the target, turning its silent switch to an update into a failure the caller can act on. + * The three below are exactly its `alreadyExists` branches: a flow and an app are replaced + * outright, and a script is given the target's head as `parent_hash`, which is what makes an + * otherwise identical `createScript` an update. + */ + conflict?: DeployConflict +): DeployProvider { const withPermissionedAs = >(requestBody: T): T => ({ ...requestBody, on_behalf_of: onBehalfOfPrincipal }) + const refuseUpdate = (): never => { + if (conflict) conflict.hit = true + throw new Error('item already exists in the target workspace') + } return { existsFlowByPath: (p) => FlowService.existsFlowByPath(p), existsScriptByPath: (p) => ScriptService.existsScriptByPath(p), @@ -193,17 +220,36 @@ function makeProvider(onBehalfOfPrincipal?: string): DeployProvider { createFlow: (p) => FlowService.createFlow({ ...p, requestBody: withPermissionedAs(p.requestBody) }), updateFlow: (p) => - FlowService.updateFlow({ ...p, requestBody: withPermissionedAs(p.requestBody) }), + conflict + ? refuseUpdate() + : FlowService.updateFlow({ ...p, requestBody: withPermissionedAs(p.requestBody) }), archiveFlowByPath: (p) => FlowService.archiveFlowByPath(p), getScriptByPath: (p) => ScriptService.getScriptByPath(p), createScript: (p) => - ScriptService.createScript({ ...p, requestBody: withPermissionedAs(p.requestBody) }), + conflict && p.requestBody.parent_hash + ? refuseUpdate() + : ScriptService.createScript({ ...p, requestBody: withPermissionedAs(p.requestBody) }), archiveScriptByPath: (p) => ScriptService.archiveScriptByPath(p), - getAppByPath: (p) => AppService.getAppByPath(p), + // An app's identity lives in its policy, and the shared deploy forwards the source policy + // untouched — it only turns `onBehalfOf` into `preserve_on_behalf_of: true`. Rewriting the + // policy on the way out is therefore the only way a chosen identity reaches the target; the + // backend honours it (`should_preserve` requires `policy.on_behalf_of.is_some()`). + getAppByPath: async (p) => { + const app = await AppService.getAppByPath(p) + if (!appIdentity) return app + return { + ...app, + policy: { + ...app.policy, + on_behalf_of: appIdentity.permissionedAs, + on_behalf_of_email: appIdentity.email + } + } + }, createApp: (p) => AppService.createApp(p), - updateApp: (p) => AppService.updateApp(p), + updateApp: (p) => (conflict ? refuseUpdate() : AppService.updateApp(p)), createAppRaw: (p) => AppService.createAppRaw(p), - updateAppRaw: (p) => AppService.updateAppRaw(p), + updateAppRaw: (p) => (conflict ? refuseUpdate() : AppService.updateAppRaw(p)), getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p), getRawAppData: (p) => AppService.getRawAppData(p), deleteApp: (p) => AppService.deleteApp(p), @@ -284,12 +330,19 @@ export interface DeployItemParams { */ onBehalfOf?: string /** - * Authorization half of `onBehalfOf` for flows/scripts (u/username or g/group). - * Must name the same identity as `onBehalfOf`. Set it only when the user picked a - * specific user; undefined clears the key, leaving the backend to derive the target - * workspace's own principal from `onBehalfOf`. + * Authorization half of `onBehalfOf` (u/username or g/group). Must name the same identity as + * `onBehalfOf`. Set it only when the user picked a specific user; undefined clears the key, + * leaving the backend to derive the target workspace's own principal from `onBehalfOf`. Apps + * additionally need it in the policy, which holds both formats — see `makeProvider`. */ onBehalfOfPrincipal?: string + /** + * Fail instead of overwriting when the target turns out to already have the item. The shared + * deploy re-probes and silently switches to an update, so a caller that only means to create — + * one acting on the item being absent — has to say so or it will overwrite whoever got there + * between the two probes. The result then carries `conflict`. + */ + createOnly?: boolean } /** @@ -297,7 +350,9 @@ export interface DeployItemParams { * `DeployKind` union plus the legacy generic `'trigger'` from `DeployWorkspace.svelte`, * which carries its sub-kind in `additionalInformation`. */ -export async function deployItem(params: DeployItemParams): Promise { +export async function deployItem( + params: DeployItemParams +): Promise { const { kind, path, @@ -305,7 +360,8 @@ export async function deployItem(params: DeployItemParams): Promise { + const provider = makeProvider() + if (kind === 'flow') return (await provider.getFlowByPath({ workspace, path })).on_behalf_of_email + if (kind === 'script') + return (await provider.getScriptByPath({ workspace, path })).on_behalf_of_email + return (await provider.getAppByPath({ workspace, path })).policy?.on_behalf_of_email +} + +/** + * Every workspace group, not just the first page. + * + * `listGroupNames` would be the obvious call but unions in instance groups, which folder rules do + * not resolve against — a same-named instance group would let an unusable rule through. `listGroups` + * reads the workspace's own `group_` rows, which is what the server checks, but it paginates: a + * group missed here reads as "no account in the target", which now refuses a folder copy outright. + */ +async function workspaceGroupNames(workspace: string): Promise> { + const PER_PAGE = 1000 + const names = new Set() + // Stops on a short page; the size check is the backstop for a server that ignores `page`. + for (let page = 1; page <= 50; page++) { + const batch = await GroupService.listGroups({ workspace, page, perPage: PER_PAGE }) + const before = names.size + batch.forEach((g) => names.add(g.name)) + if (batch.length < PER_PAGE || names.size === before) break + } + return names +} + +/** + * Resolve a source-workspace principal into the same person or group as the target names them. + * + * A `u/` is workspace-local: the same username in the target can be a different account, + * so copying one verbatim can hand a folder — or an item's execution identity — to a namesake. Email + * is the only identifier stable across workspaces, so users go source username -> email -> target + * username, and anyone without an account there resolves to undefined for the caller to deal with. + */ +async function principalTranslator(workspaceFrom: string, workspaceTo: string) { + const [fromUsers, toUsers, targetGroups] = await Promise.all([ + // `list_users` is unpaginated, unlike the group listing below. + UserService.listUsers({ workspace: workspaceFrom }), + UserService.listUsers({ workspace: workspaceTo }), + workspaceGroupNames(workspaceTo) + ]) + const emailOfSourceUsername = new Map(fromUsers.map((u) => [u.username, u.email])) + const targetUsernameOfEmail = new Map(toUsers.map((u) => [u.email, u.username])) + + /** The same principal as `workspaceTo` names it, or undefined when it has no account there. */ + return (principal: string): string | undefined => { + if (principal.startsWith('u/')) { + const email = emailOfSourceUsername.get(principal.slice(2)) + const username = email ? targetUsernameOfEmail.get(email) : undefined + return username ? `u/${username}` : undefined + } + if (principal.startsWith('g/')) { + return targetGroups.has(principal.slice(2)) ? principal : undefined + } + // An email is already workspace-independent; it only has to name someone there. + return targetUsernameOfEmail.has(principal) ? principal : undefined + } +} + +export type CreateFolderResult = DeployResult & { + /** Access dropped because its principal has no account in the target, if any. */ + droppedAccess?: string[] +} + +/** + * Copy a folder into `workspaceTo`, creating it and never updating it. + * + * `deployItem` re-probes and switches to `updateFolder` when the folder turns out to exist, which + * would replace its owners and ACL with the source's. For a folder the user asked to deploy that is + * the point; for one created on their behalf to give an item somewhere to land it would silently + * rewrite the permissions of a folder someone else just created. Losing that race is success here — + * the folder exists, which is all the caller needed. + * + * Every principal is translated into the target's own naming (see `principalTranslator`), and the + * two kinds of unresolvable principal are treated differently because they fail differently: + * + * - an **owner or ACL entry** with no account in the target is dropped. The folder ends up more + * restrictive than its source, never less, and `create_folder` makes the caller an owner, so + * nobody is locked out of what they just created. + * - an **identity rule** with no account in the target refuses the whole copy. Dropping it would + * leave the folder applying no rule where the source applied one, so an item landing inside runs + * as whoever deployed it — the silent substitution this prompt exists to prevent — and carrying + * it verbatim is worse still: the server validates a rule's shape at folder-create time but its + * principal's existence at item-create time, so the folder would be created and then reject + * every deploy into it, including the retry. + * + * `default_permissioned_as` and `labels` are carried at all, which the shared folder deploy drops. + */ +export async function createFolderIfAbsent( + name: string, + workspaceFrom: string, + workspaceTo: string +): Promise { + try { + const folder = await FolderService.getFolder({ workspace: workspaceFrom, name }) + const rules = folder.default_permissioned_as ?? [] + const owners = folder.owners ?? [] + const acl = Object.entries((folder.extra_perms ?? {}) as Record) + const translate = await principalTranslator(workspaceFrom, workspaceTo) + + const unresolvableRule = rules.map((r) => r.permissioned_as).find((p) => !translate(p)) + if (unresolvableRule) { + return { + success: false, + error: + `f/${name} runs items on behalf of ${unresolvableRule}, which has no account in ` + + `the target workspace. Bring the folder across from the compare page first.` + } + } + + const droppedAccess = [...owners, ...acl.map(([p]) => p)].filter((p) => !translate(p)) + await FolderService.createFolder({ + workspace: workspaceTo, + requestBody: { + name, + owners: owners.map(translate).filter((p): p is string => !!p), + extra_perms: Object.fromEntries( + acl.flatMap(([p, write]) => { + const t = translate(p) + return t ? [[t, write] as const] : [] + }) + ), + summary: folder.summary ?? undefined, + default_permissioned_as: rules.map((r) => ({ + ...r, + permissioned_as: translate(r.permissioned_as)! + })), + labels: folder.labels + } + }) + return { success: true, droppedAccess: droppedAccess.length ? droppedAccess : undefined } + } catch (e) { + // The name conflict a concurrent create produces is not part of the API contract, so ask + // again rather than matching its message. + try { + if (await checkItemExists('folder', `f/${name}`, workspaceTo)) return { success: true } + } catch {} + return { success: false, error: `${e}` } + } +} + export type DeployPermission = { ok: boolean; reason?: string } /** @@ -501,9 +716,13 @@ export type DeployPermission = { ok: boolean; reason?: string } * Fails open on any error — the server still enforces on the actual deploy. * Shared by the session dock and the compare page so both gate identically. */ -export async function checkDeployPermission(workspace: string): Promise { +export async function checkDeployPermission( + workspace: string, + /** Pre-fetched `whoami` for `workspace`, to save a round trip when the caller already has one. */ + whoami?: User +): Promise { try { - const me = await UserService.whoami({ workspace }) + const me = whoami ?? (await UserService.whoami({ workspace })) if (me.operator) { return { ok: false, reason: "You're an operator in this workspace — operators can't deploy" } } @@ -526,3 +745,75 @@ export async function checkDeployPermission(workspace: string): Promise, + folderExists: (folderPath: string) => Promise = (folderPath) => + checkItemExists('folder', folderPath, workspace) +): Promise { + if (me.is_admin) return { ok: true } + const owner = path.match(/^u\/([^/]+)\//)?.[1] + if (owner) { + return owner === me.username + ? { ok: true } + : { + ok: false, + reason: `${path} is owned by u/${owner} — only they or a workspace admin can write there` + } + } + const folder = path.match(/^f\/([^/]+)\//)?.[1] + if (!folder || me.folders?.includes(folder)) return { ok: true } + try { + // A folder the target doesn't have yet is created by the deploy, with the deployer as its + // owner — lacking write access to something that doesn't exist isn't a refusal. + if (!(await folderExists(`f/${folder}`))) return { ok: true } + } catch { + // Inconclusive: let the deploy decide rather than refusing on a failed probe. + return { ok: true } + } + return { ok: false, reason: `You don't have write access to folder ${folder}` } +} + +export type DeployTargetAccess = { + permission: DeployPermission + /** Whether the user may hand the item an identity other than their own. */ + canPreserveOnBehalfOf: boolean + /** The caller as `workspace` knows them — usernames are per-workspace, emails are not. */ + me?: AppIdentity +} + +/** + * What the target workspace says about landing one item in it: the workspace-level gate, write + * access to the item's path, and whether another identity may be preserved. Bundled so one `whoami` + * answers all of it, and so a refusal is known before the deploy rather than as a 403 on confirm. + */ +export async function checkItemDeployAccess( + workspace: string, + path: string +): Promise { + let me: User + try { + me = await UserService.whoami({ workspace }) + } catch { + return { permission: { ok: true }, canPreserveOnBehalfOf: false } + } + const workspaceLevel = await checkDeployPermission(workspace, me) + return { + permission: workspaceLevel.ok + ? await checkPathWritePermission(workspace, path, me) + : workspaceLevel, + canPreserveOnBehalfOf: me.is_admin || (me.groups ?? []).includes('wm_deployers'), + me: { email: me.email, permissionedAs: `u/${me.username}` } + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 2781d130ec..a88184ed62 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -20,6 +20,7 @@ import { SIDEBAR_BG, SIDEBAR_BG_DARK } from '$lib/components/sidebar/sidebarChrome' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' import ForkConflictModal from '$lib/components/ForkConflictModal.svelte' + import UpdateDevWorkspaceModal from '$lib/components/UpdateDevWorkspaceModal.svelte' import { enterpriseLicense, isPremiumStore, @@ -1400,6 +1401,8 @@ + + onEditInForkClick(e, 'flow', flow.path, { hasHref: true }), unifiedSize: 'md', variant: !showEditButtons ? 'default' : 'subtle', - startIcon: GitFork + startIcon: Pen } }) } diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index f542a79d99..d5ff28ccca 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -67,7 +67,8 @@ // merged toggle (CompareModeToggle, rendered inside each card) reports its // selection here; the page only swaps which comparison component is shown. // `?dir=update` opens on the other one, for callers that already know which - // direction has something in it (the fork banner's CTA). + // direction has something in it (the fork banner's CTA, the "not in the dev + // workspace yet" prompt). let forkDirection = $state<'deploy_to' | 'update'>( page.url.searchParams.get('dir') === 'update' ? 'update' : 'deploy_to' ) @@ -458,6 +459,7 @@ {draftKeys} {chatMask} {chatMaskReady} + maskAppliesToUpdate={urlItemsMask !== undefined} onChanged={refreshCounts} onModeSelected={selectMode} /> diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 740371d021..faa019253c 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -37,7 +37,6 @@ Code2, ClipboardCopy, GitBranch, - GitFork, EllipsisVertical, Share2 } from 'lucide-svelte' @@ -104,7 +103,12 @@ import { useNestedRestartState } from '$lib/components/useNestedRestartState.svelte' import JobOtelTraces from '$lib/components/JobOtelTraces.svelte' import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { + buildForkEditUrl, + editInForkAllowed, + editInForkLabel, + onEditInForkClick + } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' let job: (Job & { result?: any; result_stream?: string }) | undefined = $state() let jobUpdateLastFetch: Date | undefined = $state() @@ -892,11 +896,12 @@ {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} {editInForkLabel($workspaceStore, $userWorkspaces)} {/if} {/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index bb8a38dde6..04c16c0acb 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -91,7 +91,12 @@ import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte' import { Triggers } from '$lib/components/triggers/triggers.svelte' import { page } from '$app/state' - import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' + import { + buildForkEditUrl, + editInForkAllowed, + editInForkLabel, + onEditInForkClick + } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' import WacDiagram from '$lib/components/graph/WacDiagram.svelte' @@ -465,9 +470,10 @@ label: editInForkLabel($workspaceStore, $userWorkspaces), buttonProps: { href: buildForkEditUrl('script', script.path), + onClick: (e: Event | undefined) => onEditInForkClick(e, 'script', script.path, { hasHref: true }), unifiedSize: 'md', variant: !showEditButtons ? 'default' : 'subtle', - startIcon: GitFork + startIcon: Pen } }) } diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 3db5efcf60..6fbffda21d 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -40,10 +40,22 @@ "Client got disposed and can't be restarted." ] + // The only load of the workspace list for the whole session, and an empty `$userWorkspaces` + // degrades silently rather than erroring — the edit-in-dev affordance, the no-direct-deploy + // alert and the fork banner all quietly lose their dev workspace. Retry rather than strand + // the tab in that state. + const WORKSPACE_LIST_RETRY_DELAYS_MS = [1000, 3000, 8000] async function setUserWorkspaceStore() { - const list = await WorkspaceService.listUserWorkspaces() - $usersWorkspaceStore = list - return list + for (let attempt = 0; ; attempt++) { + try { + $usersWorkspaceStore = await WorkspaceService.listUserWorkspaces() + return + } catch (e) { + if (attempt >= WORKSPACE_LIST_RETRY_DELAYS_MS.length) throw e + console.error('could not load workspace list, retrying', e) + await new Promise((r) => setTimeout(r, WORKSPACE_LIST_RETRY_DELAYS_MS[attempt])) + } + } } // A fork deleted remotely while the tab was open leaves the client pointing at a From 3beb0b9496df18c16c34ce467f65595a1e312bd4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 13:51:43 +0000 Subject: [PATCH 177/400] start python debug sessions whose script has third-party imports (#10537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(debugger): return PrepareResult when the service prepared the venv `prepare_dependencies()` returns `PrepareResult` on every path except the service-prepared short-circuit, which returned the venv path as a bare `str`. `handle_launch` reads `prepared.error` on it, so every Python session whose script has a third-party import raised `AttributeError`, hung, and failed at 180s with `Debugpy command timeout: launch`. The two consumers of a prepare-deps response also read a `stderr` key the CLI does not emit; the field is `install_stderr`, and it carries the same text `error` already wraps in a sentence, so take one rather than joining both. Co-Authored-By: Claude Opus 5 (1M context) * fix(debugger): keep the failing step in the launch message, drop the dead branch Preferring the raw `install_stderr` made `_first_line` pick uv's opening progress line, so a refused launch reported "Using Python 3.12.13 environment at: venv" — which reads like success. `error` is the same text prefixed with the step that failed, so it is the better of the two to condense. The installer-diagnostics pass over a `success: true` response is unreachable: every `success: true` site in prepare_deps.rs sets `install_stderr: None`, and its comment claimed the opposite of what that file documents. It existed to work around a producer that warned and returned success on a failed `uv pip install`; that producer now returns `success: false`. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- debugger/dap_websocket_server.py | 69 +++++--------------------------- 1 file changed, 11 insertions(+), 58 deletions(-) diff --git a/debugger/dap_websocket_server.py b/debugger/dap_websocket_server.py index 7388c8e76e..33937d9617 100644 --- a/debugger/dap_websocket_server.py +++ b/debugger/dap_websocket_server.py @@ -301,56 +301,16 @@ def _prepare_error_detail(response: dict) -> str: """ Build the failure reason from a prepare-deps response. - `stderr` carries the installer's own output and is only present on newer workers, so - fall back to `error` alone when it is missing. + `error` is the installer's own output prefixed with the step that failed, and + `install_stderr` is that same output unprefixed, so take one rather than both: joining + them prints the installer's output twice, and the prefix is what tells a reader whether + the venv or the install was what went wrong. """ - parts = [ - str(response[key]).strip() - for key in ("error", "stderr") - if response.get(key) and str(response[key]).strip() - ] - return "\n".join(parts) or "unknown error" - - -# Prefixes uv uses for routine resolve/install progress, which it writes to stderr on a -# perfectly successful run. `warning:` belongs here because uv's warnings are non-fatal by -# construction (the hardlink fallback fires whenever the cache and the venv are on -# different filesystems, which is the normal layout). The `+`/`-` forms are the -# per-package change list. -_INSTALLER_PROGRESS_PREFIXES = ( - "resolved ", - "prepared ", - "installed ", - "uninstalled ", - "downloading ", - "downloaded ", - "building ", - "built ", - "updated ", - "audited ", - "using ", - "creating ", - "warning:", - "+ ", - "- ", -) - - -def _installer_diagnostics(stderr: str) -> str: - """ - Strip an installer's routine progress from its stderr, keeping anything unexplained. - - uv renders failures several ways (`error:`, `× No solution found` with tree glyphs), so - matching failure shapes misses some of them. Matching progress instead errs toward a - spurious warning rather than toward the silence this exists to prevent. All of this - goes away once the response carries an explicit failure flag to key on. - """ - kept = [ - line - for line in stderr.splitlines() - if line.strip() and not line.strip().lower().startswith(_INSTALLER_PROGRESS_PREFIXES) - ] - return "\n".join(kept).strip() + for key in ("error", "install_stderr"): + detail = str(response.get(key) or "").strip() + if detail: + return detail + return "unknown error" def _first_line(detail: str, limit: int = 300) -> str: @@ -398,7 +358,7 @@ class DebugSession: # The debug service installs dependencies itself so that the registry credentials # the CLI needs never enter this interpreter, which executes the debugged script. logger.info(f"Using dependencies prepared by the debug service: {self._prepared_venv_path}") - return self._prepared_venv_path + return PrepareResult(venv_path=self._prepared_venv_path) if not self.windmill_path: logger.info("No windmill binary path configured, skipping dependency preparation") @@ -469,14 +429,7 @@ class DebugSession: else: logger.info("No external dependencies detected in code") - # `uv pip install` failing for individual packages does not fail the whole - # response, so a "successful" preparation can still carry the reason an import - # is about to fail. - installer_error = _installer_diagnostics(str(response.get("stderr") or "")) - if installer_error: - logger.warning(f"prepare-deps reported an installer error: {installer_error}") - - return PrepareResult(venv_path=venv_path, error=installer_error or None) + return PrepareResult(venv_path=venv_path) except subprocess.TimeoutExpired: message = f"prepare-deps timed out after {PREPARE_DEPS_TIMEOUT_SECONDS}s" From d9b10e7b0a1ba6702ecf05f64b80ac8bb7c79326 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 5 Aug 2026 15:53:48 +0200 Subject: [PATCH 178/400] fix(ai): collapse thinking to a status row with a thought-for duration (#10515) * refactor(ai): render thinking blocks with the shared tool-call card Co-Authored-By: Claude Opus 5 (1M context) * feat(ai): collapse thinking to a status row with a thought-for duration Co-Authored-By: Claude Opus 5 (1M context) * refactor(ai): separate reasoning-timing reset from duration read Co-Authored-By: Claude Opus 5 (1M context) * fix(ai): render expanded thinking in the body font, not mono Co-Authored-By: Claude Opus 5 (1M context) * feat(ai): mark in-progress chat rows with a shimmer sweep Thinking and tool calls both announced themselves with a spinner, which carried no more information than the row already did and read as visual noise once several tools ran in sequence. A white copy of the label now sits over the coloured one and is revealed through a travelling band, so a running row is marked by motion across its own text rather than by a separate glyph. Both spinners and the brain icon are gone, leaving the card with no icon slot at all, and every header label settles on text-secondary. Co-Authored-By: Claude Opus 5 (1M context) * fix(ai): keep a running row marked under reduced motion The shimmer is the only thing distinguishing a running tool row from a settled one, and the reduced-motion rule removed it outright, so the two became identical for those users. The band now degrades to a flat wash instead of disappearing. Also covers the reasoning-duration state machine: that thinking stops at the first answer token rather than at the end of the turn, and that each reasoning pass of a tool-using turn is timed from scratch. Co-Authored-By: Claude Opus 5 (1M context) * test(ai): restore the clock spy after the reasoning-duration tests The file-level hook only clears call records, so the Date.now spy stayed installed and would freeze time for anything appended after this block. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../lib/components/copilot/chat/AIChat.svelte | 7 +- .../copilot/chat/AIChatManager.svelte.ts | 63 ++++- .../copilot/chat/AIChatManager.test.ts | 89 ++++++- .../copilot/chat/AIChatModelSettings.svelte | 14 + .../copilot/chat/AssistantMessage.svelte | 79 +++--- .../copilot/chat/ChatCollapsibleCard.svelte | 155 ++++++++++++ .../copilot/chat/ToolExecutionDisplay.svelte | 239 ++++++++---------- .../src/lib/components/copilot/chat/shared.ts | 3 + .../chat/thinkingPreferences.svelte.ts | 19 ++ 9 files changed, 492 insertions(+), 176 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte create mode 100644 frontend/src/lib/components/copilot/chat/thinkingPreferences.svelte.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 038751f4b6..2a51b1b2e5 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -149,7 +149,12 @@ { role: 'assistant', content: aiChatManager.currentReply, - ...(aiChatManager.currentReasoning ? { reasoning: aiChatManager.currentReasoning } : {}), + ...(aiChatManager.currentReasoning + ? { + reasoning: aiChatManager.currentReasoning, + reasoningDurationMs: aiChatManager.currentReasoningDurationMs + } + : {}), streaming: true, contextElements: aiChatManager.contextManager .getSelectedContext() diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 4bfb6f90df..5e5f006e7c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -428,6 +428,44 @@ export class AIChatManager { // a scalar: several workspace/provider pairs can be unavailable at once, and // the chat loop only notifies on first detection per pair. private reasoningSummaryUnavailableFor = $state([]) + // Timed off arrival, not off the typewriter: the reveal paces *display*, so + // reading the clock there would report how long the text took to paint. + private reasoningStartedAt: number | undefined + private reasoningEndedAt: number | undefined + /** Set the moment thinking ends, which is mid-turn — the answer is still + * streaming. Reactive so the live message settles to "Thought for X" then, + * rather than waiting for the turn to finalize. */ + currentReasoningDurationMs = $state(undefined) + + private markReasoningStarted() { + if (this.reasoningStartedAt === undefined) { + this.reasoningStartedAt = Date.now() + this.currentReasoningDurationMs = undefined + } + } + + /** Thinking ends at the first answer token; a turn that thinks straight into a + * tool call ends it at the message boundary instead. */ + private markReasoningEnded() { + if (this.reasoningStartedAt !== undefined && this.reasoningEndedAt === undefined) { + this.reasoningEndedAt = Date.now() + this.currentReasoningDurationMs = this.reasoningEndedAt - this.reasoningStartedAt + } + } + + private resetReasoningTiming() { + this.reasoningStartedAt = undefined + this.reasoningEndedAt = undefined + this.currentReasoningDurationMs = undefined + } + + /** Reads the duration and clears it, so the next reasoning pass of the same + * turn (after a tool call) times itself from scratch. */ + private takeReasoningDuration(): number | undefined { + const duration = this.currentReasoningDurationMs + this.resetReasoningTiming() + return duration + } private reasoningSummaryKey(provider: string): string { return `${this.operatingWorkspace ?? ''}:${provider}` @@ -2931,6 +2969,7 @@ export class AIChatManager { this.currentReply = '' this.currentReasoning = '' this.currentReasoningActive = false + this.resetReasoningTiming() // Compaction trigger. Without a known context window there is no limit // to enforce, so compaction stays off rather than guessing one. @@ -2996,9 +3035,19 @@ export class AIChatManager { messages: [...this.messages], abortController: this.abortController, callbacks: { - onNewToken: (token) => this.replyReveal.push(token), - onReasoningDelta: (token) => this.reasoningReveal.push(token), - onReasoningStart: () => (this.currentReasoningActive = true), + onNewToken: (token) => { + this.markReasoningEnded() + this.replyReveal.push(token) + }, + // Not every provider fires onReasoningStart, so deltas start the clock too. + onReasoningDelta: (token) => { + this.markReasoningStarted() + this.reasoningReveal.push(token) + }, + onReasoningStart: () => { + this.markReasoningStarted() + this.currentReasoningActive = true + }, onMessageEnd: () => { // Drain any un-revealed backlog into currentReply first, so the reads // below see the full text. This funnel covers clean completion, tool @@ -3006,6 +3055,10 @@ export class AIChatManager { // keeps text from being lost or duplicated on any exit path. this.replyReveal.flush() this.reasoningReveal.flush() + // A turn that reasoned straight into a tool call never saw an answer + // token, so this is where its thinking stops. + this.markReasoningEnded() + const reasoningDurationMs = this.takeReasoningDuration() // Keep the streamed text for the abort/error paths. Non-empty only: // parsers flush (and reset) when a tool call starts after text, and // the catch's later empty call would wipe it — stale keeps are @@ -3019,7 +3072,9 @@ export class AIChatManager { { role: 'assistant', content: this.currentReply, - ...(this.currentReasoning ? { reasoning: this.currentReasoning } : {}), + ...(this.currentReasoning + ? { reasoning: this.currentReasoning, reasoningDurationMs } + : {}), contextElements: this.mode === AIMode.SCRIPT ? oldSelectedContext.filter((c) => c.type === 'code') diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 4e60c35ef3..66b441a8c5 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FlowAIChatHelpers } from './flow/core' import type { PipelineAIChatHelpers } from './pipeline/core' import type { CurrentEditor } from '$lib/components/flows/types' @@ -3003,3 +3003,90 @@ describe('AIChatManager.waitForPipelineHelpers', () => { await expect(manager.waitForPipelineHelpers(10)).resolves.toBe(false) }) }) + +describe('AIChatManager reasoning duration', () => { + beforeEach(() => { + localStorage.clear() + mocks.getCurrentModel.mockReturnValue({ model: 'test-model', provider: 'openai' }) + }) + + // The file-level hook only clears call records, so the clock spy below would + // stay installed and freeze time for anything that runs after it. + afterEach(() => { + nowSpy?.mockRestore() + nowSpy = undefined + }) + + let nowSpy: ReturnType | undefined + + function assistantDurations(manager: AIChatManager): (number | undefined)[] { + return manager.displayMessages + .filter((m) => m.role === 'assistant') + .map((m) => (m as { reasoningDurationMs?: number }).reasoningDurationMs) + } + + it('stops the clock at the first answer token, not at the end of the turn', async () => { + const manager = new AIChatManager() + manager.changeMode(AIMode.ASK) + manager.setAiChatInput({ restoreInstructions: vi.fn(), focusInput: vi.fn() } as any) + + let now = 1_000 + nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + + vi.mocked(runChatLoop).mockImplementation(async (config) => { + config.callbacks.onReasoningStart?.() + config.callbacks.onReasoningDelta?.('weighing the options') + now += 4_000 + config.callbacks.onNewToken('here is the answer') + // The answer keeps streaming well past the end of thinking; none of it + // may land in the duration. + now += 9_000 + config.callbacks.onMessageEnd() + return { + addedMessages: [], + tokenUsage: {} as any, + lastIterationUsage: null, + hitMaxIterations: false + } + }) + + manager.instructions = 'do a thing' + await manager.sendRequest() + + expect(assistantDurations(manager)).toEqual([4_000]) + }) + + it('times each reasoning pass of a tool-using turn independently', async () => { + const manager = new AIChatManager() + manager.changeMode(AIMode.ASK) + manager.setAiChatInput({ restoreInstructions: vi.fn(), focusInput: vi.fn() } as any) + + let now = 1_000 + nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + + vi.mocked(runChatLoop).mockImplementation(async (config) => { + // First pass reasons straight into a tool call — no answer token, so the + // message boundary is where its thinking stops. + config.callbacks.onReasoningDelta?.('which tool do I need') + now += 3_000 + config.callbacks.onMessageEnd() + // Tool execution must not be billed to either pass. + now += 20_000 + config.callbacks.onReasoningDelta?.('now what does that result mean') + now += 7_000 + config.callbacks.onNewToken('here is the answer') + config.callbacks.onMessageEnd() + return { + addedMessages: [], + tokenUsage: {} as any, + lastIterationUsage: null, + hitMaxIterations: false + } + }) + + manager.instructions = 'do a thing' + await manager.sendRequest() + + expect(assistantDurations(manager)).toEqual([3_000, 7_000]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index 0a911134b6..d38caf8f0a 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -25,6 +25,7 @@ import { base } from '$lib/base' import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte' import { getAiChatManager } from './aiChatManagerContext' + import { thinkingPreferences } from './thinkingPreferences.svelte' import { getReasoningCapability, resolveEffectiveReasoning, @@ -377,6 +378,19 @@
Not supported by this model
{/if} + + + (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)} + > + Always expand thinking + {#if thinkingPreferences.expandByDefault} + + {/if} + {/snippet} diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index 0f3f261f26..787d9ef4fc 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -1,10 +1,9 @@ {#if reasoning} -
- - - {#if reasoningExpanded} -
- -
- {/if} -
+ (reasoningToggled = !reasoningExpanded)} + shimmer={reasoningStreaming} + class="mb-2" + labelClass="truncate" + contentClass="font-main text-secondary {markdownProse.xs}" + > + + {/if} {#if message.content} diff --git a/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte b/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte new file mode 100644 index 0000000000..bb530e7e7c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte @@ -0,0 +1,155 @@ + + +
+ {#snippet labelText()} + + {label} + + {/snippet} + + {#snippet headerButton()} + + {/snippet} + + {#if headerRight} +
+ {@render headerButton()} + {@render headerRight()} +
+ {:else} + {@render headerButton()} + {/if} + + {@render belowHeader?.()} + + {#if expanded && children} +
+ {@render children()} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index a1ee9984e5..754db1aac0 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -1,12 +1,11 @@ {#snippet sectionHeader(title: string)} @@ -153,7 +160,7 @@ -
+
+ {#if suspendNeedsItsOwnContinueToggle} + + This only applies when the step's own code fails. A disapproval or an approval timeout + is not a failure of this step, so it still stops the flow. To continue past those, + turn on "Continue on disapproval/timeout" in the approval settings. + + {/if}
diff --git a/frontend/src/lib/components/graph/flowRunStatus.svelte.ts b/frontend/src/lib/components/graph/flowRunStatus.svelte.ts new file mode 100644 index 0000000000..8f5ee97641 --- /dev/null +++ b/frontend/src/lib/components/graph/flowRunStatus.svelte.ts @@ -0,0 +1,56 @@ +import { getContext, hasContext, setContext } from 'svelte' +import { SvelteMap } from 'svelte/reactivity' +import type { Job } from '$lib/gen' +import type { GraphModuleState } from './model' + +const FLOW_RUN_STATUS_KEY = 'FlowRunStatus' + +export type SuspendStatus = Record + +/** + * Run status is read straight from here by the node and edge renderers rather than + * being carried in their `data`. Baking it into `data` means the only way to show a + * status change is to rebuild every node and edge, which re-runs the sugiyama layout + * and makes xyflow re-measure and re-create the whole graph on every poll. + */ +export class FlowRunStatus { + #moduleStates = new SvelteMap() + flowJob = $state.raw(undefined) + suspendStatus = $state.raw({}) + + getModuleState(id: string | undefined): GraphModuleState | undefined { + return id == undefined ? undefined : this.#moduleStates.get(id) + } + + setModuleStates(next: Record | undefined) { + const incoming = next ?? {} + for (const id of [...this.#moduleStates.keys()]) { + if (!(id in incoming)) { + this.#moduleStates.delete(id) + } + } + // Writing a key invalidates only that key's readers, so one step finishing + // never re-renders the other steps. + for (const [id, state] of Object.entries(incoming)) { + if (this.#moduleStates.get(id) !== state) { + this.#moduleStates.set(id, state) + } + } + } +} + +export function setFlowRunStatusContext(): FlowRunStatus { + const status = new FlowRunStatus() + setContext(FLOW_RUN_STATUS_KEY, status) + return status +} + +/** + * Graphs that never show run status (mini graph, diff viewer) provide no context, so + * every reader has to tolerate its absence. + */ +export function getFlowRunStatusContext(): FlowRunStatus | undefined { + return hasContext(FLOW_RUN_STATUS_KEY) + ? getContext(FLOW_RUN_STATUS_KEY) + : undefined +} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 980aa56f5c..bd133dfeb3 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -1,4 +1,4 @@ -import type { FlowModule, Job, PathScript, RawScript, Script } from '$lib/gen' +import type { FlowModule, PathScript, RawScript, Script } from '$lib/gen' import { type Edge } from '@xyflow/svelte' import { getAllModules, getDependeeAndDependentComponents } from '../flows/flowExplorer' import { dfsByModule } from '../flows/previousResults' @@ -132,7 +132,6 @@ export type InputN = { editMode: boolean isRunning: boolean individualStepTests: boolean - flowJob: Job | undefined showJobStatus: boolean flowHasChanged: boolean chatInputEnabled: boolean @@ -148,11 +147,9 @@ export type ModuleN = { id: string parentIds: string[] eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined testModuleState: ModuleTestState | undefined insertable: boolean editMode: boolean - flowJob: Job | undefined isOwner: boolean assets: AssetWithAltAccessType[] | undefined moduleAction: ModuleActionInfo | undefined @@ -167,7 +164,6 @@ export type FailureModuleN = { id: string module: FlowModule eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined } } @@ -178,7 +174,6 @@ export type BranchAllStartN = { id: string branchIndex: number eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined insertable: boolean branchOne: boolean } @@ -189,7 +184,6 @@ export type BranchAllEndN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined } } @@ -199,7 +193,6 @@ export type ForLoopEndN = { id: string eventHandlers: GraphEventHandlers simplifiedTriggerView: boolean - flowModuleState: GraphModuleState | undefined } } @@ -208,7 +201,6 @@ export type ForLoopStartN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined selectedId: string | undefined editMode: boolean simplifiedTriggerView: boolean @@ -222,7 +214,6 @@ export type ResultN = { success: boolean | undefined eventHandlers: GraphEventHandlers editMode: boolean - job: Job | undefined showJobStatus: boolean } } @@ -244,7 +235,6 @@ export type BranchOneStartN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined selected: boolean insertable: boolean label: string @@ -259,7 +249,6 @@ export type BranchOneEndN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined } } @@ -280,7 +269,6 @@ export type NoBranchN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined branchOne: boolean label: string branchIndex: number @@ -330,7 +318,6 @@ export type AiToolN = { // Tool of a linked agent: its inputs are editable but its structure comes from the resource, // so it can't be deleted here. readOnly?: boolean - flowModuleStates: Record | undefined } } @@ -352,10 +339,7 @@ export type CollapsedGroupN = { autocollapse: boolean | undefined stepCount: number modules: FlowModule[] - flowModuleStates: Record | undefined - flowJob: Job | undefined isOwner: boolean - suspendStatus: Record showNotes: boolean editMode: boolean eventHandlers: GraphEventHandlers @@ -416,6 +400,9 @@ export function graphBuilder( extra: { disableAi: boolean insertable: boolean + // Only for the parts of the graph's shape a run decides: which loop iteration is + // expanded and where the error-handler marker attaches. Everything a step merely + // displays comes from FlowRunStatus, never from here. flowModuleStates: Record | undefined testModuleStates: ModulesTestStates | undefined moduleActions?: Record @@ -428,9 +415,7 @@ export function graphBuilder( isOwner: boolean isRunning: boolean individualStepTests: boolean - flowJob: Job | undefined showJobStatus: boolean - suspendStatus: Record flowHasChanged: boolean chatInputEnabled: boolean additionalAssetsMap?: Record @@ -481,12 +466,10 @@ export function graphBuilder( id: module.id, parentIds: [], eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], testModuleState: extra.testModuleStates?.states?.[module.id], insertable: extra.insertable && !module.id.startsWith('subflow:'), editMode: extra.editMode, isOwner: extra.isOwner, - flowJob: extra.flowJob, assets: getFlowModuleAssets(module, extra.additionalAssetsMap), moduleAction: extra.moduleActions?.[module.id], ...extraData @@ -511,8 +494,7 @@ export function graphBuilder( data: { id: module.id, module, - eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id] + eventHandlers: eventHandlers }, type: 'failureModule', selectable: false @@ -609,7 +591,11 @@ export function graphBuilder( disableMoveIds: options?.disableMoveIds, enableTrigger: sourceId === 'Input', index, - ...extra, + // Only what the edge renderer reads. Anything listed here lands on every edge, + // so a value that changes each poll invalidates the whole edge set and makes + // Svelte re-create each one. + disableAi: extra.disableAi, + isOwner: extra.isOwner, insertable: extra.insertable && !options?.disableInsert && prefix == undefined, shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId) }, @@ -631,7 +617,6 @@ export function graphBuilder( editMode: extra.editMode, isRunning: extra.isRunning, individualStepTests: extra.individualStepTests, - flowJob: extra.flowJob, showJobStatus: extra.showJobStatus, flowHasChanged: extra.flowHasChanged, chatInputEnabled: extra.chatInputEnabled, @@ -672,7 +657,6 @@ export function graphBuilder( eventHandlers: eventHandlers, success: success, editMode: extra.editMode, - job: extra.flowJob, showJobStatus: extra.showJobStatus }, type: 'result' @@ -741,10 +725,7 @@ export function graphBuilder( modules: leafIds .map((id) => moduleMap.get(id)) .filter((m): m is FlowModule => !!m), - flowModuleStates: extra.flowModuleStates, - flowJob: extra.flowJob, isOwner: extra.isOwner, - suspendStatus: extra.suspendStatus, showNotes, editMode: prefix == undefined && extra.editMode, eventHandlers @@ -866,8 +847,7 @@ export function graphBuilder( id: `${module.id}-end`, data: { id: module.id, - eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id] + eventHandlers: eventHandlers }, type: 'branchAllEnd' } @@ -882,7 +862,6 @@ export function graphBuilder( id: module.id, branchIndex: -1, eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], branchOne: false, label: 'No branches' }, @@ -908,7 +887,6 @@ export function graphBuilder( id: module.id, branchIndex: branchIndex, eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], insertable: extra.insertable, branchOne: false }, @@ -954,7 +932,6 @@ export function graphBuilder( simplifiedTriggerView, eventHandlers: eventHandlers, editMode: extra.editMode, - flowModuleState: extra.flowModuleStates?.[module.id], selectedId: extra.selectedId }, type: 'forLoopStart' @@ -975,8 +952,7 @@ export function graphBuilder( data: { id: module.id, eventHandlers: eventHandlers, - simplifiedTriggerView, - flowModuleState: extra.flowModuleStates?.[module.id] + simplifiedTriggerView }, type: 'forLoopEnd' } @@ -1046,7 +1022,6 @@ export function graphBuilder( id: `${module.id}-end`, data: { eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], id: module.id }, type: 'branchOneEnd' @@ -1062,7 +1037,6 @@ export function graphBuilder( eventHandlers: eventHandlers, insertable: extra.insertable, preLabel: undefined, - flowModuleState: extra.flowModuleStates?.[module.id], selected: false, modules: module.value.default }, @@ -1098,7 +1072,6 @@ export function graphBuilder( branchIndex: branchIndex, eventHandlers: eventHandlers, insertable: extra.insertable, - flowModuleState: extra.flowModuleStates?.[module.id], selected: false, modules: branch.modules }, diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 650831a65f..89e7c88c94 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -8,12 +8,13 @@ import { NODE_WITH_WRITE_ASSET_Y_OFFSET } from '../nodes/AssetNode.svelte' import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte' import type { Job } from '$lib/gen' - import type { GraphModuleState } from '../../model' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' import { GROUP_TOP_PADDING } from '$lib/components/graph/compoundLayout' const { useDataflow, showAssets, moveManager } = getGraphContext() + const flowRunStatus = getFlowRunStatusContext() let { id, @@ -39,10 +40,7 @@ enableTrigger: boolean disableAi: boolean disableMoveIds: string[] - flowModuleStates: Record | undefined isOwner: boolean - flowJob: Job | undefined - suspendStatus?: Record shouldOffsetInsertBtnDueToAssetNode?: boolean } } = $props() @@ -75,12 +73,13 @@ // TODO: this is a hack to show the waiting for events indicator on the edge a proper way would be to have a edge state // and handle the edge state in the graph builder let waitingForEvents = $derived( - data?.flowModuleStates?.[data.targetId]?.type === 'WaitingForEvents' || - data?.flowModuleStates?.[`${data.sourceId}-v`]?.type === 'WaitingForEvents' + flowRunStatus?.getModuleState(data.targetId)?.type === 'WaitingForEvents' || + flowRunStatus?.getModuleState(`${data.sourceId}-v`)?.type === 'WaitingForEvents' ) + let flowJob: Job | undefined = $derived(flowRunStatus?.flowJob) let suspendStatus: Record | undefined = $derived( - data?.suspendStatus + flowRunStatus?.suspendStatus ) let centerY = $derived( @@ -132,7 +131,7 @@ - {#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'} + {#if waitingForEvents && flowJob && flowJob.type === 'QueuedJob'}
@@ -146,10 +145,10 @@
- {#if data?.flowJob && data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} + {#if flowJob && flowJob.flow_status?.modules?.[flowJob.flow_status?.step]?.type === 'WaitingForEvents'} diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index 8913aa9070..1876d5af33 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -43,7 +43,7 @@ let computeAIToolNodesCache: | { nodes: (Node & NodeLayout)[] - hasFlowModuleStates: boolean + agentActions: Record linkedAgentTools: Record | undefined ret: ReturnType } @@ -69,6 +69,22 @@ } } + function agentActionsOf( + nodes: (Node & NodeLayout)[], + flowModuleStates: Record | undefined, + insertable: boolean + ): Record { + const actions: Record = {} + // The editor renders the static tool set and ignores the run's actions, so snapshotting + // them there would deep-clone a value that changes every poll and never matches. + if (insertable) return actions + for (const node of nodes) { + if (node.type !== 'module' || node.data.module.value.type !== 'aiagent') continue + actions[node.id] = $state.snapshot(flowModuleStates?.[node.id]?.agent_actions) + } + return actions + } + export function computeAIToolNodes( nodes: (Node & NodeLayout)[], eventHandlers: GraphEventHandlers, @@ -83,7 +99,10 @@ } { if ( computeAIToolNodesCache && - !!flowModuleStates === computeAIToolNodesCache.hasFlowModuleStates && + deepEqual( + agentActionsOf(nodes, flowModuleStates, insertable), + computeAIToolNodesCache.agentActions + ) && deepEqual(nodes.map(getComparableNode), computeAIToolNodesCache.nodes) && deepEqual(linkedAgentTools, computeAIToolNodesCache.linkedAgentTools) ) { @@ -191,8 +210,7 @@ // misroute agent-node clicks into the graph's manual aiTool selection path. selectTarget: isLinkedAgent && !agentActions ? node.id : undefined, insertable, - readOnly: isLinkedAgent, - flowModuleStates + readOnly: isLinkedAgent }, id: `${node.id}-tool-${tool.id}`, width: inputToolWidth, @@ -253,7 +271,7 @@ computeAIToolNodesCache = { nodes: nodes.map(getComparableNode), - hasFlowModuleStates: !!flowModuleStates, + agentActions: agentActionsOf(nodes, flowModuleStates, insertable), linkedAgentTools: $state.snapshot(linkedAgentTools), ret } @@ -277,6 +295,7 @@ import { getNodeColorClasses } from '../../util' import { deepEqual } from 'fast-equals' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' let hover = $state(false) @@ -286,10 +305,11 @@ } let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() const { selectionManager } = getGraphContext() - const flowModuleState = $derived(data.flowModuleStates?.[data.moduleId]) + const flowModuleState = $derived(flowRunStatus?.getModuleState(data.moduleId)) let colorClasses = $derived( getNodeColorClasses( data.nameError ? 'Failure' : flowModuleState?.type, diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts index cdc6fa3f3b..a187c97436 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts @@ -83,6 +83,30 @@ describe('computeAIToolNodes', () => { } }) + it('picks up a tool call that arrives without moving any node', () => { + // Run status lives outside node data, so the memo has to key on the agent's actions + // itself. Two calls occupy one row, i.e. identical positions, so a memo keyed only on + // the nodes would serve the stale single-tool result forever. + const node = aiAgentNode('agent', [ + { id: 'tool_a', summary: 'my_tool', value: { tool_type: 'flowmodule', type: 'script' } } + ]) + const stateWith = (n: number) => + ({ + agent: { + type: 'InProgress', + agent_actions: Array.from({ length: n }, (_, i) => ({ + type: 'tool_call', + function_name: 'my_tool', + module_id: 'tool_a', + job_id: `j${i}` + })) + } + }) as any + + expect(computeAIToolNodes([node], eventHandlers, false, stateWith(1)).toolNodes.length).toBe(1) + expect(computeAIToolNodes([node], eventHandlers, false, stateWith(2)).toolNodes.length).toBe(2) + }) + it('still flags genuinely duplicate tool names in the editor (static tool set)', () => { const node = aiAgentNode('agent2', [ { id: 't1', summary: 'dup', value: { tool_type: 'flowmodule', type: 'script' } }, diff --git a/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte b/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte index 9682d941b6..2f93567cc0 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte @@ -7,17 +7,19 @@ import type { BranchAllStartN } from '../../graphBuilder.svelte' import { getGraphContext } from '../../graphContext' import { computeBorderStatus } from '../utils' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: BranchAllStartN['data'] id: string } let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() const { selectionManager } = getGraphContext() let borderStatus = $derived( - computeBorderStatus(data.branchIndex, 'branchall', data.flowModuleState) + computeBorderStatus(data.branchIndex, 'branchall', flowRunStatus?.getModuleState(data.id)) ) diff --git a/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte b/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte index 9902582ee0..3ba33a33b0 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte @@ -7,6 +7,7 @@ import type { BranchOneStartN } from '../../graphBuilder.svelte' import { getGraphContext } from '../../graphContext' import { computeBorderStatus } from '../utils' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: BranchOneStartN['data'] id: string @@ -14,11 +15,12 @@ const { selectionManager } = getGraphContext() let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() // branchIndex is -1 for the default branch and 0-based for explicit branches; // branchChosen is 0 for default and 1-based, hence the +1. let borderStatus = $derived( - computeBorderStatus(data.branchIndex + 1, 'branchone', data.flowModuleState) + computeBorderStatus(data.branchIndex + 1, 'branchone', flowRunStatus?.getModuleState(data.id)) ) diff --git a/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte index 98cae2b050..a4c3917075 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte @@ -8,6 +8,7 @@ import { Hourglass } from 'lucide-svelte' import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte' import { dfs } from '$lib/components/flows/dfs' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: CollapsedGroupN['data'] @@ -16,6 +17,8 @@ let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() + let outlineColorClass = $derived( (NOTE_COLORS[(data.color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]) .outline @@ -31,8 +34,8 @@ let waitingForEvents = $derived( allModuleIds.some( (mid) => - data.flowModuleStates?.[mid]?.type === 'WaitingForEvents' || - data.flowModuleStates?.[`${mid}-v`]?.type === 'WaitingForEvents' + flowRunStatus?.getModuleState(mid)?.type === 'WaitingForEvents' || + flowRunStatus?.getModuleState(`${mid}-v`)?.type === 'WaitingForEvents' ) ) @@ -55,16 +58,12 @@ /> {#if data.modules && data.modules.length > 0}
- +
{/if}
- {#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'} + {#if waitingForEvents && flowRunStatus?.flowJob && flowRunStatus.flowJob.type === 'QueuedJob'}
@@ -79,16 +78,16 @@
- {#if data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} + {#if flowRunStatus?.flowJob?.flow_status?.modules?.[flowRunStatus.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} - {:else if data.suspendStatus && Object.keys(data.suspendStatus).length > 0} + {:else if flowRunStatus?.suspendStatus && Object.keys(flowRunStatus.suspendStatus).length > 0}
- {#each Object.values(data.suspendStatus) as suspendCount (suspendCount.job.id)} + {#each Object.values(flowRunStatus.suspendStatus) as suspendCount (suspendCount.job.id)} diff --git a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte index 195f920d03..c59a775bd2 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte @@ -12,6 +12,7 @@ import type { FlowEditorContext } from '$lib/components/flows/types' import { MessageSquare } from 'lucide-svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' import FunnelCog from '$lib/components/icons/FunnelCog.svelte' interface Props { @@ -21,6 +22,7 @@ let { data }: Props = $props() const { selectionManager, diffManager } = getGraphContext() + const flowRunStatus = getFlowRunStatusContext() const flowEditorContext = getContext('FlowEditorContext') const { previewArgs, flowStore } = flowEditorContext || {} @@ -110,7 +112,7 @@ data.eventHandlers.hideJobStatus() }} individualStepTests={data.individualStepTests} - job={data.flowJob} + job={flowRunStatus?.flowJob} showJobStatus={data.showJobStatus} flowHasChanged={data.flowHasChanged} > diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index 01955d5e02..16d8a5c25d 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -8,6 +8,7 @@ import { isMac, type Item } from '$lib/utils' import { getContext } from 'svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: ModuleN['data'] @@ -17,11 +18,12 @@ // Get NoteEditor context for group note creation const noteEditorContext = getNoteEditorContext() + const flowRunStatus = getFlowRunStatusContext() let state = $derived.by(() => { return data.testModuleState - ? (jobToGraphModuleState(data.testModuleState) ?? data.flowModuleState) - : data.flowModuleState + ? (jobToGraphModuleState(data.testModuleState) ?? flowRunStatus?.getModuleState(data.id)) + : flowRunStatus?.getModuleState(data.id) }) let flowJobs = $derived( @@ -152,7 +154,7 @@ data.eventHandlers.updateMock(detail) }} onEditInput={data.eventHandlers.editInput} - flowJob={data.flowJob} + flowJob={flowRunStatus?.flowJob} isOwner={data.isOwner} maximizeSubflow={data.module?.value?.type == 'flow' && 'path' in data.module.value ? () => { diff --git a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte index 13ede87991..88febcd6ca 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte @@ -3,6 +3,7 @@ import NodeWrapper from './NodeWrapper.svelte' import type { ResultN } from '../../graphBuilder.svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: ResultN['data'] @@ -12,6 +13,7 @@ let { data, id }: Props = $props() const { selectionManager } = getGraphContext() + const flowRunStatus = getFlowRunStatusContext() @@ -27,7 +29,7 @@ }} nodeKind="result" editMode={data.editMode} - job={data.job} + job={flowRunStatus?.flowJob} showJobStatus={data.showJobStatus} /> {/snippet} From e4e7782517a9fd0fbbec020694b263a948ed1c0c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 6 Aug 2026 03:23:10 +0200 Subject: [PATCH 193/400] fix: restore the flow expression editor's property side panel (#10555) * fix: give flow expression editors their property side panel back * fix: keep the picker column tied to an input that can receive the pick --- .../lib/components/InputTransformForm.svelte | 60 +++++----- .../content/BranchPredicateEditor.svelte | 3 +- .../content/FlowEnvironmentVariables.svelte | 6 +- .../components/flows/content/FlowLoop.svelte | 6 +- .../flows/content/FlowModuleEarlyStop.svelte | 6 +- .../flows/content/FlowModuleSkip.svelte | 3 +- .../flows/content/FlowModuleSleep.svelte | 3 +- .../flows/content/FlowModuleSuspend.svelte | 4 +- .../flows/content/FlowModuleTimeout.svelte | 2 +- .../flows/content/FlowRetries.svelte | 3 +- .../flows/propPicker/ExpressionPicker.svelte | 105 ------------------ .../flows/propPicker/PropPickerWrapper.svelte | 104 +++++++++++------ 12 files changed, 116 insertions(+), 189 deletions(-) delete mode 100644 frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index da89d61f12..5df67638b0 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -37,7 +37,6 @@ import type { PickableProperties } from './flows/previousResults' import { twMerge } from 'tailwind-merge' import FlowPlugConnect from './FlowPlugConnect.svelte' - import ExpressionPicker from './flows/propPicker/ExpressionPicker.svelte' import { deepEqual } from 'fast-equals' import S3ArrayHelperButton from './S3ArrayHelperButton.svelte' import { inputBorderClass } from './text_input/TextInput.svelte' @@ -157,10 +156,6 @@ const propPickerWrapperContext: PropPickerWrapperContext | undefined = getContext('PropPickerWrapper') const pickerMode = $derived(propPickerWrapperContext?.pickerMode?.() ?? 'pane') - // Settings rows hand their properties to the wrapper, not to this form. - const connectableProperties = $derived( - pickableProperties ?? propPickerWrapperContext?.pickableProperties?.() - ) const { inputMatches, connectProp: focusProp, @@ -365,6 +360,18 @@ }) } + /** A predicate is usually half-written when you reach for a property, so insert at the + * cursor and leave the rest of the expression alone. Only a field that isn't an + * expression yet gets replaced outright. */ + function pickIntoArg(path: string) { + if (propertyType === 'javascript' && monaco) { + propPickerWrapperContext?.onPick?.(path) + } else { + connectProperty(path) + } + dispatch('change', { argName }) + } + function connectProperty(rawValue: string) { // Extract path from variable('x') or resource('x') format const varMatch = variableMatch(rawValue) @@ -465,8 +472,21 @@ } } + // The column beside a settings row delivers here rather than through the host's `select` + // handler, which can only reach a mounted expression editor. A collapsed setting has no + // field at all, so it gives the target up and the column closes with it. + $effect(() => { + if (pickerMode !== 'sidePane') return + propPickerWrapperContext?.setPickTarget?.( + collapsed ? undefined : { id: argName, onSelect: pickIntoArg } + ) + }) + onDestroy(() => { updatePropsBeingEdited(false) + if (pickerMode === 'sidePane') { + propPickerWrapperContext?.setPickTarget?.(undefined) + } }) let prevArg: any = undefined @@ -613,27 +633,7 @@ /> {/if} - {#if propPickerWrapperContext && pickerMode === 'popover'} - - { - // A predicate is usually half-written when you reach for a property, so - // insert at the cursor and leave the rest of the expression alone. Only - // a field that isn't an expression yet gets replaced outright. - if (propertyType === 'javascript' && monaco) { - propPickerWrapperContext.onPick?.(path) - } else { - connectProperty(path) - } - dispatch('change', { argName }) - }} - /> - {:else if propPickerWrapperContext} + {#if propPickerWrapperContext} { - connectProperty(path) - dispatch('change', { argName }) + if (pickerMode === 'sidePane') { + pickIntoArg(path) + } else { + connectProperty(path) + dispatch('change', { argName }) + } return true }) } diff --git a/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte b/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte index 493f04b86e..949862632a 100644 --- a/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte +++ b/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte @@ -46,10 +46,9 @@ { editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte index 44204dc786..cd14ddc23a 100644 --- a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte +++ b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte @@ -234,10 +234,8 @@ connectProp: () => {}, propPickerConfig: writable(undefined), clearConnect: () => {}, - pickerMode: () => 'popover' as const, - pickableProperties: () => undefined, - result: () => undefined, - extraResults: () => undefined, + pickerMode: () => 'pane' as const, + setPickTarget: () => {}, onPick: () => {}, exprBeingEdited: writable([]) }) diff --git a/frontend/src/lib/components/flows/content/FlowLoop.svelte b/frontend/src/lib/components/flows/content/FlowLoop.svelte index d78a3199b7..bfb80addd6 100644 --- a/frontend/src/lib/components/flows/content/FlowLoop.svelte +++ b/frontend/src/lib/components/flows/content/FlowLoop.svelte @@ -226,10 +226,9 @@ {#if selectedTab === 'loop'}
{ editor?.insertAtCursor(detail) @@ -323,10 +322,9 @@ {#if mod.value.parallel}
{ parallelismEditor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte b/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte index 8f0f091320..e9a824c294 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte @@ -256,12 +256,11 @@ {#if blocks !== 'all-iters' && !isBranchAll}
{ stopAfterEditor?.insertAtCursor(detail) @@ -299,11 +298,10 @@ {#if blocks !== 'stop-after' && (isLoop || isBranchAll)}
{ stopAfterAllItersEditor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte b/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte index 51d75b0ca6..40fb32b064 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte @@ -71,11 +71,10 @@
{ editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte index 03dcf2b6f5..f0645c4854 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte @@ -77,11 +77,10 @@ {#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
{ editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte index a20bb8c671..43c65bc482 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte @@ -219,9 +219,9 @@ for any) { editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte index 9f6a1b32b2..ae4c614ee2 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte @@ -78,7 +78,7 @@ {#if flowModule.timeout && schema.properties['timeout']}
{ retryIfEditor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte b/frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte deleted file mode 100644 index 6e29efe58d..0000000000 --- a/frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte +++ /dev/null @@ -1,105 +0,0 @@ - - - - (detail ? connect.arm({ id, onSelect }) : connect.disarm())} -> - {#snippet trigger()} - - {/snippet} - {#snippet content()} -
- {#if pickableProperties} - { - connect.resolve(detail) - open = false - }} - /> - {:else} -
Nothing to pick from yet.
- {/if} -
- {/snippet} -
diff --git a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte index 7820b1a53b..5fce4a972a 100644 --- a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte +++ b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte @@ -13,17 +13,17 @@ inputMatches: Writable<{ word: string; value: string }[] | undefined> connectProp: (propName: string, onSelect: SelectCallback) => void clearConnect: () => void - /** 'popover' hangs the picker off each input's own connect button instead of - * taking a pane — for single-argument settings rows, which are not the step's - * input form. */ - pickerMode: () => 'pane' | 'popover' - /** The wrapper owns these; nested inputs receive none of their own. */ - pickableProperties: () => PickableProperties | undefined - /** The step's own result, and anything extra worth offering beside it (a loop's - * `all_iters`). Only the pane renders them directly — in popover mode the picker - * hangs off each input, so it reads them from here instead. */ - result: () => any - extraResults: () => any + /** Where the properties are offered, and therefore what a pick does: + * - 'pane': the step's input form — a pick replaces the argument outright. + * - 'sidePane': a settings row — a pick lands at the expression's cursor, since a + * half-written predicate must survive it. */ + pickerMode: () => 'pane' | 'sidePane' + /** The single input a `sidePane` column belongs to. It stays a destination for as + * long as its field is mounted, so a pick lands whether or not a connect is armed — + * a static field has no editor for the host's own `select` handler to write into. + * `undefined` (the setting was switched off) leaves the column with nowhere to + * deliver, so it closes. */ + setPickTarget: (target: { id: string; onSelect: (path: string) => void } | undefined) => void /** Deliver a pick the way the pane does — as a `select` event, so each setting's own * handler inserts it at the cursor. Replacing the whole value is right for a step * input but destroys a half-written predicate. */ @@ -37,6 +37,7 @@ import PropPickerResult from '$lib/components/propertyPicker/PropPickerResult.svelte' import { clickOutside } from '$lib/utils' import { createEventDispatcher, getContext, setContext } from 'svelte' + import { fade } from 'svelte/transition' import { Pane, Splitpanes } from 'svelte-splitpanes' import { writable, type Writable } from 'svelte/store' import type { PickableProperties } from '../previousResults' @@ -55,8 +56,9 @@ noPadding?: boolean paneClass?: string /** Settings rows reuse the step-input form for one argument but are not the input - * form; their picker belongs in a popover. */ - popover?: boolean + * form: their picker is a column beside the row, revealed while the expression is + * being written, rather than a permanent split of the panel. */ + sidePane?: boolean children?: import('svelte').Snippet } @@ -70,7 +72,7 @@ notSelectable = false, noPadding = false, paneClass = '', - popover = false, + sidePane = false, children }: Props = $props() @@ -79,6 +81,7 @@ >(undefined) const inputMatches = writable<{ word: string; value: string }[] | undefined>(undefined) + const exprBeingEdited = writable([]) const dispatch = createEventDispatcher() const propPickerContext = getContext('PropPickerContext') @@ -100,13 +103,14 @@ propPickerConfig, inputMatches, connectProp: (propName, onSelect) => connect.arm({ id: propName, onSelect }), - clearConnect: connect.disarm, - pickerMode: () => (popover ? 'popover' : 'pane'), - pickableProperties: () => pickableProperties, - result: () => result, - extraResults: () => extraResults, + clearConnect: closePicker, + pickerMode: () => (sidePane ? 'sidePane' : 'pane'), + setPickTarget: (target) => { + pickTarget = target + if (!target) closePicker() + }, onPick: (path) => dispatch('select', path), - exprBeingEdited: writable([]) + exprBeingEdited }) async function getPropPickerElements(): Promise { @@ -116,9 +120,38 @@ } let rightPaneHeight: number = $state(0) + + let pickTarget: { id: string; onSelect: (path: string) => void } | undefined = $state(undefined) + + // The side column stays put once the row is being worked on: picking a property blurs + // the editor, so closing on blur would take the column away mid-click. It is dismissed + // deliberately instead — by clicking away, by the input's own connect button, or by the + // setting being switched off. + let sidePaneOpen = $state(false) + $effect(() => { + if ($propPickerConfig != undefined || $exprBeingEdited.length > 0) { + sidePaneOpen = true + } + }) + + function closePicker() { + connect.disarm() + // A switched-off setting unmounts its editor rather than blurring it, so the focus + // claim outlives the field — left standing, it reopens the column on the next tick. + exprBeingEdited.set([]) + sidePaneOpen = false + } {#snippet pickerBody()} + + {@const deliver = (path: string) => + connect.armed + ? connect.resolve(path) + : pickTarget + ? pickTarget.onSelect(path) + : dispatch('select', path)}
{ - dispatch('select', detail) - connect.resolve(detail) - }} + on:select={({ detail }) => deliver(detail)} /> {:else if pickableProperties} { - dispatch('select', detail) - connect.resolve(detail) - }} + on:select={({ detail }) => deliver(detail)} /> {/if} @@ -167,13 +194,24 @@ // Through the controller, not the stores: it owns the armed target, and a // target left armed here would make the next click on that same input // read as a toggle-off. - onClickOutside: connect.disarm + onClickOutside: closePicker }} > - {#if popover} - - {@render children?.()} + {#if sidePane} + +
+
{@render children?.()}
+ {#if sidePaneOpen && (pickableProperties != undefined || result != undefined)} + +
+ {@render pickerBody()} +
+ {/if} +
{:else} From 386c66bef0534d4a63b3220050e98b9654ef8f34 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 6 Aug 2026 03:39:43 +0200 Subject: [PATCH 194/400] fix: open the expression property column on demand, not from focus (#10558) --- .../lib/components/InputTransformForm.svelte | 7 +++++ .../content/FlowEnvironmentVariables.svelte | 1 + .../flows/propPicker/PropPickerWrapper.svelte | 29 ++++++++++--------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 5df67638b0..5fd6ff9816 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -161,6 +161,7 @@ connectProp: focusProp, propPickerConfig, clearConnect: clearFocus, + openPicker, exprBeingEdited } = propPickerWrapperContext ?? {} @@ -941,7 +942,12 @@ {/snippet} {:else if argKind === 'javascript' && arg.expr != undefined} + +
openPicker?.()} class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused, error: !!error })}`} > { focused = true updatePropsBeingEdited(true) + openPicker?.() }} on:blur={() => { focused = false diff --git a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte index cd14ddc23a..4af530a08a 100644 --- a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte +++ b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte @@ -234,6 +234,7 @@ connectProp: () => {}, propPickerConfig: writable(undefined), clearConnect: () => {}, + openPicker: () => {}, pickerMode: () => 'pane' as const, setPickTarget: () => {}, onPick: () => {}, diff --git a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte index 5fce4a972a..8bbe9b9792 100644 --- a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte +++ b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte @@ -13,6 +13,10 @@ inputMatches: Writable<{ word: string; value: string }[] | undefined> connectProp: (propName: string, onSelect: SelectCallback) => void clearConnect: () => void + /** Reveal a `sidePane` column: its input is being written in. Reaching for the editor + * again after dismissing the column has to say so, since an editor that kept focus + * throughout emits no new focus event. */ + openPicker: () => void /** Where the properties are offered, and therefore what a pick does: * - 'pane': the step's input form — a pick replaces the argument outright. * - 'sidePane': a settings row — a pick lands at the expression's cursor, since a @@ -102,8 +106,12 @@ setContext('PropPickerWrapper', { propPickerConfig, inputMatches, - connectProp: (propName, onSelect) => connect.arm({ id: propName, onSelect }), + connectProp: (propName, onSelect) => { + connect.arm({ id: propName, onSelect }) + openPicker() + }, clearConnect: closePicker, + openPicker, pickerMode: () => (sidePane ? 'sidePane' : 'pane'), setPickTarget: (target) => { pickTarget = target @@ -123,22 +131,17 @@ let pickTarget: { id: string; onSelect: (path: string) => void } | undefined = $state(undefined) - // The side column stays put once the row is being worked on: picking a property blurs - // the editor, so closing on blur would take the column away mid-click. It is dismissed - // deliberately instead — by clicking away, by the input's own connect button, or by the - // setting being switched off. + // Opened and dismissed on demand rather than derived from focus: picking a property blurs + // the editor, so a column that followed focus would vanish mid-click — and one that + // latched onto focus would never let go of an editor unmounted by its own setting. let sidePaneOpen = $state(false) - $effect(() => { - if ($propPickerConfig != undefined || $exprBeingEdited.length > 0) { - sidePaneOpen = true - } - }) + + function openPicker() { + sidePaneOpen = true + } function closePicker() { connect.disarm() - // A switched-off setting unmounts its editor rather than blurring it, so the focus - // claim outlives the field — left standing, it reopens the column on the next tick. - exprBeingEdited.set([]) sidePaneOpen = false } From 4c4387d52adc192626c73e48228eb86db7a6c307 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 6 Aug 2026 04:17:34 +0200 Subject: [PATCH 195/400] feat(flow-editor): show an agent's tool-call status without moving the graph (#10557) * feat(flow-editor): surface an agent's tool-call status without moving the graph Co-Authored-By: Claude Opus 5 (1M context) * fix: count only an agent's tool calls and key them in one place Co-Authored-By: Claude Opus 5 (1M context) * feat: report an agent's replies alongside its tool calls Co-Authored-By: Claude Opus 5 (1M context) * feat: break the agent summary down by action kind Co-Authored-By: Claude Opus 5 (1M context) * fix: key agent tool nodes by kind and keep the summary clear of the tool row Co-Authored-By: Claude Opus 5 (1M context) * refactor: read agent action status from the run's success array Co-Authored-By: Claude Opus 5 (1M context) * test: pin the tool joins a local run cannot reach Co-Authored-By: Claude Opus 5 (1M context) * fix: place the agent summary beside the step and match MCP paths bare Co-Authored-By: Claude Opus 5 (1M context) * fix: feed a single-step agent test's calls into the graph status Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../components/FlowStatusViewerInner.svelte | 47 +++---- .../lib/components/flows/map/MapItem.svelte | 15 ++ .../lib/components/graph/FlowGraphV2.svelte | 20 ++- .../components/graph/graphBuilder.svelte.ts | 5 + frontend/src/lib/components/graph/model.ts | 3 + .../graph/renderers/nodes/AIToolNode.svelte | 128 +++++++++++++++--- .../graph/renderers/nodes/AIToolNode.test.ts | 48 ++++++- .../graph/renderers/nodes/ModuleNode.svelte | 55 ++++++++ .../src/lib/components/modulesTest.svelte.ts | 19 ++- 9 files changed, 288 insertions(+), 52 deletions(-) diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index d30d98a991..e09f9e79e2 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -49,6 +49,7 @@ AI_TOOL_MESSAGE_PREFIX, AI_MCP_TOOL_CALL_PREFIX, AI_WEBSEARCH_PREFIX, + getAgentActionStateId, getToolCallId } from './graph/renderers/nodes/AIToolNode.svelte' import JobAssetsViewer from './assets/JobAssetsViewer.svelte' @@ -761,34 +762,28 @@ if (mod.agent_actions && mod.id) { setModuleState(mod.id, { - agent_actions: mod.agent_actions + agent_actions: mod.agent_actions, + agent_actions_success: mod.agent_actions_success }) mod.agent_actions.forEach((action, idx) => { - if (mod.id) { - if (action.type == 'tool_call') { - const toolCallId = getToolCallId(idx, mod.id, action.module_id) - const success = mod.agent_actions_success?.[idx] - setModuleState(toolCallId, { - job_id: action.job_id, - type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' - }) - } else if (action.type == 'mcp_tool_call') { - const mcpToolCallId = AI_MCP_TOOL_CALL_PREFIX + '-' + mod.id + '-' + idx - const success = mod.agent_actions_success?.[idx] - setModuleState(mcpToolCallId, { - type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' - }) - } else if (action.type == 'web_search') { - const websearchId = AI_WEBSEARCH_PREFIX + '-' + mod.id + '-' + idx - setModuleState(websearchId, { - type: 'Success' - }) - } else if (action.type == 'message') { - const toolCallId = getToolCallId(idx, mod.id) - setModuleState(toolCallId, { - type: 'Success' - }) - } + if (!mod.id) { + return + } + const stateId = getAgentActionStateId(idx, mod.id, action) + const success = mod.agent_actions_success?.[idx] + if (action.type == 'tool_call') { + setModuleState(stateId, { + job_id: action.job_id, + type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' + }) + } else if (action.type == 'mcp_tool_call') { + setModuleState(stateId, { + type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' + }) + } else { + setModuleState(stateId, { + type: 'Success' + }) } }) } diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte index ccd9894bd3..29d10d5ca3 100644 --- a/frontend/src/lib/components/flows/map/MapItem.svelte +++ b/frontend/src/lib/components/flows/map/MapItem.svelte @@ -22,6 +22,9 @@ insertable: boolean moduleAction: ModuleActionInfo | undefined annotation?: string | undefined + annotationTitle?: string | undefined + sideAnnotation?: string | undefined + sideAnnotationTitle?: string | undefined nodeState?: FlowNodeState duration_ms?: number | undefined retries?: number | undefined @@ -55,6 +58,9 @@ insertable, moduleAction = undefined, annotation = undefined, + annotationTitle = undefined, + sideAnnotation = undefined, + sideAnnotationTitle = undefined, nodeState, duration_ms = undefined, retries = undefined, @@ -134,8 +140,17 @@ {msToSec(duration_ms)}s
{/if} + {#if sideAnnotation && sideAnnotation != ''} +
+ {sideAnnotation} +
+ {/if} {#if annotation && annotation != ''}
flowRunStatus.setModuleStates(flowModuleStates)) + // The loader mutates `flow_status` on the job it already handed us, so subscribing to the + // test state alone would never see an agent's calls land. + Object.values(testModuleStates?.states ?? {}).forEach((s) => [ + s.loading, + s.testJob?.['flow_status']?.modules?.[0]?.agent_actions?.length + ]) + untrack(() => { + // Testing one step is its own small run, and its agent calls arrive on the test job + // rather than the flow's states. Fold them in so the renderers keep a single source. + let states = flowModuleStates + for (const [id, testState] of Object.entries(testModuleStates?.states ?? {})) { + const tested = jobToGraphModuleState(testState) + if (!tested?.agent_actions) continue + states = { ...(states ?? {}), [id]: { ...(states?.[id] ?? {}), ...tested } } + } + flowRunStatus.setModuleStates(states) + }) }) if (triggerContext && untrack(() => allowSimplifiedPoll)) { diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index bd133dfeb3..450debbef4 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -311,6 +311,11 @@ export type AiToolN = { nameError?: string eventHandlers: GraphEventHandlers moduleId: string + /** An MCP tool's server, which its calls are keyed to. */ + resourcePath?: string + /** The agent step this tool hangs off. The editor draws the declared tools, whose ids the + * run's per-call state is not keyed by, so the node needs its agent to find its calls. */ + agentModuleId: string // Set on a linked agent's display-only tools: clicking selects this module (the agent step) // instead of the tool, whose resource-owned id is not flow-unique. selectTarget?: string diff --git a/frontend/src/lib/components/graph/model.ts b/frontend/src/lib/components/graph/model.ts index 3618406371..07c7fb0e19 100644 --- a/frontend/src/lib/components/graph/model.ts +++ b/frontend/src/lib/components/graph/model.ts @@ -66,6 +66,9 @@ export type GraphModuleState = { isListJob?: boolean skipped?: boolean agent_actions?: FlowStatusModule['agent_actions'] + /** Positionally aligned with `agent_actions`: every push of an action appends one entry, so a + * missing entry means that action has not finished yet. */ + agent_actions_success?: FlowStatusModule['agent_actions_success'] script_hash?: string workflow_as_code_status?: WorkflowStatus } diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index 1876d5af33..9465f5b49f 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -55,6 +55,58 @@ : AI_TOOL_MESSAGE_PREFIX + '-' + agentModuleId + '-' + idx } + export type AgentAction = NonNullable[number] + + function bareResourcePath(path: string | undefined): string | undefined { + return path?.startsWith('$res:') ? path.slice('$res:'.length) : path + } + + /** Whether a run's action was a call of this declared tool. The editor keeps one node per + * declared tool, so each kind of action has to find its way back to the right one: a flow + * module by id, web search by being the only one of its kind, an MCP server by its resource + * path. A miss leaves the node undecorated rather than decorating the wrong tool. */ + export function agentActionMatchesTool( + action: AgentAction, + tool: { moduleId: string; type?: string; resourcePath?: string } + ): boolean { + switch (action.type) { + case 'tool_call': + return action.module_id === tool.moduleId + case 'web_search': + return tool.type === 'websearch' + case 'mcp_tool_call': + // One MCP node stands for a whole server and many function names, so the server path + // is the only join that holds. The worker strips `$res:` before building the action, + // while a flow authored outside the resource picker can still carry it. + return ( + tool.type === 'mcp' && + bareResourcePath(action.resource_path) === bareResourcePath(tool.resourcePath) + ) + case 'message': + return false + } + } + + /** The one id an agent action's state is written and read under. Every writer and reader has + * to rebuild the same key, so they all come here; a switch with no default makes a new action + * kind a compile error rather than a status that silently never resolves. */ + export function getAgentActionStateId( + idx: number, + agentModuleId: string, + action: AgentAction + ): string { + switch (action.type) { + case 'tool_call': + return getToolCallId(idx, agentModuleId, action.module_id) + case 'mcp_tool_call': + return AI_MCP_TOOL_CALL_PREFIX + '-' + agentModuleId + '-' + idx + case 'web_search': + return AI_WEBSEARCH_PREFIX + '-' + agentModuleId + '-' + idx + case 'message': + return getToolCallId(idx, agentModuleId) + } + } + function getComparableNode(node: Node & NodeLayout): Node & NodeLayout { if (node.type === 'module' && node.data.module.value.type === 'aiagent') { return { @@ -128,6 +180,7 @@ name: string type?: string stateType?: GraphModuleState['type'] + resourcePath?: string }[] = sourceTools.map((t, idx) => { // Handle FlowModule, MCP, and Websearch tools const toolType = @@ -141,7 +194,8 @@ return { id: t.id, name: t.summary ?? '', - type: toolType + type: toolType, + resourcePath: t.value.tool_type === 'mcp' ? t.value.resource_path : undefined } }) @@ -151,26 +205,13 @@ baseOffset = BELOW_ADDITIONAL_OFFSET + AI_TOOL_BASE_OFFSET rowOffset = AI_TOOL_ROW_OFFSET tools = agentActions.map((a, idx) => { + const id = getAgentActionStateId(idx, node.id, a) if (a.type === 'tool_call' || a.type === 'mcp_tool_call') { - const id = - a.type === 'tool_call' - ? getToolCallId(idx, node.id, a.module_id) - : AI_MCP_TOOL_CALL_PREFIX + '-' + node.id + '-' + idx - return { - id, - name: a.function_name - } + return { id, name: a.function_name } } else if (a.type === 'web_search') { - return { - id: AI_WEBSEARCH_PREFIX + '-' + node.id + '-' + idx, - name: 'Web Search', - type: 'websearch' - } + return { id, name: 'Web Search', type: 'websearch' } } else { - return { - id: getToolCallId(idx, node.id), - name: 'Message' - } + return { id, name: 'Message' } } }) } @@ -210,7 +251,9 @@ // misroute agent-node clicks into the graph's manual aiTool selection path. selectTarget: isLinkedAgent && !agentActions ? node.id : undefined, insertable, - readOnly: isLinkedAgent + readOnly: isLinkedAgent, + agentModuleId: node.id, + resourcePath: tool.resourcePath }, id: `${node.id}-tool-${tool.id}`, width: inputToolWidth, @@ -310,9 +353,43 @@ const { selectionManager } = getGraphContext() const flowModuleState = $derived(flowRunStatus?.getModuleState(data.moduleId)) + + /** + * The editor draws the agent's declared tools, one node per tool, while a run keys its state + * per call. Roll every call of this tool into the one node it already has, so a run shows up + * here without adding nodes and shifting the graph. A run graph looks its own state up + * directly, so it never gets here. + */ + const toolCalls = $derived.by(() => { + if (flowModuleState) return undefined + const agentState = flowRunStatus?.getModuleState(data.agentModuleId) + const actions = agentState?.agent_actions + if (!actions) return undefined + let count = 0 + let failed = 0 + let pending = 0 + actions.forEach((action, index) => { + if ( + !agentActionMatchesTool(action, { + moduleId: data.moduleId, + type: data.type, + resourcePath: data.resourcePath + }) + ) + return + count++ + const success = agentState?.agent_actions_success?.[index] + if (success === undefined) pending++ + else if (!success) failed++ + }) + if (count === 0) return undefined + const type = pending > 0 ? 'InProgress' : failed > 0 ? 'Failure' : 'Success' + return { type, count } as const + }) + let colorClasses = $derived( getNodeColorClasses( - data.nameError ? 'Failure' : flowModuleState?.type, + data.nameError ? 'Failure' : (flowModuleState?.type ?? toolCalls?.type), selectionManager?.getSelectedId() === (data.selectTarget ?? data.moduleId) ) ) @@ -363,6 +440,17 @@ {data.tool || 'Missing name'} + + + {#if toolCalls && toolCalls.count > 1} + + {toolCalls.count} + + {/if} {#if data.insertable && !data.readOnly}
diff --git a/frontend/src/lib/components/SimpleEditor.svelte b/frontend/src/lib/components/SimpleEditor.svelte index 37616aa83a..2764b130e2 100644 --- a/frontend/src/lib/components/SimpleEditor.svelte +++ b/frontend/src/lib/components/SimpleEditor.svelte @@ -11,6 +11,7 @@ - + clearPageDrawerAnchor(VARIABLES_PATH)}> {#snippet actions()} + {#if edit && curWs} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 1faa9c003b..806b963262 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -120,7 +120,8 @@ import { type ChatCommandItem, type SessionPromptContext, getSessionContextPromptSection, - type GlobalToolHelpers + type GlobalToolHelpers, + type GlobalActivePreviewContext } from './global/core' import { formatChatJobCompletion } from './datatableTools' import { isGlobalAiEnabled } from './global/gate' @@ -584,6 +585,10 @@ export class AIChatManager { // sessions modules — and re-read on every system-message rebuild; the send // path rebuilds after beforeSend, so a fork committed there is picked up. sessionContextResolver: (() => SessionPromptContext | undefined) | undefined = undefined + // The page the side panel shows, stamped on each user message. Same seam as above: + // a page tab is an iframe in its own realm, so the tab model is the only place the + // chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those. + activePreviewResolver: (() => GlobalActivePreviewContext | undefined) | undefined = undefined // Resolves the workspace this chat operates on. Session chats set it to their // own (possibly forked) workspace so the chat targets it WITHOUT switching the // global workspaceStore. Undefined for the global side-panel chat, which @@ -2329,7 +2334,10 @@ export class AIChatManager { return prepareGlobalUserMessage( pendingPrompt, this.contextManager.getSelectedContext(), - { workspace: this.operatingWorkspace } + { + workspace: this.operatingWorkspace, + activePreview: this.activePreviewResolver?.() + } ) } return undefined @@ -2936,6 +2944,7 @@ export class AIChatManager { case AIMode.GLOBAL: userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, { workspace: this.operatingWorkspace, + activePreview: this.activePreviewResolver?.(), images: sentImages, files: files }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index e0619b3832..18851e8766 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -5052,6 +5052,18 @@ describe('session-only preview tools gating', () => { } }) + // Only a session chat can ever receive an ACTIVE PREVIEW section, so the rule + // explaining it is dead weight (~100 prompt tokens per request) anywhere else. + it('carries the ACTIVE PREVIEW rule only in a chat that has a side panel', () => { + const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string + const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string + expect(off).not.toContain('ACTIVE PREVIEW') + expect(on).toContain('ACTIVE PREVIEW') + // The ACTIVE EDITOR rule is unconditional — live editors exist in both. + expect(off).toContain('ACTIVE EDITOR') + expect(on).toContain('ACTIVE EDITOR') + }) + it('mentions open_preview / get_app_runtime_logs / list_app_runs in the system prompt only when preview tools are enabled', () => { const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string @@ -5256,6 +5268,21 @@ describe('prepareGlobalUserMessage', () => { expect(message.content).not.toContain('content') }) + it('injects the previewed page and the row its drawer has open', () => { + const message = prepareGlobalUserMessage('Disable it', [], { + activePreview: { + label: 'Schedules', + location: '/schedules', + open: 'u/me/daily_report' + } + }) + + expect(message.content).toContain('## ACTIVE PREVIEW') + expect(message.content).toContain('page: Schedules') + expect(message.content).toContain('location: /schedules') + expect(message.content).toContain('open: u/me/daily_report') + }) + it('includes selected workspace item references without contents', () => { const message = prepareGlobalUserMessage('Update these items', [ { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e34e08de6e..23ca167395 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -254,9 +254,26 @@ export type GlobalActiveEditorContext = { isLiveDraft: true } +/** The page the session's side panel is showing, when it isn't one of the live + * editors ACTIVE EDITOR already covers. A page tab is an iframe in its own realm, + * so the chat can only learn about it from the tab model the session owns. */ +export type GlobalActivePreviewContext = { + /** Page name as the tab strip shows it, e.g. "Schedules". */ + label: string + /** Base-stripped page path plus the request params the page declares — values kept + * only for the ones addressing a workspace object, and percent-encoded. Never a raw + * location: a tab can host a legacy app whose hash is app state, and a filter value + * can be free text the user typed. Build it with `previewLocationContext`. */ + location: string + /** The row whose drawer is open on that page. The list pages drop the anchor when + * their drawer closes, so its absence means no row is open. */ + open?: string +} + export type GlobalUserMessageOptions = { workspace?: string activeEditor?: GlobalActiveEditorContext + activePreview?: GlobalActivePreviewContext /** Images attached to this message; delivered as image_url content parts. */ images?: AttachedImage[] /** Text files attached to this message; listed by reference below — the model @@ -1197,6 +1214,12 @@ const buildGlobalSystemPrompt = ( const pipelineAlphaNote = previewTools ? ' Data pipeline support in this chat is in ALPHA: the first time the user asks for a data pipeline in this session, briefly tell them it is an alpha feature before you start building.' : '' + // Gated on `previewTools` (constant per chat), never on whether a preview is open + // right now: the system prompt is the cached prefix, so a line appearing and + // disappearing between turns costs more cache than the tool call it saves. + const activePreviewRule = previewTools + ? '\n- If the user message includes an ACTIVE PREVIEW section, that is the page the side panel is showing — resolve "this page", "here" and "it" against it, and against `open` (the row the page is anchored at, whose drawer the user opened) when there is one. It already tells you what get_preview_status would, so do not call that tool to learn what is on screen; call it only to check the panel\'s *other* tabs.' + : '' const pipelineBullet = `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on \` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow.${pipelineAlphaNote}` return `You are Windmill's global workspace assistant. @@ -1214,7 +1237,7 @@ Path conventions: Rules: - Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items. - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. -- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor". +- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule} - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. - To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path. - Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs. @@ -7341,6 +7364,16 @@ export function prepareGlobalUserMessage( content += `isLiveDraft: true\n\n` } + if (options.activePreview) { + content += '## ACTIVE PREVIEW\n' + content += `page: ${options.activePreview.label}\n` + content += `location: ${options.activePreview.location}\n` + if (options.activePreview.open) { + content += `open: ${options.activePreview.open}\n` + } + content += '\n' + } + if (selectedWorkspaceItems.length > 0) { content += '## SELECTED CONTEXT\n' for (const context of selectedWorkspaceItems) { diff --git a/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts b/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts index 7219c509b6..6556799833 100644 --- a/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts +++ b/frontend/src/lib/components/copilot/chat/global/pageNavigation.ts @@ -1,9 +1,8 @@ import { buildFilterUrl } from '$lib/navigation' -import { buildRunsFilterSearchbarSchema } from '$lib/components/runs/runsFilter' -import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter' import { COMPARE_PAGE, TRIGGER_PAGES, + pageRequestParams, type TriggerKind } from '$lib/components/sessions/previewRouter' import { @@ -11,16 +10,17 @@ import { serializeItemsMaskParam } from '$lib/components/sessions/modifiedItemsMask' -// In-app paths for the deep-linkable preview pages the AI chat can open. -export const RUNS_PATH = '/runs' -export const SCHEDULES_PATH = '/schedules' -export const VARIABLES_PATH = '/variables' -export const RESOURCES_PATH = '/resources' -export const ASSETS_PATH = '/assets' -export const AUDIT_LOGS_PATH = '/audit_logs' -export const WORKSPACE_SETTINGS_PATH = '/workspace_settings' -export const FOLDERS_PATH = '/folders' -export const GROUPS_PATH = '/groups' +import { + RUNS_PATH, + SCHEDULES_PATH, + VARIABLES_PATH, + RESOURCES_PATH, + ASSETS_PATH, + AUDIT_LOGS_PATH, + WORKSPACE_SETTINGS_PATH, + FOLDERS_PATH, + GROUPS_PATH +} from '$lib/components/sessions/previewPaths' // Selectable tabs on the Workspace settings page (the `?tab=` query param). Mirrors the // union in routes/(root)/(logged)/workspace_settings/+page.svelte. @@ -49,27 +49,17 @@ export const WORKSPACE_SETTINGS_TABS = [ 'shared_ui' ] as const -// Valid query-param keys are derived from the real filter schemas (option arrays are -// irrelevant to the key set), so a renamed filter key propagates here for free. The -// permission flags are on so the key set is complete: gating `all_workspaces` is the -// caller's job, and the Runs page ignores it for anyone whose own schema lacks the key. -const RUNS_FILTER_KEYS = Object.keys( - buildRunsFilterSearchbarSchema({ - paths: [], - usernames: [], - folders: [], - jobTriggerKinds: [], - isSuperAdminOrDevops: true, - isAdminsWorkspace: true - }) -) -const SCHEDULES_FILTER_KEYS = Object.keys( - buildSchedulesFilterSchema({ paths: [], scriptPaths: [] }) -) +// Every builder below allows exactly the params `previewRouter` records as +// request-settable for that page, so the URLs this emits and the preview's reading of +// them stay one set. Wherever the page declares a filter schema that set is its full +// key list, so a renamed or added filter propagates here for free — including the keys +// only some viewers see: gating `all_workspaces` is the caller's job, and the Runs page +// ignores it for anyone whose own schema lacks it. What the chat may actually pass is +// narrower and lives in the open_page tool schema, not here. /** Deep-link to the Runs page with the given filters (keys must match `runsFilter`). */ export function buildRunsUrl(filters: Record): string { - return buildFilterUrl(RUNS_PATH, filters, { validKeys: RUNS_FILTER_KEYS }) + return buildFilterUrl(RUNS_PATH, filters, { validKeys: pageRequestParams(RUNS_PATH) }) } /** @@ -84,15 +74,11 @@ export function buildSchedulesUrl({ filters?: Record }): string { return buildFilterUrl(SCHEDULES_PATH, filters ?? {}, { - validKeys: SCHEDULES_FILTER_KEYS, + validKeys: pageRequestParams(SCHEDULES_PATH), hash: open }) } -// The remaining pages expose a curated subset of each page's real query params (not the -// full filter schema), so the allow-list is the exact set of keys the builder emits — -// these names match the query params the pages read (variablesFilter/resourcesFilter/ -// assetsFilter and audit_logs/+page.svelte). /** When `open` is set, the variable at that exact path is opened in the edit * drawer via the `#` hash the page already handles. */ export function buildVariablesUrl({ @@ -103,7 +89,7 @@ export function buildVariablesUrl({ filters?: Record }): string { return buildFilterUrl(VARIABLES_PATH, filters ?? {}, { - validKeys: ['path', 'owner'], + validKeys: pageRequestParams(VARIABLES_PATH), hash: open }) } @@ -118,24 +104,26 @@ export function buildResourcesUrl({ filters?: Record }): string { return buildFilterUrl(RESOURCES_PATH, filters ?? {}, { - validKeys: ['path', 'resource_type', 'owner'], + validKeys: pageRequestParams(RESOURCES_PATH), hash: open ? `/resource/${open}` : undefined }) } export function buildAssetsUrl(filters: Record): string { - return buildFilterUrl(ASSETS_PATH, filters, { validKeys: ['path'] }) + return buildFilterUrl(ASSETS_PATH, filters, { validKeys: pageRequestParams(ASSETS_PATH) }) } export function buildAuditLogsUrl(filters: Record): string { return buildFilterUrl(AUDIT_LOGS_PATH, filters, { - validKeys: ['username', 'operation', 'resource'] + validKeys: pageRequestParams(AUDIT_LOGS_PATH) }) } /** Deep-link to the Workspace settings page, optionally on a specific `?tab=`. */ export function buildWorkspaceSettingsUrl({ tab }: { tab?: string }): string { - return buildFilterUrl(WORKSPACE_SETTINGS_PATH, tab ? { tab } : {}) + return buildFilterUrl(WORKSPACE_SETTINGS_PATH, tab ? { tab } : {}, { + validKeys: pageRequestParams(WORKSPACE_SETTINGS_PATH) + }) } /** Folders and Groups list pages have no query filters — just open them. */ @@ -173,7 +161,7 @@ export function buildCompareUrl({ mode, [COMPARE_ITEMS_PARAM]: items ? serializeItemsMaskParam(items) : undefined }, - { validKeys: ['workspace_id', 'mode', COMPARE_ITEMS_PARAM] } + { validKeys: pageRequestParams(COMPARE_PAGE.path) } ) } diff --git a/frontend/src/lib/components/pendingEditorFlush.test.ts b/frontend/src/lib/components/pendingEditorFlush.test.ts new file mode 100644 index 0000000000..b55732734e --- /dev/null +++ b/frontend/src/lib/components/pendingEditorFlush.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { + anyEditorUnparseable, + setEditorUnparseable, + registerPendingEditor, + flushAllPendingEditorChanges +} from './pendingEditorFlush' + +describe('pendingEditorFlush', () => { + it('reports unparseable text until the editor clears it', () => { + const editor = {} + expect(anyEditorUnparseable()).toBe(false) + setEditorUnparseable(editor, true) + expect(anyEditorUnparseable()).toBe(true) + setEditorUnparseable(editor, false) + expect(anyEditorUnparseable()).toBe(false) + }) + + it('flushes registered editors, and stops once they unmount', () => { + let flushed = 0 + const deregister = registerPendingEditor({ flushPendingChanges: () => flushed++ }) + flushAllPendingEditorChanges() + deregister() + flushAllPendingEditorChanges() + expect(flushed).toBe(1) + }) +}) diff --git a/frontend/src/lib/components/pendingEditorFlush.ts b/frontend/src/lib/components/pendingEditorFlush.ts new file mode 100644 index 0000000000..20ddde67a5 --- /dev/null +++ b/frontend/src/lib/components/pendingEditorFlush.ts @@ -0,0 +1,36 @@ +// Every mounted `SimpleEditor`, so a caller can materialise what the user typed without +// knowing which editors a page contains — a drawer nests them through SchemaForm and +// ArgInput, so enumerating them from the container does not scale. Plain module rather +// than the editor component: importing that pulls Monaco's side-effect imports into every +// graph that reaches this, and the components around it defer Monaco deliberately. +const liveEditors = new Set<{ flushPendingChanges: () => void }>() + +/** Register a mounted editor; the returned function deregisters it. */ +export function registerPendingEditor(editor: { flushPendingChanges: () => void }): () => void { + liveEditors.add(editor) + return () => liveEditors.delete(editor) +} + +/** Drain every mounted editor's debounced buffer. For code that must act on what is on + * screen before leaving it — persisting a draft before routing to a session. */ +export function flushAllPendingEditorChanges(): void { + for (const editor of liveEditors) editor.flushPendingChanges() +} + +// Editors whose current text does not parse. Their value never reaches the bound field, so +// a caller persisting "what is on screen" would save the last value that did parse and +// leave without it. Registered by the editors that parse, not by the ones that only hold text. +const unparseable = new Set() + +/** Mark or clear this editor as holding text that does not parse. */ +export function setEditorUnparseable(key: object, invalid: boolean): void { + if (invalid) unparseable.add(key) + else unparseable.delete(key) +} + +/** Whether any editor on screen holds text that cannot be persisted as written. Registry- + * wide rather than per-item: the editors that parse are nested arbitrarily deep and none + * of them knows which draft it belongs to. */ +export function anyEditorUnparseable(): boolean { + return unparseable.size > 0 +} diff --git a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte index 72d319ec9e..22f1167455 100644 --- a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte +++ b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte @@ -4,14 +4,26 @@ // What an editor hands over for "Open in AI session": the session target it // maps to, the workspace it lives in, and a persist hook run before routing // so the session preview opens the item exactly as currently edited. - export type OpenInSessionSource = { - target: SessionTarget + type OpenInSessionCommon = { workspaceId?: string beforeOpen?: () => void | Promise /** Where inside the item the preview should open (a flow's `selected` * step). Steers the editor only — tab identity is (kind, path). */ previewParams?: Record } + + // A destination is either an editable item or a page, never both and never + // neither — the union is what makes that a compile error rather than a button + // that silently does nothing. + export type OpenInSessionSource = OpenInSessionCommon & + ( + | { target: SessionTarget; page?: never } + /** Base-prefixed href of a workspace page the preview opens as a tab (Runs, + * a trigger list). Resolved on click, not at render: a page whose filters + * live in shallow-routed query params never reflects them in `page.url`, so + * only `window.location` read at that moment matches what the user sees. */ + | { page: () => string | undefined; target?: never } + ) {#if !allowDraft} {@render extra?.()} + {#if edit} + {#if !trigger?.draftConfig}
import { untrack } from 'svelte' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import { Alert } from '$lib/components/common' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -159,6 +164,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.amqp.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -392,7 +398,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.amqp.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -131,6 +136,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.azure.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -206,11 +212,11 @@ const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any loadTriggerConfig(deployedTrigger) return { - noDeployed: !!(s as any)?.no_deployed, - overlay: draftFromBackend - ? ({ ...deployedTrigger, ...draftFromBackend } as Record) - : undefined - } + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined + } } catch (error) { sendUserToast(`Could not load Azure trigger: ${error.body}`, true) return { overlay: undefined, noDeployed: false } @@ -348,7 +354,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.azure.path)} + > import { Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -128,6 +133,7 @@ }, 100) // if loading takes less than 100ms, we don't show the loader try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.email.path, ePath) initialPath = ePath path = ePath itemKind = isFlow ? 'flow' : 'script' @@ -461,6 +467,7 @@ {#snippet saveButton()} {#if !drawerLoading} + clearPageDrawerAnchor(TRIGGER_PAGES.email.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -136,6 +141,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.gcp.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -255,13 +261,7 @@ if (!cfg) { return } - const isSaved = await saveGcpTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveGcpTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getGcpConfig()) onUpdate?.(cfg.path) @@ -368,7 +368,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.gcp.path)} + > import { Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -224,6 +229,7 @@ }, 100) // if loading takes less than 100ms, we don't show the loader try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.http.path, ePath) initialPath = ePath path = ePath itemKind = isFlow ? 'flow' : 'script' @@ -362,8 +368,8 @@ return { noDeployed: !!(s as any)?.no_deployed, overlay: draftFromBackend - ? ({ ...deployedTrigger, ...draftFromBackend } as Record) - : undefined + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined } } @@ -985,6 +991,7 @@ {#snippet saveButton()} {#if !drawerLoading} + clearPageDrawerAnchor(TRIGGER_PAGES.http.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -159,6 +164,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.kafka.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -412,7 +418,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.kafka.path)} + > import { untrack } from 'svelte' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import { Alert, Button } from '$lib/components/common' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -154,6 +159,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.mqtt.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -317,13 +323,7 @@ deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() - const isSaved = await saveMqttTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveMqttTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) @@ -392,7 +392,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.mqtt.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -142,6 +147,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.nats.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -296,13 +302,7 @@ deploymentLoading = true const previousPath = initialPath const cfg = natsConfig - const isSaved = await saveNatsTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveNatsTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) @@ -389,7 +389,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.nats.path)} + > import { Alert, Button, TabContent } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -243,6 +248,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.postgres.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -567,7 +573,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.postgres.path)} + > import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -165,6 +170,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(SCHEDULES_PATH, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' path = defaultCfg?.path ?? ePath @@ -729,6 +735,7 @@ {#snippet saveButton()} {#if !drawerLoading} + clearPageDrawerAnchor(SCHEDULES_PATH)}> import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' @@ -137,6 +142,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.sqs.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -306,13 +312,7 @@ deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() - const isSaved = await saveSqsTriggerFromCfg( - initialPath, - cfg, - edit, - wsId!, - usedTriggerKinds - ) + const isSaved = await saveSqsTriggerFromCfg(initialPath, cfg, edit, wsId!, usedTriggerKinds) if (isSaved) { draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) @@ -371,7 +371,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.sqs.path)} + > import { Alert, Button } from '$lib/components/common' + import { + clearPageDrawerAnchor, + setPageDrawerAnchor + } from '$lib/components/sessions/pageDrawerSession' + import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' import TextInput from '$lib/components/text_input/TextInput.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -180,6 +185,7 @@ drawerLoading = true try { drawer?.openDrawer() + setPageDrawerAnchor(TRIGGER_PAGES.websocket.path, ePath) initialPath = ePath itemKind = isFlow ? 'flow' : 'script' edit = true @@ -453,7 +459,11 @@ {/if} {#if useDrawer} - + clearPageDrawerAnchor(TRIGGER_PAGES.websocket.path)} + > Edit + {#if showEditButton} + + + {/if} {/if} {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} +
diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index d775c1d212..03b410d232 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -48,6 +48,7 @@ import { twMerge } from 'tailwind-merge' import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte' import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte' + import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte' import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils' import BatchReRunOptionsPane, { type BatchReRunOptions @@ -934,35 +935,16 @@
{#if batchProgress} -
- Loading jobs: {batchProgress.loaded} of {batchProgress.total}... -
-
-
- {#if currentBatchSize != null} - Batch size: - { - const v = parseInt(e.currentTarget.value) - if (v >= 1 && v <= 1000) { - jobsLoader.restreamWithBatchSize(v) - } - }} - /> - {/if} - +
+ jobsLoader.restreamWithBatchSize(v)} + onStop={() => jobsLoader.stopBatchLoading()} + />
{/if} diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index c367bce01c..6bf71c65fd 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -21,24 +21,21 @@ import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte' import { type AuditLog, - AuditService, ResourceService, UserService, ScriptService, FlowService, - AppService, - CancelError + AppService } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' import { ChevronDown, Download, Loader2, RefreshCcw } from 'lucide-svelte' - import { onDestroy, untrack } from 'svelte' + import { onDestroy, onMount, untrack } from 'svelte' import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' import Select from '../select/Select.svelte' import { usePromise } from '$lib/svelte5Utils.svelte' import { safeSelectItems } from '../select/utils.svelte' - import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils' import { sendUserToast } from '$lib/toast' let usernames: string[] | undefined = $state() @@ -48,7 +45,6 @@ logs?: AuditLog[] username?: string pageIndex?: number | undefined - hasMore?: boolean before?: string | undefined after?: string | undefined perPage?: number | undefined @@ -57,13 +53,13 @@ actionKind?: ActionKind | 'all' scope?: undefined | 'all_workspaces' | 'instance' loading?: boolean + onRefresh?: () => void } let { - logs = $bindable(undefined), + logs = undefined, username = $bindable('all'), pageIndex = $bindable(1), - hasMore = $bindable(false), before = $bindable(undefined), after = $bindable(undefined), perPage = $bindable(100), @@ -71,13 +67,11 @@ resource = $bindable() as string | undefined, actionKind = $bindable(undefined), scope = $bindable(undefined), - loading = $bindable(false) + loading = false, + onRefresh }: Props = $props() $effect.pre(() => { - if (logs == undefined) { - logs = [] - } if (operation == undefined) { operation = 'all' } @@ -89,47 +83,6 @@ } }) - function loadLogs() { - loading = true - - let username_ = username == 'all' ? undefined : username - let operation_ = operation == 'all' || operation == '' ? undefined : operation - let actionKind_ = actionKind == 'all' ? undefined : actionKind - let resource_ = resource == 'all' || resource == '' ? undefined : resource - - let _promise = AuditService.listAuditLogs({ - workspace: scope === 'instance' ? 'global' : $workspaceStore!, - page: pageIndex, - perPage, - before, - after, - username: username_, - operation: operation_, - resource: resource_, - actionKind: actionKind_, - allWorkspaces: scope === 'all_workspaces' - }) - let promise = CancelablePromiseUtils.map(_promise, (value) => { - logs = value - hasMore = !logs || (logs.length > 0 && logs.length === perPage) - loading = false - }) - promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => { - sendUserToast( - 'Loading audit logs is taking longer than expected...', - 'warning', - perPage > 25 - ? [{ label: 'Reduce to 25 items per page', callback: () => (perPage = 25) }] - : [] - ) - }) - promise = CancelablePromiseUtils.catchErr(promise, (e) => { - if (e instanceof CancelError) return CancelablePromiseUtils.pure(undefined) - return CancelablePromiseUtils.err(e) - }) - return promise - } - async function loadUsers() { usernames = $userStore?.is_admin || $userStore?.is_super_admin @@ -277,9 +230,6 @@ WORKSPACES_DELETE: 'workspaces.delete' } - let refresh = $state(0) - let lastRefresh = $state(-1) - function downloadAuditLogsAsJson() { if (!logs || logs.length === 0) { sendUserToast('No audit logs to download', true) @@ -302,19 +252,15 @@ URL.revokeObjectURL(url) } - // observe all the variables that should trigger an update + onMount(() => { + loadUsers() + resources.refresh() + }) + + // observe all the variables that should be reflected in the url $effect(() => { - ;[refresh, username, perPage, before, after, operation, resource, actionKind, scope, pageIndex] - return untrack(() => { - if (refresh !== lastRefresh) { - loadUsers() - resources.refresh() - lastRefresh = refresh - } - updateQueryParams() - let promise = loadLogs() - return () => promise?.cancel() - }) + ;[username, perPage, before, after, operation, resource, actionKind, scope, pageIndex] + untrack(() => updateQueryParams()) }) @@ -476,7 +422,9 @@
+ {#if batchProgress} +
+ onBatchSizeChange?.(size)} + onStop={() => onStopLoading?.()} + /> +
+ {/if}
Per page: - - +
{#if status === 'idle'} - + {#if noOAuth} +
{server.name} did not advertise OAuth support.
+ {/if} + {:else if status === 'discovering'} -
Discovering OAuth settings...
+
Checking what {server.name} supports...
{:else if status === 'discovered' && discoveryResult} -
- ✓ OAuth supported - {#if discoveryResult.supports_dynamic_registration} - (Dynamic Client Registration available) - {/if} -
- {#if discoveryResult.scopes_supported && discoveryResult.scopes_supported.length > 0} -
+ {#if resources} + {@const nTruncated = resources.filter((r) => r.truncated).length} + {#if nTruncated > 0} + +
+ {nTruncated} of those resources {nTruncated === 1 ? 'is' : 'are'} too large to search in full + — only {nTruncated === 1 ? 'its' : 'their'} beginning is matched. +
+ {/if} + {/if}
@@ -269,6 +285,15 @@ on:close > {#snippet actions()} + {#if item.truncated} + + Truncated + {#snippet text()} + This resource is too large to search in full: only its beginning is matched + and shown. + {/snippet} + + {/if} From 64d78b4db1d7d939c598c86d1998218d52fbcc21 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 10:45:25 +0200 Subject: [PATCH 326/400] fix: fall back to polling when a proxy mutes the job SSE stream (#10716) * fix: fall back to polling when a proxy mutes the job SSE stream * fix: do not charge deliberate no-logs sse restarts to the retry budget --- frontend/src/lib/components/JobLoader.svelte | 48 +++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index ec9e4b693c..71bd2fbb51 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -87,6 +87,7 @@ let finished: string[] = [] let ITERATIONS_BEFORE_SLOW_REFRESH = 10 let ITERATIONS_BEFORE_SUPER_SLOW_REFRESH = 100 + const MAX_SSE_ATTEMPTS = 3 let lastStartedAt: number = Date.now() let currentId: string | undefined = $state(undefined) @@ -179,7 +180,7 @@ lastCompletedJobId = undefined clearCurrentJob() lastCallbacks = callbacks - noPingTimeout = undefined + clearNoPingTimeout() const startedAt = Date.now() const testId = await fn() @@ -669,16 +670,30 @@ } } - function setNoPingTimeout(id: string, attempt: number, callbacks?: Callbacks) { + function clearNoPingTimeout() { if (noPingTimeout) { clearTimeout(noPingTimeout) + noPingTimeout = undefined } + } + + function setNoPingTimeout(id: string, attempt: number, callbacks?: Callbacks) { + clearNoPingTimeout() if (isCurrentJob(id)) { noPingTimeout = setTimeout(() => { + noPingTimeout = undefined if (isCurrentJob(id)) { currentEventSource?.close() currentEventSource = undefined - loadTestJobWithSSE(id, attempt + 1, callbacks) + // A proxy that buffers the response rather than cutting it keeps the + // connection open and error-free, so this watchdog is the only signal that + // no event is getting through. It has to share the retry budget: otherwise + // it reopens an equally mute stream forever and polling is never reached. + if (attempt < MAX_SSE_ATTEMPTS) { + loadTestJobWithSSE(id, attempt + 1, callbacks) + } else { + syncer(id, callbacks) + } } }, 10000) } @@ -841,10 +856,7 @@ if (previewJobUpdates.completed) { currentEventSource?.close() currentEventSource = undefined - if (noPingTimeout) { - clearTimeout(noPingTimeout) - noPingTimeout = undefined - } + clearNoPingTimeout() isCompleted = true if (onlyResult) { callbacks?.doneResult?.({ @@ -869,16 +881,26 @@ console.warn('SSE error:', error) currentEventSource?.close() currentEventSource = undefined + clearNoPingTimeout() let delay = 1000 let isNoLogsChange = error.type == noLogsChangeRestartEvent if (isNoLogsChange) { delay = 0 } - if (attempt < 3 || isNoLogsChange) { + if (attempt < MAX_SSE_ATTEMPTS || isNoLogsChange) { if (!isNoLogsChange) { - console.log(`SSE error (1), retrying ... attempt: ${attempt + 1}/3`) + console.log( + `SSE error (1), retrying ... attempt: ${attempt + 1}/${MAX_SSE_ATTEMPTS}` + ) } - setTimeout(() => loadTestJobWithSSE(id, attempt + 1, callbacks), delay) + // A no-logs restart is deliberate (the caller wants a stream with different + // query args), not a failure, so it must not consume the retry budget: + // toggling the flow graph tab would otherwise exhaust it in a few clicks + // and strand a healthy stream on polling. + setTimeout( + () => loadTestJobWithSSE(id, isNoLogsChange ? attempt : attempt + 1, callbacks), + delay + ) } else { // Fall back to polling on error setTimeout(() => syncer(id, callbacks), 1000) @@ -901,9 +923,10 @@ // Fall back to polling on error currentEventSource?.close() currentEventSource = undefined + clearNoPingTimeout() - if (attempt < 3) { - console.log(`SSE error (2), retrying ... attempt: ${attempt}/3`) + if (attempt < MAX_SSE_ATTEMPTS) { + console.log(`SSE error (2), retrying ... attempt: ${attempt}/${MAX_SSE_ATTEMPTS}`) attempt++ loadTestJobWithSSE(id, attempt, callbacks) } else { @@ -942,6 +965,7 @@ clearCurrentId() currentEventSource?.close() currentEventSource = undefined + clearNoPingTimeout() replayTimeouts.forEach(clearTimeout) replayTimeouts = [] }) From 010d67e07f2f036e808507e394a9a9fd2ee720ce Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 11:08:47 +0200 Subject: [PATCH 327/400] chore(main): release 1.790.1 (#10712) * chore(main): release 1.790.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 13 ++ backend/Cargo.lock | 158 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 20 files changed, 136 insertions(+), 123 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c1824f4c07..deefa1024a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.790.0" + ".": "1.790.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 40089082af..fae181899c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.790.1](https://github.com/windmill-labs/windmill/compare/v1.790.0...v1.790.1) (2026-08-17) + + +### Bug Fixes + +* fall back to polling when a proxy mutes the job SSE stream ([#10716](https://github.com/windmill-labs/windmill/issues/10716)) ([64d78b4](https://github.com/windmill-labs/windmill/commit/64d78b4db1d7d939c598c86d1998218d52fbcc21)) + + +### Performance Improvements + +* cap resource content sent to the search modal ([#10714](https://github.com/windmill-labs/windmill/issues/10714)) ([529e960](https://github.com/windmill-labs/windmill/commit/529e9606297ee0b41456a66222f31409d7bc7669)) +* unblock workers before the API router is built ([#10711](https://github.com/windmill-labs/windmill/issues/10711)) ([0258f3f](https://github.com/windmill-labs/windmill/commit/0258f3f81b96bb8d4e343ba8aeba614f9c836579)) + ## [1.790.0](https://github.com/windmill-labs/windmill/compare/v1.789.0...v1.790.0) (2026-08-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index eec483d9e1..dd9885761d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14665,7 +14665,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-nats", @@ -14750,7 +14750,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.790.0" +version = "1.790.1" dependencies = [ "async-stream", "async-trait", @@ -14783,7 +14783,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14796,7 +14796,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "argon2", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14976,7 +14976,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15002,7 +15002,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.790.0" +version = "1.790.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15029,7 +15029,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15051,7 +15051,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15074,7 +15074,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15090,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15133,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15147,7 +15147,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-nats", @@ -15182,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15207,7 +15207,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15235,7 +15235,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15277,7 +15277,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15315,7 +15315,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.790.0" +version = "1.790.1" dependencies = [ "lazy_static", "serde", @@ -15355,7 +15355,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.790.0" +version = "1.790.1" dependencies = [ "argon2", "axum 0.8.9", @@ -15379,7 +15379,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15393,7 +15393,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.790.0" +version = "1.790.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.790.0" +version = "1.790.1" dependencies = [ "chrono", "lazy_static", @@ -15442,7 +15442,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15461,7 +15461,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.790.0" +version = "1.790.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -15565,7 +15565,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.790.0" +version = "1.790.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -15584,7 +15584,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.790.0" +version = "1.790.1" dependencies = [ "regex", "serde", @@ -15599,7 +15599,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15623,7 +15623,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "futures", @@ -15640,7 +15640,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.790.0" +version = "1.790.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15656,7 +15656,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -15677,7 +15677,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -15708,7 +15708,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "arc-swap", @@ -15733,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-stream", @@ -15767,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "futures", @@ -15785,7 +15785,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.790.0" +version = "1.790.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -15794,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15806,7 +15806,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -15818,7 +15818,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "gosyn", @@ -15830,7 +15830,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15842,7 +15842,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -15854,7 +15854,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "nu-parser", @@ -15865,7 +15865,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15876,7 +15876,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15888,7 +15888,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15899,7 +15899,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -15921,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -15933,7 +15933,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15947,7 +15947,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15964,7 +15964,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -15977,7 +15977,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde", @@ -15989,7 +15989,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -16007,7 +16007,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16023,7 +16023,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16039,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -16053,7 +16053,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -16092,7 +16092,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "const_format", @@ -16132,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.790.0" +version = "1.790.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16143,7 +16143,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -16178,7 +16178,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16202,7 +16202,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16235,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16262,7 +16262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16295,7 +16295,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16315,7 +16315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16349,7 +16349,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16385,7 +16385,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16408,7 +16408,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16432,7 +16432,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-nats", @@ -16456,7 +16456,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16491,7 +16491,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16519,7 +16519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-trait", @@ -16544,7 +16544,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16563,7 +16563,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-once-cell", @@ -16679,7 +16679,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.790.0" +version = "1.790.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f19861b16e..9905d35af5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.790.0" +version = "1.790.1" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.790.0" +version = "1.790.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 9866121d20..5eb3df3fcc 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.790.0" +version = "1.790.1" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.790.0" +version = "1.790.1" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.790.0" +version = "1.790.1" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.790.0" +version = "1.790.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 10cace3b33..2e76d34920 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.790.0" +version = "1.790.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ec6f722cdf..e7cad13be4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.790.0 + version: 1.790.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 0bd4c7b7cf..8319d03899 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.790.0"; +export const VERSION = "v1.790.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 1c69b06149..784c6f95a7 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.790.0"; +export const VERSION = "1.790.1"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 89ac3d10a6..6eecdc3d5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.790.0", + "version": "1.790.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.790.0", + "version": "1.790.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 924ad3b285..0a8873c35a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.790.0", + "version": "1.790.1", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 61a941b2d4..e9b02f2495 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.790.0" +wmill = ">=1.790.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index f34ccf89c4..3608240c2c 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.790.0 + version: 1.790.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 12bedba8ab..60e8ea7b54 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.790.0' + ModuleVersion = '1.790.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index c8049b1786..998c454d0e 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.790.0" +version = "1.790.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 9374384495..7e0927e66d 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.790.0", + "version": "1.790.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index ac6ce61c9f..1f40861c21 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.790.0", + "version": "1.790.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 9d16d96806..4bd8559201 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.790.0 +1.790.1 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 110f8fe51d..2f1abcdff9 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.790.0", + "version": "1.790.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.790.0", + "version": "1.790.1", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 4b8f77485c..123dbf2980 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.790.0", + "version": "1.790.1", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From ab3c0206d7e9b32676d99ed0cd8c9d8939122584 Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:30:25 +0200 Subject: [PATCH 328/400] fix: support @typechecked decorator in Python relative imports (#8495) WindmillFinder's ModuleSpec lacked origin, so __file__ was never set on loaded modules. inspect.getfile() then raised "is a built-in module", breaking typeguard's @typechecked and anything else that introspects module source. Use spec_from_file_location() which sets origin correctly. Co-authored-by: Claude Opus 4.6 Co-authored-by: hugocasa --- backend/tests/fixtures/typechecked_python.sql | 20 +++++++++ backend/tests/python_jobs.rs | 44 +++++++++++++++++++ backend/windmill-worker/loader.py | 5 ++- 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 backend/tests/fixtures/typechecked_python.sql diff --git a/backend/tests/fixtures/typechecked_python.sql b/backend/tests/fixtures/typechecked_python.sql new file mode 100644 index 0000000000..e6350c22f3 --- /dev/null +++ b/backend/tests/fixtures/typechecked_python.sql @@ -0,0 +1,20 @@ +INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( +'test-workspace', +'test-user', +' +import inspect +import sys + +def greet(name: str) -> str: + # Verify that __file__ is set on this module (same check typeguard does) + mod = sys.modules[__name__] + source_file = inspect.getfile(mod) + return f"Hello, {name}! from {source_file}" + +def main(): + return greet("World") +', +'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', +'', +'', +'f/system/typechecked_helper', 12349, 'python3', ''); diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index eae163d675..b7aa671b21 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1241,6 +1241,50 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool) -> anyhow::Res Ok(()) } +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base", "typechecked_python"))] +async fn test_typechecked_decorator_python(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +from f.system.typechecked_helper import greet + +def main(): + return greet("World") +"# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: Some("f/system/test_typechecked".to_string()), + language: ScriptLang::Python3, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port) + .await + .json_result() + .unwrap(); + + let result_str = result.as_str().unwrap(); + assert!( + result_str.starts_with("Hello, World! from "), + "unexpected result: {result_str}" + ); + Ok(()) +} + /// End-to-end comparison between the legacy `step()` suspend-and-replay path /// and the new SDK inline-persist fast path, toggled per-job via the /// `WM_WAC_INLINE_FAST_PATH` env var which the Python script sets on its own diff --git a/backend/windmill-worker/loader.py b/backend/windmill-worker/loader.py index d3dc7b8a66..ab10c36b86 100644 --- a/backend/windmill-worker/loader.py +++ b/backend/windmill-worker/loader.py @@ -2,6 +2,7 @@ import sys import os from importlib.abc import MetaPathFinder, Loader from importlib.machinery import ModuleSpec, SourceFileLoader +from importlib.util import spec_from_file_location import time # Injected by backend: maps script path -> temp storage hash so preview jobs @@ -38,7 +39,7 @@ class WindmillFinder(MetaPathFinder): fullpath = folder + "/" + splitted[-1] + ".py" if os.path.exists(fullpath): - return ModuleSpec(name, SourceFileLoader(name, fullpath)) + return spec_from_file_location(name, fullpath) import urllib.parse @@ -70,7 +71,7 @@ class WindmillFinder(MetaPathFinder): return ModuleSpec(name, WindmillLoader(name)) with open(fullpath, "w+") as f: f.write(r) - return ModuleSpec(name, SourceFileLoader(name, fullpath)) + return spec_from_file_location(name, fullpath) except urllib.error.HTTPError as e: duration = time.time() - req_start if e.code != 404: From 66e3790da433eda955699caf20d988cb20d7ee7f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 18:27:37 +0200 Subject: [PATCH 329/400] docs: announce we are not seeking outside contribution (#10724) * docs: announce we are not seeking outside contribution Co-Authored-By: Claude Opus 5 (1M context) * docs: point big ideas at the feature request template Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 14 +++++++++++++ .github/workflows/sign-cla.yml | 8 +++++++- CONTRIBUTING.md | 34 ++++++++++++++++++++++++++++++++ README.md | 9 ++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..0e022ec801 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ + + +## What does this PR do? + +## Related issue diff --git a/.github/workflows/sign-cla.yml b/.github/workflows/sign-cla.yml index 67822542a9..52329b6119 100644 --- a/.github/workflows/sign-cla.yml +++ b/.github/workflows/sign-cla.yml @@ -21,9 +21,15 @@ jobs: PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }} with: path-to-signatures: "signatures/cla.json" - path-to-document: "https://github.com/windmill-labs/windmill/blob/master/CLA.md" + path-to-document: "https://github.com/windmill-labs/windmill/blob/main/CLA.md" branch: "signatures" allowlist: rubenfiszel,bot* + custom-notsigned-prcomment: | + Thank you for taking the time to open this PR. + + Please note that **we are not seeking outside contribution at this time**. Small, trivially-verified PRs that fix a problem are still accepted, but low-value PRs (e.g. typo fixes) and PRs longer than a dozen or so lines will be closed. If you have a bigger idea, please open a [feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md) instead. See [CONTRIBUTING.md](https://github.com/windmill-labs/windmill/blob/main/CONTRIBUTING.md) for the full policy. + + If your PR falls within that scope, we ask that you sign our [Contributor License Agreement](https://github.com/windmill-labs/windmill/blob/main/CLA.md) before we can accept it. You can sign the CLA by just posting a Pull Request Comment same as the below format. #below are the optional inputs - If the optional inputs are not given, then default values will be taken #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..e3bc91fd77 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing to Windmill + +At this time, we are not seeking outside contribution. + +AI has made writing code easy. The hard part, today, is not writing the code, but reviewing it, +making sure quality stays high, and keeping the product coherent. In that light, unfortunately, +external code contributions are "donating" the easy part of the job, while creating more of the +hard work. + +With that said, we are happy to accept small, trivially-verified PRs that fix a problem. However, +we ask that you refrain from submitting low-value PRs (e.g. typo fixes) or PRs that are more than a +dozen or so lines. Such PRs will be closed with a reference to this guideline. + +If you have a big idea you'd like us to consider, feel free to open a +[feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md) +about it. + +This policy may change in the future as the project matures. Until then, thank you for your +understanding. + +## What is still very welcome + +- [Bug reports](https://github.com/windmill-labs/windmill/issues/new?template=bug_report.yml), with + clear reproduction steps. +- [Feature requests](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md), + including for ideas too big to be a PR. +- Questions and feedback on [Discord](https://discord.gg/V7PM2YHsPB). +- Contributions to the [Windmill Hub](https://hub.windmill.dev), where scripts, flows and apps are + shared with the community. + +## If you do open a PR + +Small, self-contained fixes are still accepted. They require signing the +[CLA](./CLA.md), which the CLA bot will prompt for on your first PR. diff --git a/README.md b/README.md index d075becce1..71d4d39a19 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together

- Try it - Website - Docs - Discord - Hub - Contributor's guide + Try it - Website - Docs - Discord - Hub - Contributing

# Windmill - Developer platform for APIs, background jobs, workflows and UIs @@ -62,6 +62,7 @@ https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4 - [Run a local dev setup](#run-a-local-dev-setup) - [Frontend only](#frontend-only) - [Backend + Frontend](#backend--frontend) + - [Contributing](#contributing) - [Contributors](#contributors) - [Copyright](#copyright) @@ -329,6 +330,12 @@ running options. 2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor. 7. Windmill should be available at `http://localhost:3000` +## Contributing + +At this time, we are not seeking outside contribution. Bug reports and feature requests remain very +welcome, and small, trivially-verified PRs that fix a problem are still accepted. See +[CONTRIBUTING.md](./CONTRIBUTING.md) for the full policy. + ## Contributors From 05eba6c9ab078cdedc87f197549dbdbc4b360fe3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 17 Aug 2026 21:11:19 +0200 Subject: [PATCH 330/400] fix: include delete_after_secs in script deploy payload (#10731) --- frontend/src/lib/components/ScriptBuilder.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 9ce8c17022..5c6cb3e5a8 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -700,6 +700,7 @@ ws_error_handler_muted: script.ws_error_handler_muted, priority: script.priority, restart_unless_cancelled: script.restart_unless_cancelled, + delete_after_secs: script.delete_after_secs, timeout: script.timeout, concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, From 66bffaa60d48f992e56e459efb813b24e3942610 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 17 Aug 2026 21:14:11 +0200 Subject: [PATCH 331/400] feat: add empty state cards to list pages (#10726) * feat: add empty state cards to list pages Co-Authored-By: Claude Opus 5 (1M context) * fix: animate trigger drawers on first open Co-Authored-By: Claude Opus 5 (1M context) * fix: distinguish filtered-empty schedules, reuse the rAF helper Co-Authored-By: Claude Opus 5 (1M context) * fix: hide the header create button while the empty state offers it Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix: hide the header create button while the empty state offers it" This reverts commit 98c57eede34710c7d8f4342831ff650dbc0665f1. Co-Authored-By: Claude Opus 5 (1M context) * fix: use the default variant for the empty state button Co-Authored-By: Claude Opus 5 (1M context) * refactor: share hasActiveFilters from the filter searchbar module Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../src/lib/components/FilterSearchbar.svelte | 11 + .../common/emptyState/EmptyState.svelte | 49 ++ frontend/src/lib/components/common/index.ts | 2 + .../lib/components/common/tabs/TabFade.svelte | 26 + .../chat/CreatedResourceActionDrawers.svelte | 15 +- .../triggers/amqp/AmqpTriggerEditor.svelte | 6 +- .../triggers/azure/AzureTriggerEditor.svelte | 6 +- .../triggers/email/EmailTriggerEditor.svelte | 6 +- .../triggers/gcp/GcpTriggerEditor.svelte | 6 +- .../triggers/http/RouteEditor.svelte | 6 +- .../triggers/kafka/KafkaTriggerEditor.svelte | 6 +- .../triggers/mqtt/MqttTriggerEditor.svelte | 6 +- .../triggers/nats/NatsTriggerEditor.svelte | 6 +- .../postgres/PostgresTriggerEditor.svelte | 6 +- .../triggers/schedules/ScheduleEditor.svelte | 6 +- .../triggers/sqs/SqsTriggerEditor.svelte | 6 +- .../triggers/webhook/WebhookEditor.svelte | 5 +- .../websocket/WebsocketTriggerEditor.svelte | 6 +- frontend/src/lib/utils/paint.ts | 32 + .../(logged)/amqp_triggers/+page.svelte | 15 +- .../(logged)/azure_triggers/+page.svelte | 15 +- .../(logged)/email_triggers/+page.svelte | 17 +- .../(root)/(logged)/folders/+page.svelte | 313 +++---- .../(root)/(logged)/gcp_triggers/+page.svelte | 15 +- .../(logged)/kafka_triggers/+page.svelte | 15 +- .../(logged)/mqtt_triggers/+page.svelte | 15 +- .../[service_name]/+page.svelte | 26 +- .../(logged)/nats_triggers/+page.svelte | 15 +- .../(logged)/postgres_triggers/+page.svelte | 15 +- .../(root)/(logged)/resources/+page.svelte | 781 ++++++++++-------- .../(root)/(logged)/routes/+page.svelte | 15 +- .../(root)/(logged)/schedules/+page.svelte | 28 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 15 +- .../(root)/(logged)/variables/+page.svelte | 615 +++++++------- .../(logged)/websocket_triggers/+page.svelte | 15 +- 35 files changed, 1261 insertions(+), 881 deletions(-) create mode 100644 frontend/src/lib/components/common/emptyState/EmptyState.svelte create mode 100644 frontend/src/lib/components/common/tabs/TabFade.svelte create mode 100644 frontend/src/lib/utils/paint.ts diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 537e76ceff..5078e0a83b 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -54,6 +54,17 @@ ? T['options'][number]['value'] | `!${T['options'][number]['value']}` : T['options'][number]['value'] + /** + * Whether any filter currently narrows the result set — for pages that fetch + * server-side and so can't tell an empty workspace from an over-narrow filter. + * + * `false` does not count: a boolean filter that is off narrows nothing, and + * treating it as active makes an empty workspace look filtered. + */ + export function hasActiveFilters(val: Record): boolean { + return Object.values(val).some((v) => v !== undefined && v !== null && v !== '' && v !== false) + } + /** * Converts a FilterSchemaRec to a Zod schema for validation */ diff --git a/frontend/src/lib/components/common/emptyState/EmptyState.svelte b/frontend/src/lib/components/common/emptyState/EmptyState.svelte new file mode 100644 index 0000000000..8715065ad0 --- /dev/null +++ b/frontend/src/lib/components/common/emptyState/EmptyState.svelte @@ -0,0 +1,49 @@ + + +
+
+ +
+
+
{title}
+ {#if description} +
{description}
+ {/if} +
+ {#if action} + + + {/if} + {@render children?.()} +
diff --git a/frontend/src/lib/components/common/index.ts b/frontend/src/lib/components/common/index.ts index 4f390cfe57..531036c3de 100644 --- a/frontend/src/lib/components/common/index.ts +++ b/frontend/src/lib/components/common/index.ts @@ -7,6 +7,7 @@ export { default as UndoRedo } from './button/UndoRedo.svelte' export { default as NameIdTooltip } from './tooltip/NameIdTooltip.svelte' export { default as ClearableInput } from './clearableInput/ClearableInput.svelte' export { default as Drawer } from './drawer/Drawer.svelte' +export { default as EmptyState } from './emptyState/EmptyState.svelte' export { default as DrawerContent } from './drawer/DrawerContent.svelte' export { default as Kbd } from './kbd/Kbd.svelte' export { default as Menu } from './menu/Menu.svelte' @@ -15,6 +16,7 @@ export { default as SecondsInput } from './seconds/SecondsInput.svelte' export { default as Skeleton } from './skeleton/Skeleton.svelte' export { default as Tab } from './tabs/Tab.svelte' export { default as TabContent } from './tabs/TabContent.svelte' +export { default as TabFade } from './tabs/TabFade.svelte' export { default as Tabs } from './tabs/Tabs.svelte' export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte' export { default as FileInput } from './fileInput/FileInput.svelte' diff --git a/frontend/src/lib/components/common/tabs/TabFade.svelte b/frontend/src/lib/components/common/tabs/TabFade.svelte new file mode 100644 index 0000000000..a1d8b1a621 --- /dev/null +++ b/frontend/src/lib/components/common/tabs/TabFade.svelte @@ -0,0 +1,26 @@ + + + +
*]:col-start-1 [&>*]:row-start-1', clazz)}> + {#key key} + +
+ {@render children()} +
+ {/key} +
diff --git a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte index e25df2cc40..efd37403e9 100644 --- a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte +++ b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte @@ -1,6 +1,7 @@ +{#snippet newFolderPopover( + label: string, + placement: 'bottom' | 'bottom-end', + variant: 'accent' | 'default' +)} + + {#snippet trigger()} + + {/snippet} + {#snippet content({ close })} + handleKeyUp(e, () => close())} + placeholder="New folder name" + bind:value={newFolderName} + /> + +
+ +
+ {/snippet} +
+{/snippet} + @@ -131,164 +168,136 @@ New folder {:else} - - {#snippet trigger()} - - {/snippet} - {#snippet content({ close })} - handleKeyUp(e, () => close())} - placeholder="New folder name" - bind:value={newFolderName} - /> - -
- -
- {/snippet} -
+ {@render newFolderPopover('New folder', 'bottom-end', 'accent')} {/if}
- - - - Name - Labels - Scripts - Flows - Apps - Schedules - Variables - Resources - Participants - - - - - {#if folders === undefined} - {#each new Array(4) as _} - - - - - - {/each} - {:else} - {#if folders.length === 0} - - -
- No folders yet, create one -
-
- - {/if} + {#if folders?.length === 0} + + {#if !restricted} + {@render newFolderPopover('Add a folder', 'bottom', 'default')} + {/if} + + {:else} + + + + Name + Labels + Scripts + Flows + Apps + Schedules + Variables + Resources + Participants + + + + + {#if folders === undefined} + {#each new Array(4) as _} + + + + + + {/each} + {:else} + {#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)} + { + editFolderName = name + folderDrawer?.openDrawer() + }} + > + + {name} + {#if summary} +
+ {summary} + {/if} +
+ + {#if labels?.length} +
+ {#each labels.slice(0, 3) as label} + {label} + {/each} + {#if labels.length > 3} + 'Label: ' + l) + .join('\n')}>+{labels.length - 3} + {/if} +
+ {/if} +
+ - {#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)} - { - editFolderName = name - folderDrawer?.openDrawer() - }} - > - - {name} - {#if summary} -
- {summary} - {/if} -
- - {#if labels?.length} -
- {#each labels.slice(0, 3) as label} - {label} - {/each} - {#if labels.length > 3} - 'Label: ' + l) - .join('\n')}>+{labels.length - 3} - {/if} -
- {/if} -
- - - - - { - editFolderName = name - folderDrawer?.openDrawer() - } - }, - { - displayName: 'Publish to Hub', - icon: UploadCloud, - disabled: !($userStore?.is_admin || $userStore?.is_super_admin), - action: () => { - publishFolderName = name - hubDrawer?.openDrawer() - } - }, - { - displayName: `Delete${canWrite ? '' : ' (require owner permissions)'}`, - icon: Trash, - type: 'delete', - disabled: !canWrite, - action: async () => { - try { - await FolderService.deleteFolder({ - workspace: $workspaceStore ?? '', - name - }) - folders = folders?.filter((f) => f.name !== name) - } catch (e) { - sendUserToast(e.body, true) - loadFolders() + + + { + editFolderName = name + folderDrawer?.openDrawer() + } + }, + { + displayName: 'Publish to Hub', + icon: UploadCloud, + disabled: !($userStore?.is_admin || $userStore?.is_super_admin), + action: () => { + publishFolderName = name + hubDrawer?.openDrawer() + } + }, + { + displayName: `Delete${canWrite ? '' : ' (require owner permissions)'}`, + icon: Trash, + type: 'delete', + disabled: !canWrite, + action: async () => { + try { + await FolderService.deleteFolder({ + workspace: $workspaceStore ?? '', + name + }) + folders = folders?.filter((f) => f.name !== name) + } catch (e) { + sendUserToast(e.body, true) + loadFolders() + } } } - } - ]} - /> - -
- {/each} - {/if} - -
+ ]} + /> +
+ + {/each} + {/if} + +
+ {/if}
{/if} diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 21442cfdba..22234e0f04 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -383,7 +383,18 @@ {/each} {:else if !triggers?.length} -
No GCP Pub/Sub triggers
+ gcpTriggerEditor?.openNew(false), + aiId: 'gcp-triggers-empty-add', + aiDescription: 'Add GCP Pub/Sub trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte index 44cea41b51..6ee790818a 100644 --- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte @@ -21,7 +21,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -348,7 +348,18 @@ {/each} {:else if !triggers?.length} -
No Kafka triggers
+ kafkaTriggerEditor?.openNew(false), + aiId: 'kafka-triggers-empty-add', + aiDescription: 'Add Kafka trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, kafka_resource_path, topics, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte index 905d5b914f..8a6d84fe90 100644 --- a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -338,7 +338,18 @@ {/each} {:else if !triggers?.length} -
No MQTT triggers
+ mqttTriggerEditor?.openNew(false), + aiId: 'mqtt-triggers-empty-add', + aiDescription: 'Add MQTT trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte index fce11df6b9..f5e9a30dc5 100644 --- a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte @@ -18,8 +18,9 @@ import { userStore, workspaceStore, userWorkspaces, usedTriggerKinds } from '$lib/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' import PageHeader from '$lib/components/PageHeader.svelte' - import { Button, Alert, Skeleton } from '$lib/components/common' - import { LoaderCircle, Plus } from 'lucide-svelte' + import { Button, Alert, EmptyState, Skeleton } from '$lib/components/common' + import { LoaderCircle, Plus, Webhook } from 'lucide-svelte' + import { GithubIcon, GoogleIcon, NextcloudIcon } from '$lib/components/icons' import SearchItems from '$lib/components/SearchItems.svelte' import NoItemFound from '$lib/components/home/NoItemFound.svelte' import { page } from '$app/state' @@ -30,6 +31,11 @@ const serviceName = $derived(page.params.service_name as NativeServiceName) const serviceConfig = $derived(getServiceConfig(serviceName)) + const serviceIcons: Partial> = { + nextcloud: NextcloudIcon, + google: GoogleIcon, + github: GithubIcon + } let triggers: TriggerW[] = $state([]) let loading = $state(true) @@ -264,9 +270,19 @@ {/each} {:else if !triggers?.length} -
- No {serviceConfig?.serviceDisplayName || serviceName} triggers -
+ editor?.openNew(), + aiId: 'native-triggers-empty-add', + aiDescription: 'Add native trigger' + }} + /> {:else if items?.length} {/each} {:else if !triggers?.length} -
No NATS triggers
+ natsTriggerEditor?.openNew(false), + aiId: 'nats-triggers-empty-add', + aiDescription: 'Add NATS trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, nats_resource_path, subjects, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte index 193a3cb2ac..d1c07a7e75 100644 --- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -414,7 +414,18 @@ {/each} {:else if !triggers?.length} -
No postgres triggers
+ postgresTriggerEditor?.openNew(false), + aiId: 'postgres-triggers-empty-add', + aiDescription: 'Add Postgres trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { postgres_resource_path, publication_name, replication_slot_name, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 27fdd286b5..b4cf764cb7 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/state' import AppConnect from '$lib/components/AppConnectDrawer.svelte' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton, Tab } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton, Tab, TabFade } from '$lib/components/common' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' @@ -17,6 +17,7 @@ import { resourceTypesStore } from '$lib/components/resourceTypesStore' import SchemaViewer from '$lib/components/SchemaViewer.svelte' import FilterSearchbar, { + hasActiveFilters, useUrlSyncedFilterInstance, type FilterInstanceRec } from '$lib/components/FilterSearchbar.svelte' @@ -55,14 +56,18 @@ Braces, Building, Circle, + Database, FileUp, Link, + Palette, Pen, Plus, RotateCw, Save, + SearchX, Shield, - Trash + Trash, + Zap } from 'lucide-svelte' import { onMount, untrack } from 'svelte' import autosize from '$lib/autosize' @@ -627,6 +632,36 @@ let showTable = $derived( tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme' ) + + let activeFilters = $derived(hasActiveFilters(filters.val)) + + const emptyStates: Record = { + workspace: { + icon: Boxes, + title: 'No resources yet', + description: + 'Resources hold the connection settings and credentials your scripts, flows and apps use to reach external systems.' + }, + states: { + icon: Database, + title: 'No states yet', + description: + 'States appear here once a script stores data to keep it persistent between runs of the same trigger.' + }, + cache: { + icon: Zap, + title: 'No cached results yet', + description: + 'Cached results appear here once a flow step with caching enabled has run at least once.' + }, + theme: { + icon: Palette, + title: 'No themes yet', + description: + 'Themes are CSS for the legacy low-code app editor only. Add one from the CSS panel of an app — they cannot be created here.' + } + } + let emptyState = $derived(emptyStates[tab] ?? emptyStates.workspace) {#snippet extra()} - Theme are actually resources (but excluded from the Workspace tab for clarity). Theme - are used by the apps to customize their look and feel. + Themes are actually resources (but excluded from the Workspace tab for clarity). They + are CSS for the legacy low-code app editor only, and are added from the CSS panel of + an app rather than from this page. {/snippet} @@ -989,384 +1025,403 @@ {/if}
- {#if showTable} -
- {#if loading.resources} - - {#each new Array(6) as _} - - {/each} - {:else if filteredItems?.length == 0} -
-
No resources found
-
- Try changing the filters or creating a new resource -
-
- {:else} - - - - - Path - Resource type - Description - - - - - - {#if filteredItems} - {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft} - - - - - -
+ + {#if showTable} +
+ {#if loading.resources} + + {#each new Array(6) as _} + + {/each} + {:else if filteredItems?.length == 0} + {#if activeFilters} + + {:else} + appConnect?.open?.(), + aiId: 'resources-empty-add-resource', + aiDescription: 'Add resource' + } + : undefined} + /> + {/if} + {:else} + + + + + Path + Resource type + Description + + + + + + {#if filteredItems} + {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft} + + + + + +
+ { + handledHash = `#/resource/${path}` + resourceEditor?.initEdit?.(path) + }} + >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} + + {#if labels?.length} +
+ {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} +
+ {/if} + +
+ + { - handledHash = `#/resource/${path}` - resourceEditor?.initEdit?.(path) - }} - >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} - - {#if labels?.length} -
- {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} -
- {/if} - -
- - - { - const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) - if (linkedRt) { - resourceTypeViewerObj = { - rt: linkedRt.name, - //@ts-ignore - schema: linkedRt.schema, - description: linkedRt.description ?? '', - formatExtension: linkedRt.format_extension, - isFileset: linkedRt.is_fileset ?? false + const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) + if (linkedRt) { + resourceTypeViewerObj = { + rt: linkedRt.name, + //@ts-ignore + schema: linkedRt.schema, + description: linkedRt.description ?? '', + formatExtension: linkedRt.format_extension, + isFileset: linkedRt.is_fileset ?? false + } + resourceTypeViewer?.openDrawer?.() + } else { + sendUserToast( + `Resource type ${resource_type} not found in workspace.`, + true + ) } - resourceTypeViewer?.openDrawer?.() - } else { - sendUserToast( - `Resource type ${resource_type} not found in workspace.`, - true - ) - } - }} - > - - - - - - {removeMarkdown(truncate(description ?? '', 30))} - - - -
-
- {#if is_linked} - - - {#snippet text()} -
- This resource is linked with a variable of the same path. They are - deleted and renamed together. -
- {/snippet} -
- {/if} -
-
- {#if is_refreshed} - - - {#snippet text()} -
- The OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
- {/snippet} -
- {/if} -
- - {#if is_oauth} -
- {#if refresh_error} + }} + > + + + + + + {removeMarkdown(truncate(description ?? '', 30))} + + + +
+
+ {#if is_linked} - + {#snippet text()}
- Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
- {/snippet} -
- {:else if is_expired} - - - - {#snippet text()} -
- The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
- {/snippet} -
- {:else} - - - {#snippet text()} -
- The resource was connected through OAuth and the token is not - expired. + This resource is linked with a variable of the same path. They + are deleted and renamed together.
{/snippet}
{/if}
- {/if} -
-
- -
- {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} - - {/if} - { - shareModal?.openDrawer?.(path, 'resource') - } - }, - { - displayName: 'Edit', - icon: Pen, - disabled: !canWrite || !showCreateButtons, - action: () => { - resourceEditor?.initEdit?.(path) - } - }, - ...(!ws_specific && isDeployable('resource', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'resource') +
+ {#if is_refreshed} + + + {#snippet text()} +
+ The OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
+ {/snippet} +
+ {/if} +
+ + {#if is_oauth} +
+ {#if refresh_error} + + + {#snippet text()} +
+ Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
+ {/snippet} +
+ {:else if is_expired} + + + + {#snippet text()} +
+ The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
+ {/snippet} +
+ {:else} + + + {#snippet text()} +
+ The resource was connected through OAuth and the token is not + expired. +
+ {/snippet} +
+ {/if} +
+ {/if} +
+
+ +
+ {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} + + {/if} + { + shareModal?.openDrawer?.(path, 'resource') + } + }, + { + displayName: 'Edit', + icon: Pen, + disabled: !canWrite || !showCreateButtons, + action: () => { + resourceEditor?.initEdit?.(path) + } + }, + ...(!ws_specific && isDeployable('resource', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'resource') + } } - } - ] - : []), - { - displayName: 'Delete', - disabled: !canWrite || !showCreateButtons, - icon: Trash, - type: 'delete', - action: (event) => { - // TODO - // @ts-ignore - if (event?.shiftKey) { - deleteResource(path, account) - } else { - deleteIsLinked = is_linked ?? false - deletePath = path - deleteConfirmedCallback = () => { + ] + : []), + { + displayName: 'Delete', + disabled: !canWrite || !showCreateButtons, + icon: Trash, + type: 'delete', + action: (event) => { + // TODO + // @ts-ignore + if (event?.shiftKey) { deleteResource(path, account) + } else { + deleteIsLinked = is_linked ?? false + deletePath = path + deleteConfirmedCallback = () => { + deleteResource(path, account) + } } } - } - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RotateCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadResources() + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RotateCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $workspaceStore ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadResources() + } } - } - ] - : []) - ]} - /> -
- - {/each} - {/if} - - - {/if} -
- {:else if tab == 'types'} - {#if loading.types} - - {#each new Array(6) as _} - - {/each} - {:else if filteredResourceTypes?.length == 0} -
-
No resource types found
-
- Try changing the filters or creating a new resource type -
-
- {:else} -
- - - - Name - Description - - - - - {#if filteredResourceTypes} - {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} - - - { - resourceTypeViewerObj = { - rt: name, - //@ts-ignore - schema: schema, - description: description ?? '', - formatExtension: format_extension, - isFileset: is_fileset ?? false - } - - resourceTypeViewer?.openDrawer?.() - }} + ] + : []) + ]} + /> +
- - - - - - {removeMarkdown(truncate(description ?? '', 200))} - - - - {#if !canWrite} - - Shared globally - - This resource type is from the 'admins' workspace shared with all - workspaces - - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} -
- - -
- {:else} - - Non Editable - - Since resource types are shared with the whole workspace, only admins - can edit/delete them - - - {/if} -
- - {/each} - {/if} - - + + {/each} + {/if} + + + {/if}
+ {:else if tab == 'types'} + {#if loading.types} + + {#each new Array(6) as _} + + {/each} + {:else if filteredResourceTypes?.length == 0} +
+ +
+ {:else} +
+ + + + Name + Description + + + + + {#if filteredResourceTypes} + {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} + + + { + resourceTypeViewerObj = { + rt: name, + //@ts-ignore + schema: schema, + description: description ?? '', + formatExtension: format_extension, + isFileset: is_fileset ?? false + } + + resourceTypeViewer?.openDrawer?.() + }} + > + + + + + + {removeMarkdown(truncate(description ?? '', 200))} + + + + {#if !canWrite} + + Shared globally + + This resource type is from the 'admins' workspace shared with all + workspaces + + + {:else if $userStore?.is_admin || $userStore?.is_super_admin} +
+ + +
+ {:else} + + Non Editable + + Since resource types are shared with the whole workspace, only admins + can edit/delete them + + + {/if} +
+
+ {/each} + {/if} + +
+
+ {/if} {/if} - {/if} + {/if} diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte index 5f2a657480..53de5457d1 100644 --- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte @@ -22,7 +22,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Button, Skeleton } from '$lib/components/common' + import { Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -361,7 +361,18 @@ {/each} {:else if !triggers?.length} -
No routes
+ routeEditor?.openNew(false), + aiId: 'routes-empty-add', + aiDescription: 'Add route' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { summary, workspace_id, workspaced_route, mode, path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte index 7cbfffba7c..e403c981de 100644 --- a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte @@ -10,7 +10,7 @@ import { withForkConflictRetry } from '$lib/utils/forkConflict' import { base } from '$app/paths' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Badge, Button, Skeleton } from '$lib/components/common' + import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import Popover from '$lib/components/Popover.svelte' @@ -21,6 +21,7 @@ import Toggle from '$lib/components/Toggle.svelte' import { userStore, workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores' import { + Calendar, Circle, Copy, Eye, @@ -30,12 +31,14 @@ Pen, Play, Plus, + SearchX, Shield, Trash } from 'lucide-svelte' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' import FilterSearchbar, { + hasActiveFilters, useUrlSyncedFilterInstance } from '$lib/components/FilterSearchbar.svelte' import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter' @@ -245,6 +248,8 @@ }) ) let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema)) + + let activeFilters = $derived(hasActiveFilters(filters.val)) let allFolders = $derived( Array.from( new Set( @@ -373,7 +378,26 @@ {/each} {:else if !schedules?.length} -
No schedules
+ {#if activeFilters} + + {:else} + scheduleEditor?.openNew(false), + aiId: 'schedules-empty-add', + aiDescription: 'Add schedule' + }} + /> + {/if} {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index 41aa60d31b..1226fc2c0f 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -21,7 +21,7 @@ import { base } from '$app/paths' import { page } from '$app/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -332,7 +332,18 @@ {/each} {:else if !triggers?.length} -
No sqs triggers
+ sqsTriggerEditor?.openNew(false), + aiId: 'sqs-triggers-empty-add', + aiDescription: 'Add SQS trigger' + }} + /> {:else if items?.length}
{#each items.slice(0, nbDisplayed) as { path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index 787353bdf8..0a23656514 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -1,7 +1,16 @@ -{#if assets.value && assets.value.length > 0} +{#if assets.status === 'idle' || assets.status === 'loading'} + +{:else if assets.value && assets.value.assets.length > 0}
    - {#each assets.value ?? [] as asset} -
  • + {#each assets.value.assets as asset} +
  • {asset.path} @@ -90,17 +139,26 @@ })}
    - + {#if asset.access_type} + {formatAssetAccessType(asset.access_type)} + {/if} +
  • {/each}
+ {#if assets.value.truncated} +
+ This run touched more assets than are listed here. +
+ {/if} {:else} -
No assets found
+
+ No assets found + + Assets detected while a run executes are recorded asynchronously, and only the most recent + runs that touched an asset keep that record. + +
{/if} From 6783a396b144948fa60324eae888bc4a83917bc8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 18 Aug 2026 10:32:26 +0200 Subject: [PATCH 342/400] fix(api): document cache_ignore_s3_path on the Script read schema (#10742) Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-api/openapi.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f8579f9aa4..392229e05b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -26421,6 +26421,8 @@ components: type: integer cache_ttl: type: number + cache_ignore_s3_path: + type: boolean dedicated_worker: type: boolean ws_error_handler_muted: From 8492b4b4ba53b9061c479609e4c2cfe8d0e32427 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 18 Aug 2026 12:24:53 +0200 Subject: [PATCH 343/400] chore: prove scratch file ops per command segment (#10744) * fix(agents): prove scratch file ops per command segment Co-Authored-By: Claude Opus 5 (1M context) * docs: describe the checkout root in the scratch guidance Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): close two auto-allow holes in the scratch guards Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): keep redirects and chained writes off the allow path Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): never prove a command carrying a substitution or relative cd Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): treat sibling checkouts as separate roots Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): prove where a directory-form cp or mv actually lands Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): leave directory-form cp and mv unproved Co-Authored-By: Claude Opus 5 (1M context) * docs: state the one-write-per-line rule in the scratch guidance Co-Authored-By: Claude Opus 5 (1M context) * docs: prefer Edit/Write over shell edits in agent guidance Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): stop a failed cd from hiding the directory form Co-Authored-By: Claude Opus 5 (1M context) * refactor(agents): state the glob and cd rationale once Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 308 ++++++++++++++++++-------- .claude/hooks/guard-rm-outside-tmp.sh | 189 +++++++++------- .claude/hooks/lib-guarded-verb.sh | 191 ++++++++++++---- .claude/hooks/test-hooks.sh | 80 ++++++- AGENTS.md | 17 +- 5 files changed, 567 insertions(+), 218 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 665c23cf0a..87ce6541aa 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -1,17 +1,31 @@ #!/usr/bin/env bash -# PreToolUse allowance for scratch file ops: auto-allow a single, plain, single-line -# `mkdir` / `cp` / `mv` / `touch` / `chmod` / `tar` / `unzip` whose every path operand -# resolves under /tmp. Anything else makes no decision (exit 0) and falls back to the normal -# permission flow, except for `mv` and `chmod`: those get an explicit `ask`, the only prompt -# they get (see lib-guarded-verb.sh). +# PreToolUse allowance for scratch file ops: auto-allow `mkdir` / `cp` / `mv` / `touch` / +# `chmod` whose every path operand resolves inside one of the roots `path_class` recognizes — +# under /tmp, or inside a git working tree under $HOME — and `tar` / `unzip` confined to /tmp. +# Anything else makes no decision (exit 0) and falls back to the normal permission flow, except +# for `mv` and `chmod`: those get an explicit `ask`, the only prompt they get (see +# lib-guarded-verb.sh). +# +# The command is read one segment at a time, so chaining and line breaks carry no weight of +# their own: `cd /tmp/scratch && mv /tmp/a /tmp/b` is proved on the operands of the `mv`. A +# decision covers the whole command line, so `allow` is emitted only when every segment is one +# of these verbs proved here or a `cd` that resolved, AND exactly one of them writes (see the +# gate at the foot of this file — an earlier write can change what a later operand means). A +# line that mixes a proven op with some other command makes no decision instead and leaves that +# line to the normal permission flow, rather than waving an unexamined command through with it. # # This is a hook rather than an allow rule because permission rules match a command prefix, so # they can only constrain the FIRST operand. `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix, # and requiring every operand is the point. # -# Requiring the sources under /tmp too (not just the destination) keeps this from becoming a -# read-exfiltration path around the `Read(**/.env)` / `Read(**/secrets/**)` deny rules: a copy -# out of the project into /tmp would land the content somewhere `Read(/tmp/**)` allows. +# One operation may not straddle two roots, sources included, and a sibling checkout is a +# different root — `path_class` names the git tree, not just its kind. A copy out of a checkout +# into /tmp would be a read-exfiltration path around the `Read(**/secrets/**)` / `Read(**/*.pem)` +# deny rules, since the content lands where `Read(/tmp/**)` allows it to be read back, and one +# out of a repo the Read tool is not confined to would do the same for that repo. Keeping every +# operand of one operation inside a single root closes both without restating those rules here. +# The checkout root itself is what makes an in-repo `mv` or `chmod` auto-allowable: deleting a +# file there has never prompted, and moving or chmod-ing one is not the graver act. # # Deny-by-default tokenizing, in the same spirit as guard-rm-outside-tmp.sh: every path token # must consist only of alphanumerics and `. _ / -`. That set contains none of the characters @@ -19,12 +33,19 @@ # any glob character, so all of those forms fail by construction. `realpath -m` then resolves # `..` and existing symlinks, so `/tmp/link` pointing at /etc/passwd is caught. # +# `tar` and `unzip` keep the stricter rule — /tmp only, and absolute operands only — because +# their positional grammar makes a bare word ambiguous: `tar P -xf ...` is --absolute-names, +# not a file named P, and resolving it as a path would put an option in a root and allow it. +# The other five take relative operands, resolved against the working directory that `cd` +# tracking maintains, since for those a bare word really is a path (a GNU option starts with +# `-`, and the option allowlist below rejects the ones that would change symlink handling). +# # `tar` and `unzip` get their own parser: their write destination arrives as a flag VALUE # (`-C`, `-d`) rather than a positional, and a bundle like `-xzf` consumes the token after it. # Flags are an allowlist, not a denylist, so `-P` / `--absolute-names` — which turn off tar's # refusal to extract `..` and absolute member paths — defer rather than needing enumeration. -# Extraction additionally requires an explicit destination under /tmp, or a cwd already under -# /tmp, since otherwise members land in the project checkout. +# Extraction additionally requires an explicit destination under /tmp, or a working directory +# already under /tmp, since otherwise members land in the project checkout. # # Residual risk accepted: an archive whose members include a symlink pointing out of /tmp # followed by a write through it can still escape, because tar applies member symlinks as it @@ -52,25 +73,51 @@ defer() { exit 0 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; esac +has_substitution "$cmd" && defer "command substitution in the command line" -read -r -a toks <<< "$cmd" +# 0 iff the token is a literal path this hook may reason about. A glob never auto-allows: bash +# expands it only after the hook has decided, so realpath sees the unexpanded pattern — +# `/tmp/link*` canonicalizes to itself and passes, then expands onto a symlink whose target is +# outside, and `cp` and `chmod` follow a command-line symlink, so that is a write to the target. +# (guard-rm-outside-tmp.sh can allow globs because `rm` unlinks the symlink rather than following +# it.) The charset holds none of the characters bash uses for quoting, expansion or separation. +literal_path() { + case "$1" in *[*?[]*) return 1 ;; esac + [ -z "$(printf '%s' "$1" | tr -d 'A-Za-z0-9._/-')" ] +} -# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. +# Prints the root class of a path token, then the path it resolved to on a second line, +# resolving a relative one against the tracked working directory. Fails, printing nothing, +# when the token is unsafe to reason about or lands outside every root. +operand_class() { + local t="$1" canon alt cls alt_cls="" + literal_path "$t" || return 1 + case "$t" in + /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; + *) # A `cd` may fail at runtime and leave the command where it started, so a relative + # operand has to land in the same root either way. + [ -n "$seg_cwd" ] || return 1 + canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + if [ -n "$alt_cwd" ]; then + alt=$(realpath -m -- "$alt_cwd/$t" 2>/dev/null) + [ -n "$alt" ] || return 1 + alt_cls=$(path_class "$alt") || return 1 + fi + ;; + esac + [ -n "$canon" ] || return 1 + cls=$(path_class "$canon") || return 1 + [ -n "$alt_cls" ] && [ "$alt_cls" != "$cls" ] && return 1 + # Class and resolved path together: a caller runs this in a command substitution, so a global + # set here would be set in that subshell and lost. + printf '%s\n%s' "$cls" "$canon" +} + +# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive +# parser's stricter check; everything else goes through operand_class. under_tmp() { local t="$1" canon - # Globs never auto-allow. Bash expands them only after this hook has decided, so realpath - # sees the unexpanded pattern: `/tmp/link*` canonicalizes to itself and passes, then - # expands onto a symlink whose target is outside /tmp. chmod and cp follow command-line - # symlinks, so that is a write to the target. guard-rm-outside-tmp.sh can allow globs - # because `rm` unlinks the symlink itself rather than following it. - case "$t" in *[*?[]*) return 1 ;; esac - [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 - # Absolute only. Resolving a relative operand against the cwd makes any bare word look like - # a safe path whenever the cwd is under /tmp, while the tool itself reads it as an option: - # `tar P -xf ...` is --absolute-names, not ./P, and `cp /tmp/t -RL /tmp/o` is a - # dereferencing recursive copy, not a file named -RL. + literal_path "$t" || return 1 case "$t" in /*) ;; *) return 1 ;; esac canon=$(realpath -m -- "$t" 2>/dev/null) [ -n "$canon" ] || return 1 @@ -79,29 +126,16 @@ under_tmp() { return 1 } -# Bare command word only; wrappers (`timeout cp`), env prefixes, and `/bin/cp` defer. -# Options are an allowlist per command, so anything that changes how symlinks are followed -# defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while -# recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch -# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate -# such a symlink as a symlink instead, so no outside content is materialized. -case "${toks[0]:-}" in - mkdir) takes_mode=0; ok_opts='pv' ;; - cp) takes_mode=0; ok_opts='rRvfnpa' ;; - mv) takes_mode=0; ok_opts='vfn' ;; - touch) takes_mode=0; ok_opts='acmv' ;; - chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path - tar) ok_flags='xctzjJavfC'; val_flags='fC' ;; - unzip) ok_flags='oqnljvd'; val_flags='d' ;; - *) defer "not the leading command word" ;; -esac - -# ---------------------------------------------------------------- tar / unzip -if [ -n "${ok_flags:-}" ]; then - saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 - i=1 - while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" +# Proves one `tar` / `unzip` segment ($1 = the verb), whose tokens are in SEG_TOKS. +check_archive_segment() { + local verb="$1" ok_flags val_flags t flags val + local saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 i=1 + case "$verb" in + tar) ok_flags='xctzjJavfC'; val_flags='fC' ;; + unzip) ok_flags='oqnljvd'; val_flags='d' ;; + esac + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" i=$((i + 1)) if [ "$end_opts" = 0 ]; then [ "$t" = "--" ] && { end_opts=1; continue; } @@ -112,13 +146,13 @@ if [ -n "${ok_flags:-}" ]; then # leave a residue here and defer rather than being enumerated as denials. [ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && defer "unrecognized option \`$t\`" case "$flags" in *x*) extracting=1 ;; esac - case "${toks[0]}$flags" in unzip*[lv]*) listing=1 ;; esac + case "$verb$flags" in unzip*[lv]*) listing=1 ;; esac # A flag consuming the next token must be alone in its bundle's final position # (`-xzf a.tar`), else the token it eats is ambiguous. case "${flags%?}" in *[$val_flags]*) defer "ambiguous option bundle \`$t\`" ;; esac case "${flags: -1}" in [$val_flags]) - val="${toks[$i]:-}" + val="${SEG_TOKS[$i]:-}" i=$((i + 1)) [ -n "$val" ] || defer "option \`$t\` has no value" under_tmp "$val" || defer "\`$val\` is outside /tmp" @@ -136,54 +170,152 @@ if [ -n "${ok_flags:-}" ]; then # first is the archive. Requiring every one under /tmp is conservative for member names, # which are not filesystem paths — those defer rather than being wrongly allowed. under_tmp "$t" || defer "\`$t\` is outside /tmp" - [ "${toks[0]}" = "unzip" ] && saw_archive=1 + [ "$verb" = "unzip" ] && saw_archive=1 done # tar without -f reads a tape/stdin; unzip needs an archive [ "$saw_archive" = 1 ] || defer "no archive operand" # Writes land relative to the working directory unless a destination was given. `unzip -l` # and `-v` only list, so they need no destination. - if [ "$extracting" = 1 ] || { [ "${toks[0]}" = "unzip" ] && [ "$listing" = 0 ]; }; then - [ "$saw_dest" = 1 ] || under_tmp "${cwd:-$PWD}" || defer "extraction target is outside /tmp" + if [ "$extracting" = 1 ] || { [ "$verb" = "unzip" ] && [ "$listing" = 0 ]; }; then + # An extraction with no destination lands in the working directory. Word splitting cannot + # tell a `cd` inside a quoted string from one the shell runs, and believing a false one + # would put an archive's members in the checkout, so once any `cd` is in the line only an + # explicit destination will do. + [ "$saw_dest" = 1 ] \ + || { [ "$saw_cd" = 0 ] && [ -n "$seg_cwd" ] && under_tmp "$seg_cwd"; } \ + || defer "extraction target is outside /tmp" fi - decide allow "archive paths and extraction target are under /tmp" -fi +} -# ------------------------------------------- mkdir / cp / mv / touch / chmod -path_operand=0 -seen_mode=0 -end_opts=0 -i=1 -while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" - i=$((i + 1)) +# Proves one `mkdir` / `cp` / `mv` / `touch` / `chmod` segment ($1 = the verb), whose tokens +# are in SEG_TOKS. +check_fileops_segment() { + local verb="$1" takes_mode ok_opts t cls resolved seen_class="" + local path_operand=0 seen_mode=0 end_opts=0 i=1 rel_operand=0 + local -a ops=() + # Options are an allowlist per command, so anything that changes how symlinks are followed + # defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while + # recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch + # dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate + # such a symlink as a symlink instead, so no outside content is materialized. + case "$verb" in + mkdir) takes_mode=0; ok_opts='pv' ;; + cp) takes_mode=0; ok_opts='rRvfnpa' ;; + mv) takes_mode=0; ok_opts='vfn' ;; + touch) takes_mode=0; ok_opts='acmv' ;; + chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path + esac + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" + i=$((i + 1)) - if [ "$end_opts" = 0 ]; then - [ "$t" = "--" ] && { end_opts=1; continue; } - # Checked at any position, not just before the first operand: GNU utils permute, so - # `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion. - case "$t" in - -?*) - # Allowlist: long options and the dereferencing flags leave a residue and defer. - [ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`" - continue - ;; - esac - fi + if [ "$end_opts" = 0 ]; then + [ "$t" = "--" ] && { end_opts=1; continue; } + # Checked at any position, not just before the first operand: GNU utils permute, so + # `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion. + case "$t" in + -?*) + # Allowlist: long options and the dereferencing flags leave a residue and defer. + [ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`" + continue + ;; + esac + fi - # chmod: consume the mode operand without a path check. Octal, or symbolic clauses. - if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then - case "$t" in - [0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;; - *) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;; - esac - seen_mode=1 - continue - fi + # chmod: consume the mode operand without a path check. Octal, or symbolic clauses. + if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then + case "$t" in + [0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;; + *) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;; + esac + seen_mode=1 + continue + fi - under_tmp "$t" || defer "\`$t\` is outside /tmp" - path_operand=1 + resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME" + cls="${resolved%%$'\n'*}" + # Every operand of one operation stays in one root: see the exfiltration note above. + [ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots" + seen_class="$cls" + ops+=("${resolved#*$'\n'}") + case "$t" in /*) ;; *) rel_operand=1 ;; esac + path_operand=1 + done + + [ "$path_operand" = 1 ] || defer "no path operand" + + # In directory form the command writes a path it does not name: `cp x dir` writes `dir/x`, + # and `cp` follows that child when it is a symlink — this checkout is full of them, every + # `*_ee.rs` pointing into the sibling EE repo. Deriving that child would mean reproducing + # which name the tool picks (the operand as written, not as resolved — a symlinked source + # keeps its own name) and how deep `-r` recurses. The form is left unproved instead. + case "$verb" in + cp | mv) + [ "${#ops[@]}" -ge 2 ] || return 0 + # Whether the destination is an existing directory is itself a question about which of + # the two candidate working directories the command ran in, and only one of them is in + # `ops`. A `cd` that fails at runtime would otherwise let the form through: the + # destination resolved against the directory the command never reached is some path that + # does not exist, while the one it actually ran in is a directory full of symlinks. + [ -n "$alt_cwd" ] && [ "$rel_operand" = 1 ] \ + && defer "a relative operand after a \`cd\` lands in one of two directories" + [ -d "${ops[-1]}" ] \ + && defer "\`${ops[-1]}\` already exists as a directory, so this $verb writes a path it does not name" + ;; + esac +} + +split_segments "$cmd" +seg_cwd="${cwd:-$PWD}" +alt_cwd="" # where a `cd` that failed would have left the command +saw_cd=0 # a `cd` moved the working directory somewhere +proved=0 # how many ops came out inside a single root +only_ours=1 # ... and nothing else shares the command line + +for seg in "${SEGMENTS[@]}"; do + segment_tokens "$seg" + case "${SEG_TOKS[0]:-}" in + "") continue ;; + mkdir | cp | mv | touch | chmod) + check_fileops_segment "${SEG_TOKS[0]}" + proved=$((proved + 1)) + continue + ;; + tar | unzip) + check_archive_segment "${SEG_TOKS[0]}" + proved=$((proved + 1)) + continue + ;; + cd) + # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative + # operand points, to one of the two candidates `apply_cd` describes. + if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then + alt_cwd="$seg_cwd" + seg_cwd="$new_cwd" + else + # Not the harmless segment an allow assumes: whatever this guard could not account for + # may be a redirect, and a redirect writes. Leave the line to the normal flow. + seg_cwd="" alt_cwd="" + only_ours=0 + fi + saw_cd=1 + continue + ;; + esac + # Some other command shares the line. If an `mv` or `chmod` runs inside it after all — behind + # a wrapper, an env prefix or a path — this hook cannot say what it writes to. + for verb in mv chmod; do + segment_runs_verb "$verb" "$seg" && defer "$verb is not the leading command word in \`$seg\`" + done + only_ours=0 done -[ "$path_operand" = 1 ] || defer "no path operand" -decide allow "every path operand is under /tmp" +# Exactly one write per line. Each segment is proved against the filesystem as it stands now, +# and an earlier write can change what a later operand means: `cp -r /tmp/tree /tmp/live` that +# recreates a symlink out of /tmp turns `/tmp/live/link` — a path under /tmp when this ran — +# into a write through that symlink. Deletes compose safely and guard-rm-outside-tmp.sh allows +# several, because `rm` unlinks a symlink rather than following it. +[ "$proved" -ge 1 ] || exit 0 +[ "$only_ours" = 1 ] && [ "$proved" = 1 ] && decide allow "every path operand is inside a single root" +exit 0 diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index 5aff497d89..4d253ffc62 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -1,14 +1,17 @@ #!/usr/bin/env bash -# PreToolUse guard for `rm`: auto-allow ONLY a single, plain, single-line `rm` whose every -# operand is a whitelisted target — under /tmp, or inside a git working tree located in $HOME -# (a version-controlled project dir). Any other command that runs `rm` gets an explicit `ask`, -# which is the ordinary permission prompt and the only one `rm` gets (see lib-guarded-verb.sh); -# a command that runs no `rm` at all makes no decision (exit 0). +# PreToolUse guard for `rm`: auto-allow deletes whose every operand is a whitelisted target — +# under /tmp, or inside a git working tree located in $HOME (a version-controlled project dir). +# Any other command that runs `rm` gets an explicit `ask`, which is the ordinary permission +# prompt and the only one `rm` gets (see lib-guarded-verb.sh); a command that runs no `rm` at +# all makes no decision (exit 0). # -# The git-tree allowance trades on "this is a project under version control" being lower-stakes -# than a delete elsewhere — NOT on full recoverability: committed content is restorable via git, -# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history -# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff. +# The command is read one segment at a time, so chaining and line breaks carry no weight of +# their own: `rm -f /tmp/a && rm -rf /tmp/b` is two deletes, each proved on its own operands. +# A decision covers the whole command line, so `allow` is emitted only when every segment is +# an `rm` this guard proved or a `cd` it could resolve. A line that mixes a proven `rm` with +# some other command makes no decision instead and leaves that line to the normal permission +# flow: the delete is not what needed a prompt, and waving the rest of the line through with +# it would turn a trailing `rm -f /tmp/x` into a way to auto-approve anything. # # Deny-by-default: every token must consist only of a safe character set (alphanumerics, # `. _ / -` and glob chars `* ? [ ]`). That set contains none of the characters bash uses for @@ -17,12 +20,12 @@ # and existing symlinks (so a symlink out of the allowed roots is caught), and a wildcard in a # non-final path segment is refused because it can expand through a symlink realpath can't see. # -# The git-repo allowance covers targets inside a git working tree under $HOME, and the tree's -# own root folder only when it is a linked worktree (`.git` is a pointer file, so history in -# the main repo survives); a primary checkout's root (`.git` is a history dir) and any `.git`, -# `.claude` or `.env` path are never auto-allowed. Globs auto-allow only under /tmp — elsewhere their expansion +# Which targets those two roots cover, and the tradeoff they rest on, is `path_class` in +# lib-guarded-verb.sh. Globs auto-allow only under /tmp — elsewhere their expansion # could reach `.git` or a dotfile the literal checks never see. Relative operands resolve -# against the command's cwd (from the hook input). +# against the working directory the command runs from, which a `cd` in an earlier segment +# moves; once a `cd` is one this guard cannot resolve, that directory is unknown and a +# relative operand can no longer be proved. # # Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env. set -uo pipefail @@ -35,87 +38,103 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) # Every bail-out below goes through `defer`, so the forms this guard refuses to reason about — -# compound, quoted, wrapped — still reach the user as a prompt whenever an `rm` runs among them. +# wrapped, quoted, expanded — still reach the user as a prompt whenever an `rm` runs among them. runs_verb rm "$cmd" && guarded=1 || guarded=0 defer() { [ "$guarded" = 1 ] && decide ask "$1" exit 0 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; esac +has_substitution "$cmd" && defer "command substitution in the command line" -read -r -a toks <<< "$cmd" -# Bare leading `rm` only; wrappers (`timeout rm`), env prefixes, and `/bin/rm` defer. -[ "${toks[0]:-}" = "rm" ] || defer "rm is not the leading command word" - -# 0 (allow) iff the canonical path is an auto-allowable rm target: under /tmp, or strictly -# inside a git working tree located under $HOME. The walk stops at $HOME, so a dotfiles repo at -# ~ can't make all of $HOME deletable, and top-level ~ files stay protected. -allowed_target() { - local canon="$1" d root="" - case "$canon" in /tmp/?*) return 0 ;; esac - [ -n "${HOME:-}" ] || return 1 - case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac - # Never auto-allow: history, and the two kinds of path the "it's under version control" - # premise doesn't hold for — the agent's own guards and settings (deleting them is what - # removes the prompt on everything else), and gitignored `.env` files. - case "$canon" in - *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; - *"/.env" | *"/.env."*) return 1 ;; - esac - d="$canon" - while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do - [ -e "$d/.git" ] && { root="$d"; break; } - d=$(dirname "$d") +# Proves one `rm` segment, whose tokens are in SEG_TOKS with `rm` at index 0, resolving relative +# operands against $seg_cwd. Returns only once every operand is an auto-allowable target; +# anything it cannot prove defers instead. +check_rm_segment() { + local i=1 t canon candidates had_operand=0 end_opts=0 + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" + i=$((i + 1)) + # Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm` + # can't slip past): any character outside the safe set makes it unsafe to reason about. + [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" + # A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name` + # into an operand — never a real option, so defer. + case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac + if [ "$end_opts" = 0 ]; then + [ "$t" = "--" ] && { end_opts=1; continue; } + # Skip real options only before the first operand. A bare `-` is a filename, and under + # POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name` + # is a filename too — validate it rather than skipping it. + if [ "$had_operand" = 0 ]; then + case "$t" in -?*) continue ;; esac + fi + fi + had_operand=1 + # No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink + # realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine. + case "$t" in */*) case "${t%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac + # A relative operand has as many candidate paths as the command has candidate working + # directories, and every one of them has to be auto-allowable: a `cd` that fails at runtime + # leaves the delete running in the directory it started in. + case "$t" in + /*) candidates=$(realpath -m -- "$t" 2>/dev/null) ;; + *) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory this guard cannot pin down" + candidates=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + [ -n "$alt_cwd" ] && candidates="$candidates +$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)" + ;; + esac + while IFS= read -r canon; do + [ -n "$canon" ] || defer "cannot resolve \`$t\`" + # A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its + # expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the + # literal-path checks never see — so require literal operands in git repos. + case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac + path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + done <<< "$candidates" done - [ -n "$root" ] || return 1 # not inside a git working tree under $HOME - if [ "$canon" = "$root" ]; then - # Deleting the repo root folder itself: allow only for a linked worktree, whose `.git` is - # a file/pointer so the history lives in the main repo and survives. A primary checkout's - # `.git` is a directory holding the history, so deleting it is unrecoverable — defer. - [ -f "$root/.git" ] && return 0 - return 1 - fi - return 0 + [ "$had_operand" = 1 ] || defer "no operand" } -had_operand=0 -end_opts=0 -i=1 -while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" - i=$((i + 1)) - # Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm` - # can't slip past): any character outside the safe set makes it unsafe to reason about. - [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" - # A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name` - # into an operand — never a real option, so defer. - case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac - if [ "$end_opts" = 0 ]; then - [ "$t" = "--" ] && { end_opts=1; continue; } - # Skip real options only before the first operand. A bare `-` is a filename, and under - # POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name` - # is a filename too — validate it rather than skipping it. - if [ "$had_operand" = 0 ]; then - case "$t" in -?*) continue ;; esac - fi - fi - had_operand=1 - # No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink - # realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine. - case "$t" in */*) case "${t%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac - case "$t" in - /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; - *) canon=$(realpath -m -- "${cwd:-$PWD}/$t" 2>/dev/null) ;; +split_segments "$cmd" +seg_cwd="${cwd:-$PWD}" +alt_cwd="" # where a `cd` that failed would have left the command +saw_cd=0 +proved=0 # at least one `rm` segment came out auto-allowable +only_ours=1 # ... and nothing else shares the command line + +for seg in "${SEGMENTS[@]}"; do + segment_tokens "$seg" + case "${SEG_TOKS[0]:-}" in + "") continue ;; + rm) + check_rm_segment + proved=1 + continue + ;; + cd) + # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative + # operand points, to one of the two candidates `apply_cd` describes. + if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then + alt_cwd="$seg_cwd" + seg_cwd="$new_cwd" + else + # Not the harmless segment an allow assumes: whatever this guard could not account for + # may be a redirect, and a redirect writes. Leave the line to the normal flow. + seg_cwd="" alt_cwd="" + only_ours=0 + fi + saw_cd=1 + continue + ;; esac - [ -n "$canon" ] || defer "cannot resolve \`$t\`" - # A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its - # expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the - # literal-path checks never see — so require literal operands in git repos. - case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac - allowed_target "$canon" || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + # Some other command shares the line. If an `rm` runs inside it after all — behind a wrapper, + # an env prefix or a path — this guard cannot say what it deletes. + segment_runs_verb rm "$seg" && defer "rm is not the leading command word in \`$seg\`" + only_ours=0 done -[ "$had_operand" = 1 ] || defer "no operand" -decide allow 'rm operands are under /tmp or inside a git checkout in $HOME' +[ "$proved" = 1 ] || exit 0 +[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp or inside a git checkout in $HOME' +exit 0 diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index c4d5f5ac9a..6ef76a5466 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -10,17 +10,6 @@ # expand a glob operand against the filesystem. Neither guard relies on pathname expansion. set -f -# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash -# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt: -# the command splits on `; & |` and newlines, and a leading env assignment or process wrapper -# (`timeout 5 rm`, `xargs rm`) is skipped before the command word is read. -# -# The split set also carries the characters that open a nested command — `$(`, backticks and -# `( )` — because a rule matches the verb inside one (`echo $(rm -rf ~)` prompts), and a -# separator that only ends statements would read that as an `echo`. Braces are handled as -# words rather than separators, since splitting on them cuts `xargs -I {} … rm` in half and -# strands the `rm` in a segment that no longer knows a wrapper preceded it. - # 0 iff ($1) starts with a command that only reads its input. An allowlist, because the # opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`, # `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only @@ -115,35 +104,159 @@ strip_heredoc_bodies() { done } +# 0 iff ($1) runs as a command word in ($2), which must already be one +# segment (no separator left in it). Wrapper, env-prefix and `/bin/` forms all count. +segment_runs_verb() { + local verb="$1" w wrapped=0 + for w in $2; do + # The shell strips quotes and backslashes before it looks up the command, so `'rm'` and + # `r\m` run rm and have to compare equal to it. + w="${w//[\"\'\\]/}" + case "$w" in + "$verb" | */"$verb") return 0 ;; + *=*) ;; # leading env assignment + -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect + [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose + '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command + timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) + wrapped=1 ;; + # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`), + # so past a wrapper the scan runs to the end of the segment instead of stopping at the + # first ordinary word. Before one, that word is the command and the verb cannot follow + # it. Nothing bounds the scan: a wrapper takes unboundedly many operands + # (`env -u A -u B ...`), and any cutoff — a word count, or stopping at the first quoted + # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the + # price, and it only over-prompts. + *) [ "$wrapped" = 1 ] || break ;; + esac + done + return 1 +} + +# Splits ($1) into its command segments, into the global array SEGMENTS. Every guard +# reasons one segment at a time, so `a && b` is two commands here rather than one unparsable +# blob, and a newline is a separator like any other. +# +# The split set carries more than `; & |` and newlines: `$(`, backticks and `( )` open a nested +# command, and a separator that only ended statements would read `echo $(rm -rf ~)` as an +# `echo`. Braces are handled as words rather than separators, since splitting on them cuts +# `xargs -I {} … rm` in half and strands the `rm` in a segment that no longer knows a wrapper +# preceded it. +# +# `tr` and not `${1//[...]}`: a `}` inside the bracket expression closes the expansion itself, +# which silently leaves the command unsplit and every separator unseen. +split_segments() { + local seg + SEGMENTS=() + while IFS= read -r seg; do SEGMENTS+=("$seg"); done <<< "$(strip_heredoc_bodies "$1" | tr ';&|()`' '\n')" +} + +# 0 iff ($1) carries a command substitution outside a heredoc body. A substitution is +# concatenated into the word it sits in, and splitting on its opener cuts that word in half: +# `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/`, with the traversal validated as an +# unrelated segment. Nothing here can evaluate it, so a guard proves nothing about such a +# command. Heredoc bodies are excepted — those are data the split has already dropped. +has_substitution() { + case "$(strip_heredoc_bodies "$1")" in + *'$('* | *'`'*) return 0 ;; + esac + return 1 +} + +# Reads ($1) into the global array SEG_TOKS, dropping the shell keywords that can +# precede a command word so that `then rm -rf x` is analyzed as the `rm` it runs. Word +# splitting only: quotes are left in the token and fail the guards' charset check downstream, +# which is what keeps `rm -rf "$HOME/x"` unprovable. +segment_tokens() { + SEG_TOKS=() + read -r -a SEG_TOKS <<< "$1" + while [ "${#SEG_TOKS[@]}" -gt 0 ]; do + case "${SEG_TOKS[0]}" in + '!' | '{' | '}' | if | then | elif | else | while | until | do) SEG_TOKS=("${SEG_TOKS[@]:1}") ;; + *) break ;; + esac + done +} + +# Prints the directory a `cd` lands in, given the current one ($1) and the tokens after the +# `cd` ($2...). Fails, printing nothing, when the destination cannot be resolved — a variable, +# `-`, an option, a relative path, no operand at all (`cd` alone is $HOME), or more than one. +# +# Resolving says nothing about whether the `cd` will SUCCEED: the destination may not exist, and +# `;` runs the next command anyway, leaving it in the directory it started in. So a caller may +# never treat this as the working directory outright — it is one of two candidates, and a +# relative operand has to be provable against the one the command started in as well. That also +# makes a `cd` word splitting invented out of quoted text harmless: it can only add a candidate, +# never drop one. Past the first `cd` the branching outruns two candidates, so a caller that +# sees a second gives up on relative operands entirely. +apply_cd() { + local cwd="$1" t + shift + [ "$#" -eq 1 ] || return 1 + t="$1" + [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 + # Absolute only. A relative destination is not `$cwd/$t`: the shell searches $CDPATH first, + # so `cd ssh` may land in /etc/ssh, and this cannot see the caller's $CDPATH to rule it out. + case "$t" in /*) ;; *) return 1 ;; esac + realpath -m -- "$t" 2>/dev/null +} + +# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, or +# `repo:` for one strictly inside the git working tree at , itself under $HOME. +# Fails, printing nothing, for anything else — those are the only roots the guards are willing +# to touch unprompted. The root is part of the class so that a caller pairing two operands can +# tell one checkout from another: sibling repos are separate permission boundaries, not one. +# +# The `repo` class trades on "this is a project under version control" being lower-stakes than +# the same act elsewhere — NOT on full recoverability: committed content is restorable via git, +# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history +# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff. +# +# The walk stops at $HOME, so a dotfiles repo at ~ can't put all of $HOME in a class, and +# top-level ~ files stay out of one. A working tree's own root folder counts only when it is a +# linked worktree, whose `.git` is a pointer file so the history lives in the main repo and +# survives; a primary checkout's `.git` is a directory holding the history itself, so losing it +# is unrecoverable. +# +# Some paths are in no class in any root, /tmp included. Git history, and the agent's own guards +# and settings, because removing those is what removes the prompt on everything else. And every +# path `.claude/settings.json` refuses to read — `.env`, `secrets/`, `*.pem`, `*.key`, +# `credentials.json`, `.secret*` — because a `cp` or `mv` that is auto-allowed on both ends +# would rename one out of those globs and hand back through `Read` exactly what they deny. +path_class() { + local canon="$1" d root="" + case "$canon" in + *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; + *"/.env" | *"/.env."*) return 1 ;; + *"/secrets" | *"/secrets/"*) return 1 ;; + *.pem | *.key | *"/credentials.json") return 1 ;; + *"/.secret"* | *.secret | *.secrets) return 1 ;; + esac + case "$canon" in /tmp/?*) printf 'tmp'; return 0 ;; esac + [ -n "${HOME:-}" ] || return 1 + case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac + d="$canon" + while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do + [ -e "$d/.git" ] && { root="$d"; break; } + d=$(dirname "$d") + done + [ -n "$root" ] || return 1 # not inside a git working tree under $HOME + if [ "$canon" = "$root" ]; then + [ -f "$root/.git" ] || return 1 + fi + printf 'repo:%s' "$root" +} + +# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash +# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt: +# a guard consults this before it starts proving segments, and every bail-out it then takes +# is a prompt for exactly the commands a rule would have caught. runs_verb() { - local verb="$1" seg w wrapped - while IFS= read -r seg; do - wrapped=0 - for w in $seg; do - # The shell strips quotes and backslashes before it looks up the command, so `'rm'` and - # `r\m` run rm and have to compare equal to it. - w="${w//[\"\'\\]/}" - case "$w" in - "$verb" | */"$verb") return 0 ;; - *=*) ;; # leading env assignment - -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect - [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose - '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command - timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) - wrapped=1 ;; - # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`), - # so past a wrapper the scan runs to the end of the segment instead of stopping at the - # first ordinary word. Before one, that word is the command and the verb cannot follow - # it. Nothing bounds the scan: a wrapper takes unboundedly many operands - # (`env -u A -u B …`), and any cutoff — a word count, or stopping at the first quoted - # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the - # price, and it only over-prompts. - *) [ "$wrapped" = 1 ] || break ;; - esac - done - # `tr` and not `${2//[...]}`: a `}` inside the bracket expression closes the expansion - # itself, which silently leaves the command unsplit and every separator unseen. - done <<< "$(strip_heredoc_bodies "$2" | tr ';&|()`' '\n')" + local verb="$1" seg + split_segments "$2" + for seg in "${SEGMENTS[@]}"; do + segment_runs_verb "$verb" "$seg" && return 0 + done return 1 } diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index 65631b6a48..eca946baf9 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -4,6 +4,10 @@ # What this pins is the `ask` column: a matcher change that turns one into a no-decision drops # that command's only prompt (see lib-guarded-verb.sh). The wrapper, nested-command and quoted # rows are the ones that catch it. +# +# The `allow` column carries its own weight, because a decision covers the whole command line: +# `allow` may only appear where every segment was proved here, and a line that also runs +# something unexamined has to come out `none` so the normal permission flow still sees it. set -uo pipefail H="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" CWD="$(git -C "$H" rev-parse --show-toplevel)" @@ -46,10 +50,11 @@ run $G ask "rm -rf $CWD/*" run $G ask "rm -rf /etc/passwd" run $G ask 'rm -rf "$HOME/x"' run $G ask "rm -rf /tmp/../$OUT" -run $G ask "ls /tmp && rm -rf /tmp/x" +run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour run $G ask 'echo $(rm -rf /etc)' run $G ask 'echo `rm -rf /etc`' run $G ask "{ rm -rf /etc; }" +run $G allow "{ rm -rf /tmp/scratch/x; }" # the keyword drops, the delete still proves run $G ask "find . -name x | xargs rm" run $G ask "timeout 5 rm -rf /tmp/x" run $G ask "stdbuf -o L rm -rf /etc" @@ -107,6 +112,33 @@ run $G none 'echo $(ls /tmp)' run $G none 'grep -rn "rm" backend/' run $G none "cargo build --release" +# Chaining and line breaks are not themselves a reason to prompt: each segment is proved on its +# own operands, and a `cd` moves where a relative one points. +run $G allow "rm -f /tmp/a; rm -rf /tmp/b" +run $G allow "$(printf 'rm -f /tmp/a\nrm -rf %s/frontend/scratch' "$CWD")" +run $G allow "cd /tmp/scratch && rm -rf sub" +run $G none "mkdir -p /tmp/x && rm -rf /tmp/x" +run $G ask "$(printf 'ls /tmp\nrm -rf /etc')" +# A `cd` this guard can resolve is where the relative operand lands; one it cannot leaves the +# working directory unknown, and an unknown one proves nothing. +run $G ask "cd /etc && rm -rf foo" +run $G ask 'cd "$D" && rm -rf foo' +run $G ask "cd $CWD && rm -rf .git" +run $G ask "cd /etc && cd /tmp/scratch && rm -rf sub" # a cd out is not walked back +# A `cd` can fail at runtime, and `;` runs the delete from where the command started, so a +# relative operand is proved from both directories. +run $G ask "cd /tmp/does-not-exist; rm -rf .git" +run $G ask "cd /tmp/does-not-exist; rm -rf backend/.env" +run $G ask "cd /tmp/a && cd /tmp/b && rm -rf sub" +run $G ask "rm -rf /tmp/clone/.git" # history is never in a class +run $G ask "rm -rf /tmp/scratch/id_rsa.key" +run $G none "cd /tmp >$OUT; rm -f /tmp/a" +# A substitution is concatenated into its word, so splitting on it would prove only the literal +# half; a relative `cd` is not $cwd/$t either, since the shell searches $CDPATH first. +run $G ask 'rm -rf /tmp/a/`printf ../../etc`' +run $G ask 'rm -rf /tmp/a/$(printf ../../etc)' +run $G ask "cd ssh && rm -rf moduli" + echo echo "== allow-fileops-in-tmp.sh ==" A=allow-fileops-in-tmp.sh @@ -117,7 +149,7 @@ run $A allow "tar -xzf /tmp/a.tar.gz -C /tmp/out" run $A ask "mv /tmp/a $OUT" run $A ask "mv $CWD/AGENTS.md /tmp/a" run $A ask "chmod -R 777 $CWD" -run $A ask "ls && mv /tmp/a /tmp/b" +run $A none "ls && mv /tmp/a /tmp/b" # proved move, unexamined neighbour run $A ask 'echo $(mv /tmp/a /etc)' run $A ask "timeout --signal KILL 5 mv /tmp/a /etc" run $A ask "time -f FORMAT chmod 777 $OUT" @@ -129,5 +161,49 @@ run $A none "cp $CWD/AGENTS.md /tmp/a" run $A none "tar -xzf /tmp/a.tar.gz -C $OUT" run $A none "cargo build" +run $A none "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" # one write per line +run $A none "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')" +run $A ask "ls && mv /tmp/a /etc" +run $A ask "$(printf 'mkdir -p /tmp/x\nchmod -R 777 %s' "$CWD")" +run $A allow "cd /tmp/x && tar -xzf /tmp/a.tar.gz -C /tmp/out" +# The checkout is a root of its own, so an in-repo move or chmod is as auto-allowable as the +# in-repo delete already was — but one operation may not straddle it and /tmp. +run $A allow "chmod +x scripts/worktree-env" +run $A allow "mv backend/.sqlx backend/.sqlx.bad" +run $A allow "mv $CWD/frontend/a.ts $CWD/frontend/b.ts" +run $A ask "mv /tmp/a $CWD/frontend/a.ts" +run $A ask "chmod -R 777 $CWD/.git" +run $A ask "mv $CWD/backend/.env $CWD/backend/.env.bak" +run $A ask "mv $CWD/AGENTS.md $OUT" +run $A ask "cd /etc && mv a b" +# An auto-allowed rename may not carry a path out of the `Read` deny globs. +run $A ask "mv backend/server.pem backend/server.txt" +run $A none "cp backend/secrets/token frontend/token.txt" # cp has no prompt of its own, + # so what matters is it is not allowed +run $A ask "mv $CWD/backend/credentials.json /tmp/x" +run $A ask "cd /tmp/does-not-exist; mv .claude/settings.json settings.bak" +# A segment this hook cannot read whole may carry a redirect, and an earlier write can change +# what a later operand resolves to — neither may ride along on an allow. +run $A none "cd /tmp >$OUT; mv /tmp/a /tmp/b" +run $A none "cp -r /tmp/tree /tmp/live; cp /tmp/payload /tmp/live/link" +run $A ask 'mv /tmp/a/`printf ../../etc/x` /tmp/b' +# A sibling checkout is a different root: its files are outside what the Read tool is confined +# to, and copying them in would hand back what that confinement withholds. +EE="$(dirname "$CWD")/windmill-ee-private" # a sibling checkout; absent elsewhere, still not a root +run $A ask "mv $EE/backend/x.rs $CWD/backend/x.rs" +run $A none "cp $EE/README.md $CWD/README.copy" +# Directory form writes a path the command does not name — DEST/basename(SRC) — and `cp` +# follows that child when it is a symlink, as every `*_ee.rs` in this checkout is. +run $A ask "mv frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cp frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cp frontend/a.ts backend" +run $A ask "mv /tmp/a $CWD/backend" +# ... and a `cd` that fails at runtime may not hide that form: the destination is a directory +# in the directory the command actually ran in, whichever of the two that turns out to be. +run $A none "cd $CWD/AGENTS.md; cp frontend/apps_ee.rs backend/windmill-api/src" +run $A ask "cd $CWD/AGENTS.md; mv frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cd /tmp/x && tar -xzf /tmp/a.tar.gz" # no -C, and the cwd is now two candidates +run $A allow "cp frontend/a.ts backend/a.ts" # ... naming the destination proves fine + echo [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; } diff --git a/AGENTS.md b/AGENTS.md index 06b0402fd6..1da4be6c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,10 +147,19 @@ $NAV --root backend callees "X" # what does X call? - **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics - **Scratch stays outside the checkout.** Temp scripts, data dumps, cache backups and screenshots go in the session scratch directory or `/tmp`, so nothing temporary can end up - committed. Write `rm`/`mv`/`cp` as one plain unchained command: a PreToolUse hook - auto-allows those when every operand is under `/tmp` or inside this checkout, but it defers - on `&&`, `;`, redirects, quotes and `$VAR` — that deferral, not the delete itself, is what - turns a routine cleanup into a permission prompt. + committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each + operand, and auto-allows deletes, moves, copies and mode changes under `/tmp` or inside a git + checkout under `$HOME`, as long as one operation stays within a single root — a sibling + checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain deletes freely, each + proved on its own operands, but keep writes to one per line, name the destination rather than + a directory to drop it in, and put anything else on its own line: a command the hook does not + prove drops the whole line back to the normal permission flow. A + quoted or `$VAR` operand, a `~`, a redirect, a `$(…)`, a relative `cd`, or a wrapper like + `xargs rm` cannot be proved, and that deferral is what turns a cleanup into a prompt. +- **Change files with Edit/Write, not the shell.** `sed -i`, `cat > file <<'EOF'` and inline + `python3 - <<'PY'` scripts put an edit through the PreToolUse guards and the permission + classifier, which match `Bash` and nothing else, so a routine edit arrives as a prompt. Bash + stays right for running things — tests, builds, git, one-off queries. - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked From 6749015fbf7afe0c6dcd53b1933b0152915afd32 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 18 Aug 2026 12:25:21 +0200 Subject: [PATCH 344/400] fix: audit the icon library against brand guidelines (#10722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: audit the icon library against brand guidelines Every icon component checked against its brand's own published guidelines for correct artwork, current colours, and readability on both app surfaces. - 127 marks now carry a per-theme pair (text-[#light] dark:text-[#dark]), applied only where the brand publishes a reversed or dark variant. twMerge where the component exposes a class prop, so callers can still pass sizing. - 296 of 304 brand icons record their source in a comment above the , including the rule where the brand imposes one (Google forbids recolouring, Cal.com is deliberately greyscale, Oracle reserves the MySQL dolphin). - BRAND_COLORS.md is generated from the components, so the table cannot drift from the code. - Marks that were unreadable on a surface: 13 -> 1 on dark, 9 -> 4 on light. The remainder are blocked by trademark terms, not unfixed. - Wrong artwork replaced where a first-party or CC0 source existed: PayPal is the real three-colour monogram, Stripe is the bare S rather than an app tile, gcloud resolves to Google's mark instead of a generic hexagon. - Concept icons (CACertificate, DbIcon, Webdav, Asset*, Bcrypt) inherit currentColor instead of hardcoding a colour. Fixes a cross-component CSS bug: ten icons embedded
@@ -904,8 +936,8 @@

Instance-configured OAuth APIs

- {#if filteredConnects} - {#each filteredConnects as { key }} + {#if rankedConnects} + {#each rankedConnects as { key }} {/if} {#each filteredResources as r} - {@const isPicked = value === r} + {@const isPicked = value === r.name} {/each} diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 7ef3543073..9f0395b924 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -116,15 +116,15 @@ {:else if effectiveKind === 'postgres'} {:else if effectiveKind === 'kafka'} - + {:else if effectiveKind === 'nats'} - + {:else if effectiveKind === 'mqtt'} - + {:else if effectiveKind === 'amqp'} - + {:else if effectiveKind === 'sqs'} - + {:else if effectiveKind === 'gcp'} {:else if effectiveKind === 'azure'} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte index ac41827d6c..29d12b8084 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte @@ -5,16 +5,18 @@ import { getContext } from 'svelte' import { type TriggerContext } from '$lib/components/triggers' import { enterpriseLicense } from '$lib/stores' - import { - MqttIcon, - AmqpIcon, - NatsIcon, - KafkaIcon, - AwsIcon, - GoogleCloudIcon - } from '$lib/components/icons' + import MqttIcon from '$lib/components/icons/MqttIcon.svelte' + import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte' + import NatsIcon from '$lib/components/icons/NatsIcon.svelte' + import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte' + import AwsIcon from '$lib/components/icons/AwsIcon.svelte' + import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' import AzureIcon from '$lib/components/icons/AzureIcon.svelte' - import { type Trigger, type TriggerType } from '$lib/components/triggers/utils' + import { + triggerIconMapMono, + type Trigger, + type TriggerType + } from '$lib/components/triggers/utils' import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents' import { twMerge } from 'tailwind-merge' import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte' @@ -320,10 +322,13 @@ {/snippet} {#snippet simpleTriggerItem({ item, type })} - {@const { icon: SvelteComponent, countKey } = triggerTypeConfig()[type] || { + {@const { icon: ColourIcon, countKey } = triggerTypeConfig()[type] || { icon: Database, countKey: undefined }} + + {@const SvelteComponent = triggerIconMapMono[type] ?? ColourIcon}
diff --git a/frontend/src/lib/components/icons/AblyIcon.svelte b/frontend/src/lib/components/icons/AblyIcon.svelte new file mode 100644 index 0000000000..ce4c360152 --- /dev/null +++ b/frontend/src/lib/components/icons/AblyIcon.svelte @@ -0,0 +1,59 @@ + + + + diff --git a/frontend/src/lib/components/icons/AbstractApiIcon.svelte b/frontend/src/lib/components/icons/AbstractApiIcon.svelte new file mode 100644 index 0000000000..a4b1635775 --- /dev/null +++ b/frontend/src/lib/components/icons/AbstractApiIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AcceloIcon.svelte b/frontend/src/lib/components/icons/AcceloIcon.svelte new file mode 100644 index 0000000000..0f7846e903 --- /dev/null +++ b/frontend/src/lib/components/icons/AcceloIcon.svelte @@ -0,0 +1,23 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ActimoIcon.svelte b/frontend/src/lib/components/icons/ActimoIcon.svelte new file mode 100644 index 0000000000..59accffb99 --- /dev/null +++ b/frontend/src/lib/components/icons/ActimoIcon.svelte @@ -0,0 +1,23 @@ + + + + diff --git a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte index 836b5a242b..8d0b08374b 100644 --- a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte +++ b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte @@ -7,9 +7,17 @@ let { height = '24px', width = '24px' }: Props = $props() - + + diff --git a/frontend/src/lib/components/icons/ActivitypubIcon.svelte b/frontend/src/lib/components/icons/ActivitypubIcon.svelte index 0185d98f3a..b8eea782f7 100644 --- a/frontend/src/lib/components/icons/ActivitypubIcon.svelte +++ b/frontend/src/lib/components/icons/ActivitypubIcon.svelte @@ -1,12 +1,13 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AdRapidIcon.svelte b/frontend/src/lib/components/icons/AdRapidIcon.svelte new file mode 100644 index 0000000000..443b96a548 --- /dev/null +++ b/frontend/src/lib/components/icons/AdRapidIcon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/src/lib/components/icons/AdhookIcon.svelte b/frontend/src/lib/components/icons/AdhookIcon.svelte new file mode 100644 index 0000000000..89da89b9cf --- /dev/null +++ b/frontend/src/lib/components/icons/AdhookIcon.svelte @@ -0,0 +1,25 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte index 2f04246966..f1b513be98 100644 --- a/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte +++ b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte @@ -1,24 +1,24 @@ - - - - - + + diff --git a/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte b/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte new file mode 100644 index 0000000000..c104bca866 --- /dev/null +++ b/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte @@ -0,0 +1,21 @@ + + + + diff --git a/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte b/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte new file mode 100644 index 0000000000..e5a6169bbe --- /dev/null +++ b/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/Ai21Icon.svelte b/frontend/src/lib/components/icons/Ai21Icon.svelte new file mode 100644 index 0000000000..600fb8a3b6 --- /dev/null +++ b/frontend/src/lib/components/icons/Ai21Icon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/AiAgentIcon.svelte b/frontend/src/lib/components/icons/AiAgentIcon.svelte new file mode 100644 index 0000000000..f3d042c98d --- /dev/null +++ b/frontend/src/lib/components/icons/AiAgentIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AirtableIcon.svelte b/frontend/src/lib/components/icons/AirtableIcon.svelte index e71aa95bdd..70cc6c1698 100644 --- a/frontend/src/lib/components/icons/AirtableIcon.svelte +++ b/frontend/src/lib/components/icons/AirtableIcon.svelte @@ -1,22 +1,31 @@ + + d="m228.6 47.2-190.9 79c-10.6 4.4-10.5 19.5.2 23.7l191.7 76c16.8 6.7 35.6 6.7 52.4 0l191.7-76c10.7-4.2 10.8-19.3.2-23.7L283 47.2c-17.4-7.2-37-7.2-54.4 0" + style="fill:#fcb400" + /> + diff --git a/frontend/src/lib/components/icons/AlgoliaIcon.svelte b/frontend/src/lib/components/icons/AlgoliaIcon.svelte index e003c778e4..5b1fdddedd 100644 --- a/frontend/src/lib/components/icons/AlgoliaIcon.svelte +++ b/frontend/src/lib/components/icons/AlgoliaIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/AmqpIcon.svelte b/frontend/src/lib/components/icons/AmqpIcon.svelte index 41987f704f..c5de0c1699 100644 --- a/frontend/src/lib/components/icons/AmqpIcon.svelte +++ b/frontend/src/lib/components/icons/AmqpIcon.svelte @@ -8,6 +8,8 @@ let { size = 16, color = undefined, class: clazz = '' }: Props = $props() + - - + + diff --git a/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte new file mode 100644 index 0000000000..8fe884c479 --- /dev/null +++ b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte @@ -0,0 +1,25 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ApifyIcon.svelte b/frontend/src/lib/components/icons/ApifyIcon.svelte index b5f1529bdc..9c9e6d5978 100644 --- a/frontend/src/lib/components/icons/ApifyIcon.svelte +++ b/frontend/src/lib/components/icons/ApifyIcon.svelte @@ -1,22 +1,32 @@ + - - - - - - - - - - - + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ApolloIcon.svelte b/frontend/src/lib/components/icons/ApolloIcon.svelte index 85dbb1730a..c41d0087f5 100644 --- a/frontend/src/lib/components/icons/ApolloIcon.svelte +++ b/frontend/src/lib/components/icons/ApolloIcon.svelte @@ -1,12 +1,31 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/AppwriteIcon.svelte b/frontend/src/lib/components/icons/AppwriteIcon.svelte index 53d8d0369b..34de69ee08 100644 --- a/frontend/src/lib/components/icons/AppwriteIcon.svelte +++ b/frontend/src/lib/components/icons/AppwriteIcon.svelte @@ -1,21 +1,29 @@ + - - + diff --git a/frontend/src/lib/components/icons/ArcGisIcon.svelte b/frontend/src/lib/components/icons/ArcGisIcon.svelte new file mode 100644 index 0000000000..660d28b804 --- /dev/null +++ b/frontend/src/lib/components/icons/ArcGisIcon.svelte @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/AsanaIcon.svelte b/frontend/src/lib/components/icons/AsanaIcon.svelte index 2c03b80a27..e6043784fd 100644 --- a/frontend/src/lib/components/icons/AsanaIcon.svelte +++ b/frontend/src/lib/components/icons/AsanaIcon.svelte @@ -7,6 +7,7 @@ let { height = '24px', width = '24px' }: Props = $props() + Asana diff --git a/frontend/src/lib/components/icons/AssemblyAiIcon.svelte b/frontend/src/lib/components/icons/AssemblyAiIcon.svelte new file mode 100644 index 0000000000..bfc65a32db --- /dev/null +++ b/frontend/src/lib/components/icons/AssemblyAiIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte index b4d019e4b6..d7342a2765 100644 --- a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte +++ b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte @@ -6,7 +6,12 @@ class?: string } - let { height = '24px', width = '24px', fill = 'black', class: className = '' }: Props = $props() + let { + height = '24px', + width = '24px', + fill = 'currentColor', + class: className = '' + }: Props = $props() + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + diff --git a/frontend/src/lib/components/icons/Auth0Icon.svelte b/frontend/src/lib/components/icons/Auth0Icon.svelte index fbf419e3cf..97fa53a3d9 100644 --- a/frontend/src/lib/components/icons/Auth0Icon.svelte +++ b/frontend/src/lib/components/icons/Auth0Icon.svelte @@ -1,4 +1,5 @@ + auth0-svg + + + diff --git a/frontend/src/lib/components/icons/AutheliaIcon.svelte b/frontend/src/lib/components/icons/AutheliaIcon.svelte index 037844b4d3..4878dcb26f 100644 --- a/frontend/src/lib/components/icons/AutheliaIcon.svelte +++ b/frontend/src/lib/components/icons/AutheliaIcon.svelte @@ -1,32 +1,48 @@ - - authelia-svg - - + authelia-svg + + - + - + - + - + - + 1340 25 134 25 437 0 575 -26 150 -80 311 -114 343 -43 41 -103 38 -148 -7z" + /> + diff --git a/frontend/src/lib/components/icons/AuthentikIcon.svelte b/frontend/src/lib/components/icons/AuthentikIcon.svelte index ee52fba6af..e79f18eef1 100644 --- a/frontend/src/lib/components/icons/AuthentikIcon.svelte +++ b/frontend/src/lib/components/icons/AuthentikIcon.svelte @@ -1,27 +1,19 @@ - - authentik-svg - - - - - - - - - - - - - - - + + + authentik-svg + + diff --git a/frontend/src/lib/components/icons/AwsEcrIcon.svelte b/frontend/src/lib/components/icons/AwsEcrIcon.svelte index 2801712f62..379a14e4a0 100644 --- a/frontend/src/lib/components/icons/AwsEcrIcon.svelte +++ b/frontend/src/lib/components/icons/AwsEcrIcon.svelte @@ -1,12 +1,16 @@ + + - + - - - - diff --git a/frontend/src/lib/components/icons/AwsIcon.svelte b/frontend/src/lib/components/icons/AwsIcon.svelte index 3b02d9d773..7f371a6229 100644 --- a/frontend/src/lib/components/icons/AwsIcon.svelte +++ b/frontend/src/lib/components/icons/AwsIcon.svelte @@ -1,33 +1,38 @@ + - - diff --git a/frontend/src/lib/components/icons/AzureIcon.svelte b/frontend/src/lib/components/icons/AzureIcon.svelte index c6c3b6d60b..011142b851 100644 --- a/frontend/src/lib/components/icons/AzureIcon.svelte +++ b/frontend/src/lib/components/icons/AzureIcon.svelte @@ -1,22 +1,86 @@ + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BRAND_COLORS.md b/frontend/src/lib/components/icons/BRAND_COLORS.md new file mode 100644 index 0000000000..1cff77a3d3 --- /dev/null +++ b/frontend/src/lib/components/icons/BRAND_COLORS.md @@ -0,0 +1,578 @@ +# Icon brand colours + +Where every icon's colours come from, and whether they survive both app surfaces. +Compiled from the components themselves during the audit — colours read from the fills and +the Tailwind pair classes, sources from each component's provenance comment, contrast +computed from those hexes against `surface-primary` in each theme. Maintained by hand from +here on: change an icon's colour or source and change its row. + +Surfaces: light `#fbfbfd`, dark `#2e3441`. Ratios are WCAG non-text contrast; **bold** marks a +mark that is effectively invisible on that surface. WCAG exempts logotypes from the 3:1 +floor, so a low ratio is a signal the colour may be wrong, not automatically a defect. + +`pair` = brand publishes a per-theme variant. Usually applied as `text-[#light] dark:text-[#dark]`; `AnsibleIcon` inverts instead (`dark:invert`), and `DatadogIcon`, `DenoIcon`, `DeepLIcon` and `TogglIcon` swap between two SVGs (`dark:hidden` / `hidden dark:block`) because their two marks are different artwork, not the same shape recoloured. +`fixed` = full-colour mark, same in both themes. `inherits` = brand publishes no colour, +so the mark takes the surrounding text colour. `mixed` = the root carries a +`fill="currentColor"` that hardcoded path fills override, so it is inert — these are +candidates for cleanup, not theme-aware icons. + +The Light/Dark columns show the colour that carries the mark; white and black knockout +details are omitted. Ratios are the best contrast any part of the mark achieves. + +**Do not change a colour here without a first-party source.** Several of these look like +mistakes and are not: Cal.com is deliberately greyscale, Google Cloud may not be recoloured, +Stripe is blurple rather than black. Third-party icon sets go stale and have been wrong +repeatedly — check the brand's own page. + +| Icon | Resource types | Mode | Light | Dark | ☀ | 🌙 | Source | +|---|---|---|---|---|---|---|---| +| `AblyIcon` | `ably` | fixed | #FF5416 | #FF5416 | 3.87 | 3.88 | brand.ably.com/logo | +| `AbstractApiIcon` | `abstractapi` | fixed | #20E492 | #20E492 | **1.62** | 12.47 | abstractapi.com's own logo SVG (6538df34291c9fa4ed28d6f7_Logo.svg) | +| `AcceloIcon` | `accelo` | fixed | #4C49CB | #4C49CB | 6.51 | 8.15 | Accelo_Logo-Primary.svg on accelo.com | +| `ActiveCampaignIcon` | `activecampaign` | pair | #004CFF | #FFFFFF | 5.84 | 12.47 | activecampaign.com/brand logo pack (ActiveCampaign-Glyph-Blue.svg / ActiveCampaign-Glyph-White.svg) | +| `ActivitypubIcon` | `activitypub` | fixed | #F1007E | #F1007E | 5.01 | 2.99 | activitypub.rocks/static/images/ActivityPub-logo.svg | +| `AcumbamailIcon` | `acumbamail` | fixed | #E62F71 | #E62F71 | 8.83 | 8.86 | Acumbamail's own isotype SVG, /static/favico/Acumbamail/favicon-32.svg on acumbamail.com | +| `AdhookIcon` | `adhook` | fixed | #00ACC6 | #00ACC6 | 2.63 | 4.58 | adhook's own logo (https://adhook.io/fr/images/logo.svg, `.cls-1{fill:#00acc6}`) | +| `AdobeAcrobatSignIcon` | `adobe_acrobat_sign` | fixed | #584CCC | #584CCC | 6.12 | 12.47 | Adobe's own Acrobat Sign product icon (adobe.com/cc-shared/assets/img/product-icons/svg/acrobat-sign.svg); same value in the live app favicon | +| `Ai21Icon` | `ai21` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | ai21.com (ai21-logo-black.svg / ai21-logo-white.svg) | +| `AirtableIcon` | `airtable` | mixed | #FCB400 | #FCB400 | 3.88 | 6.93 | airtable.com/favicon.ico (fixed full-colour mark: #18BFFF and #F82B60 panels) | +| `AlgoliaIcon` | `algolia` | pair | #003DFF | #FFFFFF | 6.53 | 12.47 | algolia.com logo pack (Algolia-mark-blue.svg / Algolia-mark-white.svg) | +| `AmqpIcon` | `amqp` | fixed | — | — | — | — | — | +| `AnsibleIcon` | `ansible` | pair | #1A1918 | #E5E6E7 | 16.99 | 9.98 | ansible/logos community-marks (Black and White variants, CC BY-SA 4.0) | +| `AnthropicIcon` | `anthropic` | pair | #141413 | #FAF9F5 | 17.84 | 11.84 | anthropics/skills | +| `ApifyIcon` | `apify` | fixed | #246DFF | #246DFF | 4.32 | 12.47 | apify.com/resources/brand | +| `ApolloIcon` | `apollo` | pair | #1F1F1E | #F8FF2C | 15.96 | 11.48 | apollo.io | +| `AppwriteIcon` | `appwrite` | mixed | #FD366E | #FD366E | 3.88 | 5.71 | https://appwrite.io/assets | +| `ArcGisIcon` | `arcgis_account` | fixed | #006FDE | #006FDE | 4.69 | 2.57 | Esri's ArcGIS Pro product logo (esri.com/content/dam/esrisites/en-us/common/icons/product-logos/arcgis-pro-64.svg) | +| `AsanaIcon` | `asana` | fixed | #FF584A | #FF584A | 3.01 | 4.01 | asana.com/brand | +| `AssemblyAiIcon` | `assemblyai` | pair | #1D1B16 | #C7C3B2 | 16.65 | 12.47 | assemblyai.com (assemblyai-logo-full-primary.svg / assemblyai-logo-full-secondary.svg) | +| `AttioIcon` | `attio` | pair | #1C1D1F | #FFFFFF | 16.32 | 12.47 | the attio.com header logo (--color-black-100 / --color-white-100) | +| `Auth0Icon` | `auth0` | pair | #232220 | #FFFFFF | 15.38 | 12.47 | auth0.com docs logo light.svg / dark.svg | +| `AutheliaIcon` | `authelia` | fixed | #3F51B4 | #3F51B4 | 6.67 | 1.81 | authelia.com/images/branding/logo-cropped.svg (light stop of the official #3F51B4→#113155 gradient, flattened) | +| `AuthentikIcon` | `authentik` | pair | #FD4B2D | #FFFFFF | 3.27 | 12.47 | goauthentik.io/press | +| `AwsEcrIcon` | `aws_ecr` | fixed | #ED7100 | #ED7100 | 2.92 | 12.47 | the AWS Architecture Icons package (Icon-package_07312026, Arch_Containers/Arch_Amazon-Elastic-Container-Registry) | +| `AwsIcon` | `aws`, `redshift` | pair | #252F3E | #FF9900 | 13.07 | 5.83 | AWS's own logo files (d0.awsstatic.com/logos/powered-by-aws{,-white}.png) | +| `AzureIcon` | `azure` | fixed | — | — | — | — | Microsoft's own logo_azure.svg (learn.microsoft.com/media/logos/logo_azure.svg), whose outer wedges add the #114A8B->#0669BC and #3CCBF4->#2892DF gradients | +| `BambooHrIcon` | `bamboo_hr` | pair | #599D15 | #FFFFFF | 3.25 | 12.47 | bamboohr.com (Encore --brandColor; bamboohr-logo-white.png is the published reversed variant) | +| `BaremetricsIcon` | `baremetrics` | fixed | #5386FF | #5386FF | 3.27 | 3.70 | the mark in baremetrics.com's header logo (baremetrics-logo.svg), the asset this path is taken from | +| `BaserowIcon` | `baserow`, `baserow_table` | fixed | #2BC3F1 | #2BC3F1 | 4.96 | 6.05 | the baserow.io favicon and horizontal logo | +| `BasisTheoryIcon` | `basis_theory` | pair | #1D2032 | #EBEDFF | 15.57 | 10.74 | developers.basistheory.com/img/bt-logo-light.svg and bt-logo-dark.svg, which ship the same mark geometry in the two theme colours | +| `BeamerIcon` | `beamer` | pair | #1C1E21 | #FFFFFF | 16.16 | 12.47 | the getbeamer.com header logo (g#isotype) and their webclip app icon, which sets the same mark in white on #1C1E21 | +| `BigQueryIcon` | `bigquery` | fixed | #34A853 | #34A853 | 3.80 | 7.30 | Google Cloud's official icon library (cloud.google.com/icons, core-products-icons.zip) | +| `BitbucketIcon` | `bitbucket` | pair | #1868DB | #FFFFFF | 5.03 | 12.47 | atlassian.design/foundations/logos (Bitbucket mark, brand and inverse) | +| `BitlyIcon` | `bitly` | fixed | #F36600 | #F36600 | 3.03 | 3.99 | bitly.com/pages/bitly-logo-usage-guidelines-for-media (Bitly-MediaKit glyph_bitly_orange_RGB.svg) | +| `BloggerIcon` | `blogger` | fixed | #F57C00 | #F57C00 | 2.62 | 12.47 | Google's Blogger product logo (gstatic.com/images/branding/productlogos/blogger/v5/192px.svg) | +| `BlueskyIcon` | `bluesky` | pair | #0560FF | #FFFFFF | 4.93 | 12.47 | bsky.social/about/support/branding | +| `BotifyIcon` | `botify` | fixed | #A973FF | #A973FF | 3.09 | 3.91 | botify.com design tokens (--color--surface--purple-05) | +| `BoxIcon` | `box` | pair | #0061D5 | #FFFFFF | 5.54 | 12.47 | box.com (.box-logo-svg fill:#0061d5, reversed to #fff over the dark masthead) | +| `BrevoIcon` | `brevo`, `sendinblue` | fixed | #0B996E | #0B996E | 3.51 | 12.47 | brevo.com's favicon.svg | +| `BrexIcon` | `brex` | pair | #15191E | #FFFFFF | 17.08 | 12.47 | brex.com | +| `BrowserlessIcon` | `browserless` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | browserless.io/favicon.svg | +| `BubbleIcon` | `bubble` | mixed | #0000FF | #0000FF | 8.31 | 5.71 | the logo SVG served on bubble.io/brand; the B is #262626 there, kept as currentColor so the monochrome part follows the app theme | +| `BuildkiteIcon` | `buildkite` | fixed | #30F2A2 | #30F2A2 | 2.04 | 8.52 | buildkite.com/about/brand-assets | +| `BunIcon` | — | fixed | #FBF0DF | #FBF0DF | 6.48 | 12.47 | https://bun.com/logo.svg | +| `ButtondownIcon` | `buttondown` | fixed | #0069FF | #0069FF | 4.55 | 2.65 | https://buttondown.com/brand | +| `CSharpIcon` | — | fixed | #927BE5 | #927BE5 | 7.68 | 12.47 | dotnet/brand logo/language-icons/csharp-72.svg (CC0) | +| `CalcomIcon` | `calcom` | pair | #292929 | #FAFAFA | 14.08 | 11.95 | design.cal.com | +| `CalendlyIcon` | `calendly` | pair | #006BFF | #FFFFFF | 4.47 | 12.47 | Calendly's 2024 External Brand Guidelines and calendly_brand mark_white.svg (media kit on calendly.com/newsroom) | +| `CampaynIcon` | `campayn` | fixed | #008AFF | #008AFF | 3.34 | 12.47 | app.campayn.com/images/campayn/favicons/safari-pinned-tab.svg (colours sampled from android-chrome-512x512.png in the same directory) | +| `CertopusIcon` | `certopus` | fixed | #FF6E30 | #FF6E30 | 12.07 | 12.47 | https://certopus.com/images/logo/logo_circle.svg | +| `ChromaIcon` | `chromadb` | fixed | #FFDE2D | #FFDE2D | 3.65 | 9.35 | Chroma's own logo SVG served by trychroma.com (chroma-wordmark.svg) | +| `CircleCiIcon` | `circleci` | pair | #161616 | #FFFFFF | 17.51 | 12.47 | brand.circleci.com | +| `CiscoIcon` | `cisco` | pair | #00BCEB | #FFFFFF | 2.16 | 12.47 | cisco.com logo SVG and newsroom.cisco.com/logos | +| `ClaudeIcon` | — | fixed | #D97757 | #D97757 | 3.02 | 12.47 | https://claude.ai/favicon.svg (Anthropic's own asset) | +| `ClearbitIcon` | `clearbit` | fixed | #4DB1FD | #4DB1FD | 20.32 | 10.83 | clearbit.com/logo.svg | +| `ClerkIcon` | `clerk` | fixed | #BAB1FF | #BAB1FF | 5.10 | 6.43 | clerk.com/brand-assets (symbol-primary.svg) | +| `ClickhouseIcon` | `clickhouse` | pair | #161616 | #FFFFFF | 17.51 | 12.47 | clickhouse.design/brand/logo-usage (logomark, on-light / on-dark) | +| `ClickupIcon` | `clickup` | fixed | #6647F0 | #6647F0 | 5.46 | 4.12 | clickup.com/brand (v4 Logomark-gradient.svg); the gradient mark is the same on light and dark, and the guidelines say "don't change the color" | +| `CloseIcon` | `close` | fixed | #4EC375 | #4EC375 | 4.77 | 7.39 | close.com/brand (close-logo-2024 mark.svg) | +| `CloudflareIcon` | `cloudflare` | fixed | #FF5F08 | #FF5F08 | 2.95 | 5.83 | the logomark shipped on cloudflare.com, blog.cloudflare.com and workers.cloudflare.com | +| `CloudinaryIcon` | `cloudinary` | pair | #3448C5 | #FFFFFF | 7.06 | 12.47 | cloudinary_logo_for_white_bg.svg and cloudinary_logo_for_black_bg.svg on cloudinary-res.cloudinary.com | +| `CockroachDbIcon` | `cockroachdb` | pair | #6933FF | #FFFFFF | 5.78 | 12.47 | cockroachlabs.com (electric-purple-500, also the CockroachDB docs primaryColor) and the docs light/dark logo pair | +| `CodaIcon` | `coda` | fixed | #F46A54 | #F46A54 | 2.89 | 4.18 | Coda's own app icon, https://cdn.coda.io/icons/png/color/coda-192.png (single-colour mark, no dark variant published) | +| `CodatIcon` | `codat` | fixed | #D1E100 | #D1E100 | 17.31 | 8.60 | codat.io (logo-white.svg glyph outlines, colours from the site palette); framing matches their 300x300 favicon exactly | +| `CohereIcon` | `cohere` | fixed | #355146 | #355146 | 8.41 | 12.47 | https://cohere.com/logo.svg | +| `CoinMarketCapIcon` | `coinmarketcap` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | coinmarketcap.com | +| `CoinbaseIcon` | `coinbase` | pair | #0052FF | — | 5.57 | — | Coinbase's own light/dark logo files (mintcdn.com/coinbase-prod/.../logos/wordmark-light.svg and wordmark-dark.svg, served by docs.cdp.coinbase.com) | +| `ComapeoIcon` | `comapeo_server` | pair | #022199 | #0066FF | 12.09 | 2.58 | the CoMapeo Cloud mark shipped as public/favicon.svg in digidem/comapeo-cloud-app (the server this resource connects to) | +| `ConfluenceIcon` | `confluence` | fixed | #1868DB | #1868DB | 5.03 | 12.47 | Atlassian's @atlaskit/logo (atlassian.design logo library) | +| `ContentfulIcon` | `contentful` | fixed | #1773EB | #1773EB | 4.33 | 9.08 | Contentful's Forma 36 design system (ContentfulLogoIcon) | +| `ContiguityIcon` | `contiguity` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | contiguity.com/assets/icon-white.png and icon-black.png (docs.contiguity.com likewise ships logo/black.svg for light and logo/white.svg for dark) | +| `ConvertKitIcon` | `convertkit` | pair | #1E1E1E | #F2EFE9 | 16.13 | 10.87 | kit.com/brand | +| `CoupaIcon` | `coupa` | pair | #1565C0 | #FFFFFF | 5.56 | 12.47 | the Coupa logo kit linked from coupa.com/company/press-kit, which ships the mark in blue and a white reversed variant | +| `CssIcon` | — | fixed | #663399 | #663399 | 8.13 | 12.47 | github.com/CSS-Next/logo.css (CC0), the official CSS logo endorsed by the W3C CSS WG | +| `CurrencyApiIcon` | `currencyapi` | fixed | #2994FF | #2994FF | 9.13 | 4.67 | currencyapi.com/img/currencyapi_logo_color.svg | +| `DatabricksIcon` | `databricks` | fixed | #FF3621 | #FF3621 | 3.50 | 3.45 | Databricks' own logo asset (databricks.com/sites/default/files/2023-08/databricks-default.png) | +| `DatadogIcon` | `datadog` | pair | #632CA6 | #FFFFFF | 8.32 | 12.47 | datadoghq.com press kit | +| `DatoCmsIcon` | `datocms` | fixed | #FF7751 | #FF7751 | 2.54 | 4.76 | datocms.com/company/brand-assets | +| `DbtIcon` | `dbt_profile` | fixed | #FE6703 | #FE6703 | 2.84 | 4.25 | the dbt Labs brand assets (getdbt.com/brand-guidelines) | +| `DeelIcon` | `deel` | pair | #1B1B1B | #FFFFFF | 16.67 | 12.47 | deel.com's own logo_revamp.svg / logo_revamp_white.svg | +| `DeepInfraIcon` | `deep_infra` | pair | #2A3275 | #4C9CEC | 11.22 | 12.47 | the DeepInfra press-kit logo pack (deepinfra.com/media-center → DEEPINFRA_LOGO_COLOR / DEEPINFRA_LOGO_WHITE) | +| `DeepLIcon` | `deepl` | pair | #0F2B46 | #FFFFFF | 13.97 | 12.47 | DeepL's official logo pack on deepl.com/en/press ("Logo Deep Blue" RGB #0F2B46 and the published "Logo White" reversed variant) | +| `DeepSeekIcon` | `deepseek` | pair | #4D6BFE | #6799FE | 4.19 | 4.49 | deepseek.com design tokens (--ds-color-brand under :root / [data-theme=dark]) | +| `DenoIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | the "Deno Logo Guidelines 2024" asset pack on deno.com/brand | +| `DigitalOceanIcon` | `digitalocean` | fixed | #0080FF | #0080FF | 3.67 | 3.29 | DigitalOcean's official logo kit (DO_Logo_icon_blue.svg, linked from digitalocean.com/press) | +| `DiscordIcon` | `discord`, `discord_webhook` | mixed | #5865F2 | #5865F2 | 4.46 | 5.71 | https://discord.com/branding | +| `DiscourseIcon` | `discourse` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | discourse.org/brand (discourse-icon.svg / discourse-icon-dark.svg) | +| `DocSpringIcon` | `docspring` | fixed | #3C8EE0 | #3C8EE0 | 3.31 | 12.47 | DocSpring's own logo SVG, docspring.com/assets/logo-text-*.svg | +| `DockerIcon` | — | fixed | #2560FF | #2560FF | 4.84 | 2.49 | Docker's official logo kit (docker.com/company/newsroom/media-resources) | +| `DocusignIcon` | `docusign` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.docusign.com/logo: only the Nexus overlap flips per background, Cobalt #4C00FF and Poppy #FF5252 must not be recoloured | +| `DropboxIcon` | `dropbox` | fixed | #0061FE | #0061FE | 4.91 | 2.46 | brand.dropbox.com/logo and the DIG token dig-color__primary__base | +| `DuckDbIcon` | `duckdb` | pair | #1A1A1A | #FFF100 | 16.84 | 10.59 | duckdb.org/design logo package (DuckDB_icon-lightmode.svg / DuckDB_icon-darkmode.svg) | +| `DucklakeIcon` | — | pair | #1A1A1A | #2EAFFF | 16.84 | 5.16 | duckdb.org | +| `DustIcon` | `dust` | fixed | #FE9C1A | #FE9C1A | 4.04 | 10.64 | dust.tt/home/brand-resources (Dust_LogoSquare.svg from their brand kit) | +| `DynatraceIcon` | `dynatrace` | fixed | #1496FF | #1496FF | 10.01 | 7.83 | Dynatrace brand guidelines (live.standards.site/dynatrace, Dynatrace_mark_color.svg) | +| `EdgeDbIcon` | `edgedb` | fixed | #8FAF24 | #8FAF24 | 2.44 | 4.94 | geldata.com (favicon/apple-touch-icon glyph and its ) | +| `EnodeIcon` | `enode` | pair | #5D770D | #E8E8E1 | 4.94 | 10.13 | enode.com/static/favicon.svg | +| `EventbriteIcon` | `eventbrite` | fixed | #FF5E30 | #FF5E30 | 2.95 | 4.10 | the 2025 Eventbrite press kit logos; the brand publishes no reversed variant | +| `ExaIcon` | `exa` | pair | #0143D9 | #FFFFFF | 7.24 | 12.47 | exa.ai/brand (Exa Brand Assets kit, Logomark Blue/White) | +| `FaunadbIcon` | `faunadb` | pair | #3F00A5 | #604BE9 | 11.58 | 2.19 | Fauna's own VS Code extension icons (fauna/fauna-vscode: icons/fauna.svg for light themes, icons/fauna-light.svg for dark) | +| `FigmaIcon` | `figma` | fixed | #24CB71 | #24CB71 | 4.42 | 5.85 | static.figma.com/app/icon/2/favicon.svg (2025 brand refresh) | +| `FirebaseIcon` | `firebase` | fixed | #FF9100 | #FF9100 | 4.58 | 7.81 | firebase.google.com/brand-guidelines (Logomark_Full Color.svg in firebase-brand-assets.zip) | +| `FlyIcon` | `fly` | pair | #24175B | #FFFFFF | 15.07 | 12.47 | fly.io | +| `FormstackIcon` | `formstack` | fixed | #21B573 | #21B573 | 2.56 | 4.70 | the brand guide at formstack.com/press-kit | +| `FoxentryIcon` | `foxentry` | fixed | #E74600 | #E74600 | 5.09 | 4.14 | foxentry.com/assets/img/logo-foxentry-symbol.svg | +| `FreshdeskIcon` | `freshdesk` | fixed | #20A849 | #20A849 | 3.01 | 12.47 | Freshworks' own product-logo asset (freshdesk-dew.svg, used on freshworks.com/apps) | +| `FrontAppIcon` | `frontapp` | fixed | #A857F1 | #A857F1 | 3.83 | 3.15 | the logo mark front.com ships inline on its own pages; the mark keeps this purple on both light and dark backgrounds | +| `FunkwhaleIcon` | `funkwhale` | mixed | #009FE3 | #009FE3 | 10.69 | 5.71 | www.funkwhale.audio/logos (theme/images/icon.svg) | +| `GSheetsIcon` | `gsheets` | fixed | #009954 | #009954 | 3.57 | 12.47 | Google product logo sheets_2026q3 (gstatic productlogos, used on workspace.google.com/products/sheets) | +| `GcalIcon` | `gcal` | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | Google's own Calendar 2026 product logo, https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg (paths verbatim) | +| `GdocsIcon` | `gdocs` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | Google's own Docs product icon (gstatic.com/images/branding/productlogos/docs_2026/v2/web/192px.svg, served on workspace.google.com/products/docs) | +| `GdriveIcon` | `gdrive` | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg, Google's own product-logo CDN; paths and gradient stops are verbatim | +| `GhostCmsIcon` | `ghostcms` | pair | #15171A | #FFFFFF | 17.38 | 12.47 | docs.ghost.org | +| `GiphyIcon` | `giphy` | fixed | #FFF35C | #FFF35C | 4.76 | 10.83 | GIPHY's own app icon (giphy.com/static/img/icons/apple-touch-icon-180px.png) | +| `GitBookIcon` | `gitbook` | pair | #181C1F | #F2F7F7 | 16.59 | 11.54 | the GitBook-icon-dark / GitBook-icon-light downloads on gitbook.gitbook.io/brand-assets, matching the live gitbook.com favicon | +| `GitIcon` | `git_repository`, `git` | fixed | #F03C2E | #F03C2E | 3.77 | 3.20 | git-scm.com/community/logos (Git-Icon-1788C.svg, logo by Jason Long, CC BY 3.0) | +| `GithubIcon` | `github` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.github.com/foundations/logo | +| `GitlabIcon` | `gitlab` | mixed | #FC6D26 | #FC6D26 | 4.01 | 6.18 | https://design.gitlab.com/brand-design/color (Orange 03p/02p/01p, "colors from our core logo") | +| `GmailIcon` | `gmail` | mixed | #4285F4 | #4285F4 | 5.61 | 7.30 | gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png | +| `GoogleAiIcon` | `googleai` | fixed | #217BFE | #217BFE | 3.81 | 5.44 | Google's standard Gemini product icon (gstatic.com/images/branding/productlogos/gemini/v1/192px.svg) | +| `GoogleCalendarIcon` | — | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | the Google Calendar 2026 product icon, taken verbatim from https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg | +| `GoogleCloudIcon` | `gcloud`, `gcp_service_account` | fixed | #EA4335 | #EA4335 | 3.80 | 7.30 | Google's own product logo asset https://www.gstatic.com/images/branding/product/2x/google_cloud_64dp.png | +| `GoogleDriveIcon` | — | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg (Drive 2026 mark, copied verbatim) | +| `GoogleFormsIcon` | `gforms` | fixed | #969DFF | #969DFF | 5.99 | 12.47 | Google's own Forms product icon at www.gstatic.com/images/branding/productlogos/forms_2026/v2/web/192px.svg | +| `GoogleIcon` | `google`, `gworkspace` | mixed | #4285F4 | #4285F4 | 3.88 | 7.30 | the G mark Google serves in accounts.google.com/gsi/client | +| `GorgiasIcon` | `gorgias` | pair | #000000 | #FFF9F4 | 20.32 | 11.94 | gorgias.com/about-us/style, which ships the symbol as a "Dark"/"Light" pair | +| `GraphqlIcon` | `graphql` | pair | #E10098 | #FFFFFF | 4.37 | 12.47 | graphql.org | +| `GreipIcon` | `greip` | pair | #141C27 | #FFFFFF | 16.59 | 12.47 | docs.greip.io | +| `GristIcon` | `grist` | fixed | #16B378 | #16B378 | 2.62 | 8.25 | getgrist.com/trademark/assets/ | +| `GroqIcon` | `groqai`, `groq` | fixed | #F43E01 | #F43E01 | 3.67 | 12.47 | https://groq.com/favicon.svg | +| `HackernewsIcon` | `hackernews` | fixed | #FF6600 | #FF6600 | 2.84 | 12.47 | news.ycombinator.com/y18.svg | +| `HoldedIcon` | `holded` | fixed | #FD454D | #FD454D | 3.31 | 3.64 | cdn.holded.com/assets/img/brand/holded-logo.svg | +| `HoneybadgerIcon` | `honeybadger` | fixed | #EA5937 | #EA5937 | 3.40 | 3.55 | honeybadger.io/favicon.svg | +| `HtmlIcon` | — | fixed | #E44D26 | #E44D26 | 3.77 | 12.47 | the W3C HTML5 logo, w3.org/html/logo (downloads/HTML5_Logo.svg) | +| `HubspotIcon` | `hubspot` | pair | #FF2F00 | #FFFFFF | 3.59 | 12.47 | hubspot.com | +| `IfsIcon` | `ifs_cloud_oidc` | fixed | #72C9F8 | #72C9F8 | 6.03 | 6.79 | the IFS symbol on ifs.com | +| `IftttIcon` | `ifttt` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | ifttt.com | +| `InkeepIcon` | `inkeep` | fixed | #D5E5FF | #D5E5FF | 2.45 | 9.79 | Inkeep's brand page "Icon Core" (https://inkeep.com/brand) | +| `IntercomIcon` | `intercom` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | intercom.com | +| `IpinfoIcon` | `ipinfo` | pair | #3091CF | #FFFFFF | 3.34 | 12.47 | ipinfo.io logo-positive.svg and logo-negative.svg | +| `JavaIcon` | — | fixed | #007396 | #007396 | 5.22 | 4.93 | Oracle's Java Branding and Licensing Guidelines v21 (oracle.com/a/ocom/docs/java-licensing-logo-guidelines-1908204.pdf) | +| `JavaScriptIcon` | — | fixed | #F7DF1E | #F7DF1E | 20.32 | 9.22 | js.svg in github.com/voodootikigod/logo.js, the origin of the JavaScript logo | +| `JiraIcon` | `jira` | fixed | #1868DB | #1868DB | 5.03 | 12.47 | Atlassian's official Jira logo pack (atlassian.design/foundations/logos) | +| `JoomlaIcon` | `joomla` | fixed | #7AC143 | #7AC143 | 3.58 | 6.23 | the official logo at cdn.joomla.org/images/joomla-colours-logo.svg | +| `JotformIcon` | `jotform` | pair | #0A1551 | #FFFFFF | 16.40 | 12.47 | jotform.com footer logomark (#jotform-logomark-fourth is filled with --jf-logo-img: #0A1551 light, #fff dark) | +| `JsonIcon` | — | fixed | #F9A825 | #F9A825 | 1.91 | 6.33 | Material Design Yellow 800 (api.flutter.dev Colors.yellow[800]); glyph is Google's Material Symbols "data_object" | +| `JumpCloudIcon` | `jumpcloud` | pair | #002B49 | #F7F7FB | 14.09 | 11.67 | jumpcloud.com/press (Ocean Blue / White Smoke); White Smoke is the brand's own reversed logo for dark backgrounds | +| `KafkaIcon` | `kafka` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | apache/kafka | +| `KanidmIcon` | `kanidm` | fixed | #B1DEF4 | #B1DEF4 | 20.32 | 12.47 | artwork/logo-square.svg in github.com/kanidm/kanidm (full palette: #FF6600 #803300 #D45500 #2A3455 #B1B3B8 #CCCCCC) | +| `KeycloakIcon` | `keycloak` | fixed | #00B8E3 | #00B8E3 | 8.18 | 10.65 | keycloak.org's own mark, https://www.keycloak.org/resources/images/icon.svg (cyan #00B8E3/#33C6E9/#008AAA over greys #4D4D4D–#EDEDED, single theme) | +| `KlaviyoIcon` | `klaviyo` | pair | #1D1E20 | #FFFFFF | 16.14 | 12.47 | klaviyo.com --color-core-charcoal; the flag mark is the standalone logomark the site header collapses to, and the shape of klaviyo.com/icons/icon-512x512.png | +| `KoboToolboxIcon` | `kobotoolbox` | fixed | #2095F3 | #2095F3 | 3.05 | 3.95 | the kobotoolbox.org header logo and $kobo-blue in kobotoolbox/kpi jsapp/scss/colors.scss | +| `KustomerIcon` | `kustomer` | fixed | #FBEC2A | #FBEC2A | 14.08 | 12.47 | kustomer.com/images/kustomer/Kusty.svg | +| `LangfuseIcon` | `langfuse` | fixed | #FF5D5F | #FF5D5F | 2.91 | 4.47 | langfuse.com/brand "Icon - Color (SVG)", used unmodified | +| `LessIcon` | — | pair | #274F82 | #FFFFFF | 8.04 | 12.47 | github.com/less/logo (MIT) | +| `LineIcon` | `line` | fixed | #06C755 | #06C755 | 2.18 | 5.53 | LINE's official brand icon asset (line.me/en/logo) | +| `LinearIcon` | `linear` | pair | #222326 | #F4F5F8 | 15.20 | 11.44 | linear.app/brand | +| `LinkdingIcon` | — | pair | #5856E0 | #ADABF7 | 5.32 | 5.91 | sissbruecker/linkding | +| `LinkedinIcon` | `linkedin` | mixed | #0A66C2 | #0A66C2 | 5.50 | 12.47 | the official inbug SVGs embedded in brand.linkedin.com/in-logo | +| `LinodeIcon` | `linode` | fixed | #004B16 | #004B16 | 10.07 | 4.54 | Linode's own packages/manager/src/assets/logo/logo.svg in linode/manager @3e53c92, the last revision before the Akamai rebrand dropped it | +| `LumaAiIcon` | `lumaai` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | lumalabs.ai (favicon-black.ico on light, favicon-white.ico on dark) | +| `MSSqlServerIcon` | — | fixed | #0094F0 | #0094F0 | 10.54 | 12.47 | learn.microsoft.com/en-us/azure/architecture/icons — Microsoft's anchor blue, a stop in its own SQL Server SVG and throughout the set's Fluent gradients | +| `MSTeamsIcon` | — | fixed | #A98AFF | #A98AFF | 12.96 | 12.47 | Microsoft's Teams-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com, served on microsoft.com/microsoft-teams); every gradient stop here is verbatim from it | +| `MagentoIcon` | `magento` | fixed | #F26322 | #F26322 | 3.09 | 3.91 | Magento's own logo asset, magento2 lib/web/images/logo.svg | +| `MailchimpIcon` | `mailchimp` | fixed | #241C15 | #241C15 | 16.23 | 10.74 | mailchimp.com/about/brand-assets | +| `MailerLiteIcon` | `mailerlite` | fixed | #09C269 | #09C269 | 2.27 | 5.31 | mailerlite.com/brand-assets | +| `MailgunIcon` | `mailgun` | fixed | #F04126 | #F04126 | 3.70 | 12.47 | mailgun.com's own logo-mailgun-icon.svg | +| `MandrillIcon` | `mandrill` | pair | #241C15 | #FFFFFF | 16.23 | 12.47 | mailchimp.com/about/brand-assets and Mandrill's own mandrillapp.com/img/navigation/freddie.svg | +| `MapboxIcon` | `mapbox` | pair | #0E1012 | #FFFFFF | 18.45 | 12.47 | mapbox.com | +| `MarkdownIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | dcurtis/markdown-mark (public domain) | +| `MastodonIcon` | `mastodon` | mixed | #6364FF | #6364FF | 7.07 | 12.47 | https://joinmastodon.org/branding | +| `MatrixIcon` | `matrix` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | matrix.org/branding | +| `MatteroomIcon` | `matteroom` | fixed | #134A81 | #134A81 | 8.74 | 12.47 | the MATTEROOM logomark vector at login.matteroom.com/images/login_logo.svg; square tile proportions taken from their own app icon at matteroom.com/favicon.ico | +| `MauticIcon` | `mautic` | pair | #4E5E9E | #FFFFFF | 5.94 | 12.47 | mautic.org/about/brand-logos-graphics (Mautic_Logo_LB.svg / Mautic_Logo_DB.svg); the "M" stays Sunglow #FDB933 in both, as the trademark policy requires the mark in its exact published form without alteration in colour | +| `McpIcon` | `mcp` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | modelcontextprotocol/modelcontextprotocol | +| `MediumIcon` | `medium` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | medium.design | +| `MeteosourceIcon` | `meteosource` | fixed | #FAD961 | #FAD961 | 2.87 | 9.01 | the logo in the meteosource.com site header (no brand page published) | +| `MezmoIcon` | `mezmo` | pair | #0A090C | #E6E6E5 | 19.22 | 9.99 | mezmo.com nav mark and docs.mezmo.com logo/light.png + logo/dark.png | +| `MicrosoftIcon` | `microsoft` | mixed | #F25022 | #F25022 | 3.88 | 7.24 | the official logo asset linked from Microsoft's logo third-party usage guidance | +| `MiroIcon` | `miro` | fixed | #FFDD33 | #FFDD33 | 16.46 | 9.29 | the Miro logo on miro.com | +| `MistralIcon` | `mistral` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | mistral.ai/favicon.svg (mid-band of the #FFAF01 -> #C4001D ramp); drawn here in currentColor, the monochrome variant mistral.ai/brand ships | +| `MixpanelIcon` | `mixpanel` | pair | #7856FF | #FFFFFF | 4.44 | 12.47 | brand.mixpanel.com/logo and /color (Purple 100); mixpanel.com ships the same pair as its light/dark favicons | +| `MollieIcon` | `mollie` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Mollie's app icon (my.mollie.com/assets/images/favicons/apple-touch-icon-180x180.png): a full-bleed disc with the lowercase m knocked out | +| `MondayIcon` | `monday` | fixed | #FB275D | #FB275D | 3.66 | 8.25 | monday.com's official logo pack (brand-monday.com/logo) | +| `MongodbIcon` | `mongodb` | pair | #00684A | #00ED64 | 6.60 | 7.90 | MongoDB brand resources and their LeafyGreen palette | +| `MotimateIcon` | `motimate` | fixed | #2DC89C | #2DC89C | 2.06 | 5.85 | motimateapp.com theme assets | +| `MqttIcon` | `mqtt` | pair | #660066 | #FFFFFF | 11.57 | 12.47 | mqtt/mqttorg-graphics | +| `Mysql` | `mysql` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | — | +| `NatsIcon` | `nats` | pair | #375C93 | #27AAE1 | 6.52 | 4.71 | cncf/artwork | +| `NeonDbIcon` | `neondb` | pair | #37C38F | #34D59A | 2.17 | 6.61 | neon.com/brand (neon-logomark-light-color.svg / neon-logomark-dark-color.svg) | +| `NetBoxIcon` | `netbox` | pair | #001423 | #FFFFFF | 18.07 | 12.47 | theme, so the second path carries its own fill- utilities | +| `NetlifyIcon` | `netlify` | pair | #05BDBA | #32E6E2 | 10.06 | 12.47 | netlify.com/brand (netlify-logo-monogram.zip, full-colour lightmode/darkmode) | +| `NetsuiteIcon` | `netsuite` | fixed | #BACCDB | #BACCDB | 7.71 | 7.57 | — | +| `NewsApiIcon` | `newsapi` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | newsapi.org | +| `NextcloudIcon` | `ocs`, `nextcloud` | pair | #0082C9 | #FFFFFF | 4.03 | 12.47 | nextcloud.com | +| `NocoDbIcon` | `nocodb` | fixed | #4351E8 | #4351E8 | 11.12 | 3.30 | nocodb.com's own Logo.svg / favicon | +| `NotionIcon` | `notion` | fixed | #FFFFFF | #FFFFFF | 20.32 | 12.47 | Notion's own app icon (notion.com/front-static/logo-ios.png) | +| `NuIcon` | — | fixed | #4D9B05 | #4D9B05 | 3.38 | 3.57 | nushell/vscode-nushell-lang assets/nu.svg | +| `OdkIcon` | `odk` | fixed | #3E77B4 | #3E77B4 | 6.37 | 2.67 | ODK brand assets (getodk.org/legal/brand/) | +| `OktaIcon` | `okta` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | okta.com | +| `OneSignalIcon` | `onesignal` | pair | #051B2C | #FFFFFF | 16.94 | 12.47 | OneSignal's official media kit (OneSignal-Logomark.svg / OneSignal-Logomark-White.svg), matching the prefers-color-scheme pair in their own onesignal.com/favicon.svg | +| `OpenRouterIcon` | `openrouter` | pair | #7624F4 | #C8FF00 | 6.10 | 10.55 | openrouter.ai/brand/v2/openrouter-glyph-{light,dark}.svg | +| `OpenWeatherIcon` | `openweather` | pair | #EA6D4A | — | 2.99 | — | openweather.co.uk/brand_guidelines | +| `OpenaiIcon` | `openai` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | openai.com/brand (Blossom_Light.svg / Blossom_Dark.svg) | +| `OracleDBIcon` | `oracledb` | fixed | #C74634 | #C74634 | 4.67 | 2.59 | Oracle's own logo SVG at https://www.oracle.com/a/ocom/img/oracle-logo.svg | +| `OutreachIcon` | `outreach` | pair | #5951FF | #FFFFFF | 5.02 | 12.47 | outreach.ai | +| `PHPIcon` | — | fixed | #AEB2D5 | #AEB2D5 | 20.32 | 12.47 | php.net/images/logos/new-php-logo.svg (php.net/download-logos.php) | +| `PagerDutyIcon` | `pagerduty` | pair | #048A24 | #FFFFFF | 4.35 | 12.47 | pagerduty.com/brand "P icon" pack (P-GreenRGB.svg / P-WhiteRGB.svg) | +| `PandaDocIcon` | `pandadoc` | fixed | #248567 | #248567 | 4.39 | 12.47 | the PandaDoc logo shipped on pandadoc.com (header logo SVG and favicon); white monogram on the green tile in both themes | +| `PaychexIcon` | `paychex` | fixed | #004B8D | #004B8D | 8.50 | **1.42** | paychex.com's own logo SVG (themes/custom/paychex2/images/svg/logo-paychex.svg, .st0) | +| `PaylocityIcon` | `paylocity` | fixed | #ED2024 | #ED2024 | 4.20 | 12.47 | paylocity.com design-system CSS (.styleBGBrandGradient) | +| `PaypalIcon` | `paypal` | fixed | #002991 | #002991 | 11.77 | 6.93 | PayPal's own paypal-mark-color_new.svg (site header logo on paypal.com) | +| `PersonaIcon` | `persona` | fixed | #7379FD | #7379FD | 3.45 | 3.49 | https://withpersona.com/favicon.svg (Persona, identity verification) | +| `PersonioIcon` | `personio` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | personio.design/brand/how-we-look/logo | +| `PhraseIcon` | `phrase` | pair | #181818 | #FFFFFF | 17.18 | 12.47 | Logo_primary.svg and Logo_black_background.svg on phrase.com/brand | +| `PineconeIcon` | `pinecone` | pair | #201D1E | #FFFFFF | 16.18 | 12.47 | pinecone.io/newsroom/media-kit | +| `PinterestIcon` | `pinterest` | fixed | #E60023 | #E60023 | 4.63 | 2.61 | Pinterest Gestalt tokens (color.icon.brand.primary = red.pushpin.450, identical in sema-color-light and sema-color-dark) | +| `PipedriveIcon` | `pipedrive` | fixed | #017737 | #017737 | 5.50 | 12.47 | pipedrive.com logo token --pd-puco-global-color-green-500 | +| `PlanetScaleIcon` | `planetscale` | pair | #1A1A1A | #FAFAFA | 16.84 | 11.95 | planetscale.com | +| `PocketIdIcon` | `pocketid` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | pocket-id.org header logo (fill isDark ? #ffffff : #000000) and pocket-id/pocket-id frontend/src/lib/components/logo.svelte | +| `PostgresIcon` | `postgresql` | mixed | #336791 | #336791 | 20.32 | 12.47 | the official 3-colour Slonik SVG on wiki.postgresql.org/wiki/Logo | +| `PostmarkIcon` | `postmark` | fixed | #FFDE00 | #FFDE00 | 20.32 | 9.33 | postmarkapp.com/images/logo-stamp-simple.svg | +| `PowershellIcon` | — | fixed | #00FF18 | #00FF18 | 20.32 | 12.47 | github.com/PowerShell/PowerShell/blob/master/assets/ps_black_64.svg | +| `PusherIcon` | `pusher` | pair | #300D4F | #FFFFFF | 15.68 | 12.47 | pusher.com media kit (Pusher logo primary.png / Pusher logo secondary.png) | +| `PushoverIcon` | `pushover` | fixed | #249DF1 | #249DF1 | 2.83 | 12.47 | support.pushover.net/i63-pushover-logos-and-usage | +| `QoveryIcon` | `qovery` | fixed | #642DFF | #642DFF | 6.05 | 2.00 | qovery.com/logos/qovery-logo-black.svg | +| `QuickbooksIcon` | `quickbooks` | fixed | #2CA01C | #2CA01C | 3.30 | 3.65 | the QuickBooks logo SVG on intuit.com's press room | +| `RIcon` | — | fixed | #276DC3 | #276DC3 | 6.46 | 7.89 | r-project.org/logo (gradient stops copied from the authoritative Rlogo.svg) | +| `RaindropIcon` | `raindrop` | fixed | #1988E0 | #1988E0 | 5.33 | 12.47 | app.raindrop.io/assets/icon_raw.svg and raindrop.io icon_128.png | +| `ReactIcon` | — | pair | #087EA4 | #58C4DC | 4.48 | 6.14 | react.dev brand menu (images/brand/logo_light.svg, logo_dark.svg) | +| `ReadmeIcon` | `readme` | pair | #213AFF | #FFFFFF | 6.53 | 12.47 | readme.com's own prefers-color-scheme favicon pair (favicon-213aff.ico / favicon-ffffff.ico) | +| `ReadwiseIcon` | `readwise` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | readwise.io's logo-standalone-dark.svg (light) and logo-standalone-white.svg (dark) | +| `RecraftIcon` | `recraft` | fixed | #000000 | #000000 | 20.32 | 12.47 | Recraft's press-kit "Icon White" mark (https://www.recraft.ai/press-releases) | +| `RedditIcon` | `reddit` | fixed | #FF6600 | #FF6600 | 20.32 | 12.47 | redditinc.com/brand ("a stylized Snoo head contained within an OrangeRed (#FF4500) conversation bubble") | +| `RenderIcon` | `render` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | render.com | +| `ReplicateIcon` | `replicate` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | replicate.com header logo (glyph is currentColor; site CSS sets #000, and #FFF under .dark) | +| `ResendIcon` | `resend` | pair | #000000 | #FDFDFD | 20.32 | 12.26 | cdn.resend.com/brand/resend-icon-black.svg and resend-icon-white.svg | +| `RingCentralIcon` | `ringcentral` | pair | #FF7A00 | #FFFFFF | 2.53 | 12.47 | assets.ringcentral.com/us/brand-library/logos/ringcentral-logo.zip (RingCentral logo fullcolor.svg / RingCentral logo white.svg) | +| `RocketChatIcon` | `rocketchat` | fixed | #F5455C | #F5455C | 3.45 | 3.50 | Rocket.Chat brand colours (docs.rocket.chat/v1/docs/colors), the primary red of their logo | +| `RssIcon` | `rss` | fixed | #FFA500 | #FFA500 | 1.91 | 12.47 | Mozilla's feed icon guidelines (mozilla.org/en-US/foundation/feed-icon-guidelines/), which fix no exact hex | +| `RubyIcon` | — | fixed | #FB7655 | #FB7655 | 10.63 | 12.47 | the official logo kit at ruby-lang.org/en/about/logo | +| `RunPodIcon` | `runpod` | pair | #5D29F0 | #FFFFFF | 6.68 | 12.47 | runpod.io/brandkit | +| `RustIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | rust-lang/rust-artwork | +| `S3Icon` | `s3` | fixed | #7AA116 | #7AA116 | 2.93 | 12.47 | AWS Architecture Icons (Icon-package_07312026, Arch_Storage/64/Arch_Amazon-Simple-Storage-Service_64.svg) | +| `SageIcon` | `sage_intacct` | pair | #000000 | #00D639 | 20.32 | 6.34 | @sage/design-tokens --logo-sage-bg-default | +| `SalesflareIcon` | `salesflare` | fixed | #0053FF | #0053FF | 5.52 | 2.19 | salesflare.com's own `--color--major-blue` design token | +| `SalesforceIcon` | `salesforce` | fixed | #00B3FF | #00B3FF | 2.29 | 5.28 | brand.salesforce.com/brand/color | +| `SassIcon` | — | fixed | #CC6699 | #CC6699 | 3.43 | 12.47 | sass-lang.com's own style guide token --sl-color--hopbush (assets/dist/css/sass.css) | +| `SegmentIcon` | `segment` | fixed | #52BD94 | #52BD94 | 2.24 | 5.38 | Segment's own app favicon (app.segment.com) and Evergreen green500 #52BD95 | +| `SendflakeIcon` | `snowflake` | pair | #29B5E8 | — | 2.29 | — | snowflake.com/brand-guidelines | +| `SendgridIcon` | `sendgrid` | fixed | #00B3E3 | #00B3E3 | 3.81 | 8.57 | styleguide.sendgrid.com/colors.html | +| `SensorTowerIcon` | `sensortower` | fixed | #00CFB8 | #00CFB8 | 1.91 | 12.47 | sensortower.com/favicon.svg, copied verbatim | +| `SentryIcon` | `sentry` | pair | #181225 | #FFFFFF | 17.64 | 12.47 | sentry.io/branding logo generator (Dark/Light themes, "Invert in dark mode") | +| `ServiceNowIcon` | `servicenow` | fixed | #62D84E | #62D84E | 1.77 | 6.80 | servicenow.com/company/servicenow-logo.html (servicenow-logo-icon.svg) | +| `ShopifyIcon` | `shopify` | fixed | #95BF47 | #95BF47 | 3.75 | 12.47 | shopify.com/brand-assets (shopify-logo-shopping-bag-full-color.svg: #95BF47/#5E8E3E/#fff) | +| `ShortcutIcon` | `shortcut` | pair | #494BCB | #797ADE | 6.45 | 3.36 | shortcut.com/branding (mark-default.svg; the reversed lockup uses #797ADE on dark) | +| `ShutterstockIcon` | `shutterstock` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.shutterstock.com | +| `SigNozIcon` | `signoz` | fixed | #FF5E19 | #FF5E19 | 3.63 | 12.47 | signoz.io/img/SigNozLogo-orange.svg | +| `Slack` | `slack` | mixed | #E01E5A | #E01E5A | 4.51 | 6.52 | slack.com's own nav logo (a.slack-edge.com/38f0e7c/marketing/img/nav/logo.svg, linked from slack.com/media-kit) | +| `SmartsheetIcon` | `smartsheet` | pair | #031C59 | #FFFFFF | 15.46 | 12.47 | brandguides.brandfolder.com/smartsheet-visual-guide/basics | +| `SnowflakeIcon` | — | pair | #29B5E8 | — | 2.29 | — | snowflake.com/brand-guidelines | +| `SpeechifyIcon` | `speechify` | pair | #2F43FA | #FFFFFF | 6.15 | 12.47 | the Speechify brand kit (speechify.com/brand-kit, Logomark_blue.svg and Logomark_white.svg) | +| `SplitwiseIcon` | `splitwise` | pair | #1CC29F | — | 2.19 | — | splitwise.com/press (sw.svg / sw-wide.svg / bg-primary.svg) | +| `SpotifyIcon` | `spotify` | pair | #1ED760 | #FFFFFF | 1.86 | 12.47 | developer.spotify.com/documentation/design (2024 Primary Logo icon pack) | +| `SquareIcon` | `square` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Square_Logo_2025 in squareup.com/us/en/press/logo | +| `StraleIcon` | `strale` | pair | #0D0D0E | #F2F2F3 | 18.80 | 11.15 | strale.dev favicon.svg and the site's own --foreground token | +| `StravaIcon` | `strava` | fixed | #FC5200 | #FC5200 | 3.20 | 3.77 | developers.strava.com/guidelines (Strava API logo pack, orange SVGs) | +| `StripeIcon` | `stripe` | fixed | #533AFD | #533AFD | 5.99 | 12.47 | Stripe's own favicon.svg and Stripe_logo_kit.zip (stripe.com/newsroom/brand-assets) | +| `SupabaseIcon` | `supabase` | fixed | #3ECF8E | #3ECF8E | 3.75 | 6.25 | supabase.com/brand-assets | +| `SurrealdbIcon` | `surrealdb` | mixed | #D255FE | #D255FE | 7.33 | 5.71 | surrealdb.com/brand | +| `SvelteIcon` | — | fixed | #FF3E00 | #FF3E00 | 3.42 | 12.47 | sveltejs/branding (svelte-logo.svg, white cutout #fff) | +| `TallyIcon` | `tally` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | the "Tally Icon - Black" / "Tally Icon - White" files in the icon pack on tally.so/help/press-kit, matching the live tally.so/favicon.svg | +| `TaskadeIcon` | `taskade` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | taskade.com/press (Mascot Mark light = agent_taskade.svg, Genesis Icon dark = taskade-icon-dark.svg) | +| `TelegramIcon` | `telegram` | fixed | #2AABEE | #2AABEE | 2.92 | 12.47 | Telegram's press-kit Logo.svg (telegram.org/press) | +| `TelnyxIcon` | `telnyx` | pair | #000000 | #00E3AA | 20.32 | 12.47 | telnyx.com | +| `TerraIcon` | `terra` | fixed | #008AFF | #008AFF | 20.32 | 10.43 | tryterra.co/providers/terra_icon.svg, the only vector square mark Terra ships (the site logo is a "TERRA API" wordmark, the favicon a raster .ico) | +| `TheirStackIcon` | `their_stack` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | theirstack.com/en/docs/brand, which lists both as core brand colours | +| `ThreadsIcon` | `threads` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Meta's Threads Brand Resource Center logo pack (meta.com/brand/resources/threads) | +| `TodoistIcon` | `todoist` | fixed | #E44232 | #E44232 | 3.97 | 3.04 | Todoist Brand Guidelines (doist.com/brand-assets/todoist-logo.zip), "Red — the primary brand color for Todoist" | +| `TogetherAiIcon` | `togetherai` | fixed | #EF2CC1 | #EF2CC1 | 3.50 | 6.46 | together.ai's brand page (https://www.together.ai/brand) | +| `TogglIcon` | `toggl` | pair | #2C1138 | #E57CD8 | 16.33 | 4.87 | Toggl Track media toolkit (toggl.com/track/media-toolkit, icon-dark-purple.svg / icon-pink.svg) | +| `TomorrowIoIcon` | `tomorrow` | fixed | #004CF8 | #004CF8 | 6.00 | 12.47 | tomorrow.io's own design tokens (--color-logo-blue in site-frame.min.css, matching the header lockup SVG and logo-490.png) | +| `TrelloIcon` | `trello` | fixed | #1558BC | #1558BC | 6.44 | 12.47 | Atlassian Design logo library (atlassian.design/foundations/logos → trello_app.zip, Trello_icon.svg) | +| `TripadvisorIcon` | `tripadvisor` | fixed | #002B11 | #002B11 | 15.01 | **1.24** | 2025 Tripadvisor Brand Guidelines for Partners, tripadvisor.mediaroom.com | +| `TursoIcon` | `turso` | pair | #183134 | #FFFFFF | 13.30 | 12.47 | turso.tech/brand (Dark Teal and white logomark variants) | +| `TwilioIcon` | `twilio` | fixed | #F22F46 | #F22F46 | 3.86 | 3.13 | twilio.com (mask-icon color, favicon and apple-touch-icon artwork) | +| `TwitchIcon` | `twitch` | fixed | #9146FF | #9146FF | 4.49 | 2.69 | brand.twitch.com | +| `TwitterIcon` | `twitter` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | about.x.com | +| `TypeformIcon` | `typeform` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | typeform.com/brand | +| `UltravoxIcon` | `ultravox` | fixed | #BB3B57 | #BB3B57 | 6.67 | 7.65 | the ultravox.ai favicon (framerusercontent.com/images/hzAEdihxJ11mv3l4trNh2WprE.svg) | +| `VectaraIcon` | `vectara` | fixed | #7E00FF | #7E00FF | 7.00 | 9.80 | — | +| `VercelIcon` | `vercel` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | vercel.com | +| `VismaIcon` | `visma` | pair | #131313 | #FFFFFF | 17.98 | 12.47 | design.visma.com/logo (VA symbol from the official Visma Logopack) | +| `VueIcon` | — | fixed | #42B883 | #42B883 | 8.97 | 5.00 | vuejs/art logo.svg | +| `WebflowIcon` | `webflow` | fixed | #146EF5 | #146EF5 | 4.44 | 2.72 | brand.webflow.com/brand-assets | +| `WhatsappBusinessIcon` | `whatsapp_business` | fixed | #25D366 | #25D366 | 1.92 | 6.29 | WhatsApp's Digital_Glyph_Green_RGB_2026.svg, shipped by whatsapp.com/business (→ whatsappbusiness.com) | +| `WizIcon` | `wiz` | pair | #0254EC | #FFFFFF | 5.84 | 12.47 | wiz.io/press media kit logo pack (WizLogo_Blue_Vector.svg / WizLogo_White_Vector.svg) | +| `WooCommerceIcon` | `woocommerce` | pair | #873EFF | #FFFFFF | 4.88 | 12.47 | the Woo logo pack at woocommerce.com/brand-and-logo-guidelines (Woo_logo_color.svg and Woo_logo_white.svg) | +| `WordpressIcon` | `wordpress` | pair | #32373C | #FFFFFF | 11.63 | 12.47 | wordpress.org/about/logos/ | +| `XataIcon` | `xata` | fixed | #8468F6 | #8468F6 | 3.84 | 3.14 | xata.io/brand (logo-symbol.svg) | +| `XeroIcon` | `xero` | fixed | #13B5EA | #13B5EA | 2.30 | 12.47 | xero.com favicon.svg and the site header logo (Xero__LogoPath fill) | +| `YamlIcon` | — | mixed | #CB171E | #CB171E | 5.52 | 5.71 | yaml.org's own assets/favicon.svg and assets/logo.png; the Y, M and L carry no fill in YAML's SVG, so they take the surrounding text colour | +| `YelpIcon` | `yelp` | fixed | #FF1A1A | #FF1A1A | 3.75 | 3.22 | yelp.com/brand (burst_red.svg and the official logo kit) | +| `YnabIcon` | `ynab` | pair | #3B5EDA | #FEF9E6 | 5.35 | 11.82 | ynab.com press kit tree logo (Tree Logo Blurple.svg / Tree Logo Buttermilk.svg — the buttermilk reverse is what ynab.com itself uses on its dark footer) | +| `YoutubeIcon` | `youtube` | fixed | #FF0033 | #FF0033 | 3.83 | 12.47 | brand.youtube/color (YouTube Red, updated from #FF0000) | +| `ZammadIcon` | `zammad` | fixed | #CD2015 | #CD2015 | 7.62 | 9.73 | zammad.com favicon-32x32.svg | +| `ZendeskIcon` | `zendesk` | pair | #11110D | #FFFFFF | 18.31 | 12.47 | zendesk.com | +| `ZeroTierIcon` | `zerotier` | fixed | #FFB25B | #FFB25B | 16.58 | 6.99 | zerotier.com's own icon.svg and logo lockups | +| `ZitadelIcon` | `zitadel` | pair | #232323 | #FFFFFF | 15.21 | 12.47 | zitadel/zitadel console assets zitadel-logo-solo-dark.svg / zitadel-logo-solo-light.svg | +| `ZixflowIcon` | `zixflow` | pair | #141414 | #FFFFFF | 17.83 | 12.47 | docs.zixflow.com logo pack (logo/light.svg / logo/dark.svg) | +| `ZohoIcon` | `zoho` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | zoho.com/branding (zoho-logo-web.svg / zoho-logo-white.svg) | +| `ZoomIcon` | `zoom` | pair | #0B5CFF | #FFFFFF | 5.09 | 12.47 | brand.zoom.com | +| `ZuploIcon` | `zuplo` | fixed | #FF00BD | #FF00BD | 3.39 | 3.56 | https://zuplo.com/brand | + +## Rules the brand imposes + +Constraints that would otherwise be broken by a well-meaning change. + +- **AblyIcon** — "Don't use other colours or gradients for the symbol." +- **AcceloIcon** — The mark keeps these three fills in both themes; only the wordmark (not drawn here) swaps #10202D for white. +- **AmqpIcon** — No brand colour: AMQP is an OASIS protocol, not a vendor, and amqp.org publishes no palette (https://www.amqp.org/legal.html). Generic glyph, deliberately monochrome — keep it on currentColor. +- **AnsibleIcon** — The mark is a solid disc knocked out with a white "A", so the pair is applied by inverting rather than currentColor: recolouring the disc alone would leave white on light grey. +- **ApifyIcon** — White/black variants are reserved for monochromatic contexts, so the tricolour mark stays in both themes. +- **AppwriteIcon** — Brand asks that the logo not be altered, so no per-theme variant. +- **ArcGisIcon** — Esri publishes no reversed variant for this badge and forbids altering its logos. +- **AsanaIcon** — Asana's guidelines forbid recolouring: the symbol always appears in coral, on light and dark backgrounds alike. +- **AssemblyAiIcon** — Two colours per theme, so the second stroke carries its own fill- utilities: #777673 on light, #FFFFFF on dark. +- **AttioIcon** — The mark is a filled compound path; stroking it instead thickens it and leaks the default black fill. +- **Auth0Icon** — Okta's content terms forbid altering the mark, so ship only these published variants. +- **AutheliaIcon** — authelia.com/reference/guides/branding permits format/layout changes only — do not alter the design. +- **AuthentikIcon** — The white variant is the brand's own asset for dark backgrounds; proportions and colour must not be altered otherwise. +- **AwsEcrIcon** — AWS ships one flat fill for both themes; the gradient tile was retired in the 2023 accessibility refresh. +- **AwsIcon** — aws.amazon.com/trademark-guidelines forbids altering the logo's colour, so only these two published variants may be used. +- **BaserowIcon** — The mark keeps these three colours on light and dark; only the wordmark reverses to white. +- **BeamerIcon** — The isotype is monochrome in all first-party artwork. +- **BigQueryIcon** — Google publishes no reversed variant, so the same mark is used on both themes. +- **BitbucketIcon** — Atlassian ships brand/neutral/inverse only: "don't use unapproved color combinations". +- **BitlyIcon** — Bitly's reversed logomark is white over orange, not over neutral dark, so the orange mark is used on both. +- **BloggerIcon** — Google publishes no reversed variant. +- **BlueskyIcon** — The downloadable media-kit butterfly still ships the older #006AFF, but the palette is the normative source: "use only the official color values above. Do not substitute, tint, or approximate." White is the approved monochrome variant for dark backgrounds. +- **BoxIcon** — box.com/legal/trademark forbids any other recolouring of the mark. +- **BrevoIcon** — Brevo publishes no per-theme variant of the app mark; the reversed "Mint" #F9FFF6 asset is the wordmark only. +- **BrowserlessIcon** — Its own prefers-color-scheme block sets black on light, white on dark. +- **BubbleIcon** — Bubble's brand terms forbid re-colouring the mark beyond its published dark/light pair. +- **BuildkiteIcon** — Buildkite ships a single mark "for any context", so there is no per-theme variant, and asks that it not be altered. +- **ButtondownIcon** — Brand forbids recolouring the logo, so no per-theme variant. +- **CSharpIcon** — Its README forbids altering the mark, so the same full-colour icon is used on light and dark. +- **CalcomIcon** — Cal.com's design system states it is deliberately a grayscale brand and publishes exactly two logo variants. +- **CalendlyIcon** — Guidelines: "Only show our logo and lockups in blue or white." +- **CertopusIcon** — Verbatim copy of the brand's own circle mark: #2C353D and the white disc are its other fixed tones, not a dark-theme variant. +- **CircleCiIcon** — Guidelines require Terminal (#161616) on light backgrounds and White on dark, and forbid any color not named in them. +- **CiscoIcon** — Cisco requires all parts of the mark be knocked out to white on dark backgrounds. +- **ClerkIcon** — Same two-tone symbol on light and dark; the mono symbol-dark/symbol-light pair is Clerk's alternate for single-colour contexts. +- **ClickhouseIcon** — Brand forbids recolouring the mark, so only its own published pair is used. +- **CloseIcon** — Close forbids modifying the logo, so the same colours are kept on light and dark. +- **CloudflareIcon** — Cloudflare's logo guidelines forbid altering the colours or filling the flare, so the flare stays knocked out on both themes. +- **CloudinaryIcon** — Cloudinary Blue is reserved for the logo; no other recolouring is permitted. +- **CockroachDbIcon** — The full-colour mark is a cyan-to-purple gradient; Cockroach Labs reduces it to solid white on dark backgrounds. +- **CoinbaseIcon** — Coinbase asks that the mark not be altered or recoloured, so only these two published variants are used. +- **ComapeoIcon** — Awana Digital publishes no reversed variant, so dark mode uses the brand's own accent blue #0066FF from CoMapeoLogo.svg (digidem/comapeo-mobile); the navy is 1.3:1 on dark surfaces. +- **ConfluenceIcon** — Atlassian requires the logo be used without modification, and its brand appearance is identical in light and dark. +- **ContentfulIcon** — Same full-colour mark on light and dark. +- **ContiguityIcon** — The `>_` glyph is knocked out to the opposite colour, so it carries its own fill- utilities. +- **ConvertKitIcon** — ConvertKit rebranded to Kit in 2024. +- **CssIcon** — Small-size variant; the only per-theme variants published are mono black/white fallbacks, so the rebeccapurple tile is kept in both themes. +- **DatadogIcon** — Datadog publishes one mark per background, the purple tile with Bits knocked out on light and the white Bits silhouette on dark, and forbids recolouring or inverting either. +- **DatoCmsIcon** — Brand kit forbids altering the logo's shape or colour. +- **DbtIcon** — Their Trademark Policy states "The dbt logo mark color cannot be altered", so this stays orange on both themes. +- **DeelIcon** — Post-rebrand the period is a square in the wordmark colour, not a blue circle. +- **DeepInfraIcon** — Their brand guidelines say "use the primary white logo on dark backgrounds", where the connector bars invert to #FFFFFF. +- **DenoIcon** — Deno publishes no hex and forbids colorizing; the black "Light (no outline)" and white "Dark (outlined)" marks are separate artworks to be swapped per background, never inverted. +- **DiscordIcon** — Discord forbids recolouring the logo, so no per-theme variant. +- **DiscourseIcon** — Only the outer bubble reverses; the five inner colours are the same in both variants. +- **DockerIcon** — Docker requires its logos appear only in its primary brand colours. +- **DropboxIcon** — Dropbox's branding terms forbid recolouring the logo, and their inverse-theme token keeps the same blue. +- **DuckDbIcon** — The pair is a full inversion, so the duck carries its own fill- utilities; the manual forbids recolouring outside these two brand hexes. +- **DustIcon** — Dust's guidelines forbid recolouring the logo. +- **DynatraceIcon** — Guidelines forbid colorizing the logo, so all six fills stay fixed in both themes. +- **EdgeDbIcon** — EdgeDB is now Gel — edgedb.com redirects to geldata.com — so this is Gel's "g" symbol, not the retired EDGE|DB wordmark. +- **EnodeIcon** — Their own favicon carries the pair in a prefers-color-scheme block. +- **ExaIcon** — Blue for standard applications, white on dark backgrounds. +- **FigmaIcon** — Figma's guidelines forbid modifying the marks, so the five-colour original is used on both themes. +- **FirebaseIcon** — Same full-colour artwork on light and dark; the guidelines forbid recolouring or redrawing the mark. +- **FoxentryIcon** — Fixed tri-tone mark, no per-theme variant: the brand ships a separate greyscale logo rather than a recoloured one. +- **FreshdeskIcon** — Freshworks publishes no reversed variant: the white glyph always sits on the green leaf. +- **FunkwhaleIcon** — Identity guidelines forbid recolouring. +- **GSheetsIcon** — Google forbids recolouring its marks, so the same full-colour artwork is used on both themes. +- **GcalIcon** — Google forbids modifying its logos, colour included, so this stays fixed with no per-theme pair. +- **GdriveIcon** — Google forbids modifying its logos "in any way, including changing the color", so this stays full-colour with no per-theme pair. +- **GiphyIcon** — Same full-colour mark on light and dark. +- **GitBookIcon** — #1C1917 is the marketing palette's dark base, not the logomark. +- **GitIcon** — A white reversed logomark exists, but git-scm.com's own dark theme exempts the mark from inversion and keeps it orange. +- **GithubIcon** — GitHub allows the Invertocat in white or black only and forbids recolouring it, so the pair is fixed here rather than inherited from the caller. +- **GmailIcon** — Google's brand guidelines forbid recolouring the mark. +- **GoogleAiIcon** — Google ships no reversed variant; the same gradient is used on light and dark. +- **GoogleCalendarIcon** — Google's trademark guidelines forbid distorting or altering a brand feature, so no per-theme recolour. +- **GoogleCloudIcon** — Google forbids recolouring its logos. +- **GoogleDriveIcon** — Google's Drive branding guide permits resizing only — no other change to the logo — so no per-theme recolour. +- **GoogleFormsIcon** — Google's brand guidelines forbid modifying or recolouring its product icons. +- **GoogleIcon** — developers.google.com/identity/branding-guidelines forbids changing the colour of the G. +- **GorgiasIcon** — The guide allows only black or white for the symbol: "Do not use gray!". +- **GristIcon** — "Keep it exactly as depicted — no recoloring, no cropping." +- **GroqIcon** — Logo use in a UI requires a license from Groq. +- **HoldedIcon** — Holded ships one flat red mark for both themes; the red-orange gradient is retired. +- **HubspotIcon** — The legacy Coral #FF7A59 is not the current logo color. +- **IfsIcon** — IFS's negative lockup reverses only the wordmark, so the symbol keeps its #8427E2-to-#72C9F8 gradient on dark. +- **IftttIcon** — IFTTT's brand guidelines state "Our wordmark may be used in solid white or black" and publish no other hex for the mark. +- **IntercomIcon** — Intercom ships the mark as fill="currentColor" bound to its nav foreground token, so it takes the colour of the surface it sits on. +- **JavaIcon** — The Coffee Cup mark is licensee-only and "you may not use a modified version of the Coffee Cup logo" — do not recolour it or flatten it to currentColor. +- **JavaScriptIcon** — Fixed mark: yellow field, black lettering, no per-theme variant. +- **JiraIcon** — The logomark is identical on light and dark; only the wordmark changes colour. +- **JoomlaIcon** — Joomla's trademark policy forbids recolouring the mark, so there is no per-theme variant. +- **JotformIcon** — The other three bars keep their fixed brand colours in both themes. +- **JsonIcon** — JSON itself has no brand owner or published colours — json.org states none — so this is a Material palette pick, not a brand colour. +- **KanidmIcon** — Kanidm's artwork is CC-BY-NC-ND — no recolouring or other derivatives. +- **KlaviyoIcon** — Klaviyo draws it in currentColor, hence the white swap on dark. +- **LangfuseIcon** — Langfuse's trademark terms forbid modifying the assets. +- **LineIcon** — LINE forbids any change to the logo's colour, so there is no reversed variant. +- **LinearIcon** — Guidelines ship a light/dark logomark pair and forbid altering the assets in any other way. +- **LinkedinIcon** — That page forbids recolouring: only the approved blue, black and white variants. +- **LinodeIcon** — The keyline path stays unfilled so it follows currentColor instead of the source's near-black #231f20. +- **LumaAiIcon** — The two faces ship at 65% opacity, which is what makes their overlap read as a cube. +- **MSSqlServerIcon** — Microsoft licenses its product icons for diagrams, docs, and training only, and forbids cropping, rotating, or reshaping them. +- **MSTeamsIcon** — Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. +- **MailchimpIcon** — Mailchimp forbids altering the files, so both official tones are painted and neither is recoloured per theme. +- **MailerLiteIcon** — Their IP guidelines forbid altering or recolouring the mark. +- **MailgunIcon** — Mailgun ships no reversed variant; the tile is identical on light and dark. +- **MandrillIcon** — On dark their rule is the reversed (white) Freddie; Cavendish Yellow #FFE01B is a background colour, never the mark. +- **MarkdownIcon** — Spec: keep the enclosure's aspect ratio and radius, keep the M/arrow/box relative sizes, and draw all three in one colour. +- **MastodonIcon** — Swap to the black or white logo rather than recolouring when contrast fails. +- **MatrixIcon** — Artwork is the Foundation's matrix-icon.svg verbatim; the trademark policy forbids altering it. +- **MediumIcon** — Guidelines mandate black or white only for both the wordmark and the icon and forbid "any other colors, gradients, or filled with images". +- **MezmoIcon** — The star stays #F4B811 in both themes. +- **MicrosoftIcon** — Microsoft forbids recolouring the symbol, so it stays full-colour on both themes. +- **MistralIcon** — Brand forbids any other recolouring. +- **MixpanelIcon** — "The Mixpanel logo is only ever used in three colors: black, white and the primary brand purple." +- **MollieIcon** — The m is the glyph from Mollie-Logo-Black-2023.svg (Mollie logo pack, mollie.com/resources), scaled and placed to match that icon pixel for pixel; the logo pack itself ships only the 320x94 wordmark. Mollie publishes black and white variants, so the pair flips for dark mode. +- **MondayIcon** — All three colours are required; the brand forbids monochrome or recoloured versions, so no dark-theme variant. +- **MongodbIcon** — MongoDB permits only four logo colours, chosen for contrast with the background, and forbids any other recolour. +- **MotimateIcon** — Motimate is a registered trademark of Motimate AS (Kahoot!). +- **Mysql** — Used under Fair Use: https://fr.wikipedia.org/wiki/Fichier:MySQL.svg +- **NeonDbIcon** — Neon forbids recolouring, so only these published variants may be used. +- **NetlifyIcon** — Two colours per theme, so the "n" carries its own fill- utilities: #014847 on light, #FFFFFF on dark. +- **NetsuiteIcon** — Pre-Oracle NetSuite "N" mark. #125580/#baccdb approximate netsuite.com's own 2014 logo art, which is itself inconsistent: /portal/common/img/ns-logo.png is #14487e/#b9c9d5 and /portal/common/img/logo-ns-mobile.png is #13527d/#b6c7d5 (both via web.archive.org/web/2014/). Not Oracle's current NetSuite mark, which is a different logo in a different palette (#264759/#36677D/#94BFCE/#E2C06B). +- **NocoDbIcon** — NocoDB publishes no reversed variant; the full-colour mark is used on light and dark alike. +- **NotionIcon** — The plate is fixed, not theme-swapped: the mark is pure black and disappears on dark backgrounds without it. +- **NuIcon** — Nushell registers that one file as both the `light` and `dark` icon, so the green is not theme-swapped. +- **OdkIcon** — ODK publishes no reversed or monochrome variant. +- **OktaIcon** — Okta's official April-2025 logo package (logos-04-2025.zip) ships the mark in Black and White only. +- **OpenWeatherIcon** — Their negative (dark-background) logo reverses only the wordmark; the symbol stays brand orange. +- **OpenaiIcon** — The guidelines state "DON'T add any colors to the Blossom" — black or white only. +- **OracleDBIcon** — Oracle reserves its logo for licensees. +- **PHPIcon** — Official logo, CC BY-SA 4.0: keep it verbatim and credit Colin Viebrock rather than recolouring. +- **PaychexIcon** — The isolated P is the square mark Paychex ships as its own 192x192 app icon. Paychex requires prior approval for any use of its marks. +- **PaypalIcon** — The third fill is the deep/bright blue overlap: a two-colour or flat fill loses it. +- **PersonioIcon** — Black on light, white on dark or coloured backgrounds. +- **PhraseIcon** — The green wedge stays #03EAB3 in both — Phrase forbids altering the logo mark colour. +- **PineconeIcon** — The mark is stroke-only, so fill must stay none. +- **PinterestIcon** — Brand guidelines: "Do not alter the logo colour." +- **PipedriveIcon** — Pipedrive's partner media kit says "do not alter, rotate, modify or animate the logo", so the mark keeps its published colours on both themes. +- **PostgresIcon** — The PostgreSQL trademark policy forbids recolouring the mark without prior approval. +- **PostmarkIcon** — Postmark publishes no reversed variant. +- **PowershellIcon** — Trademarked Microsoft logo, exempt from that repo's MIT license. The #00FF18 line below is opacity-0 in the upstream asset and paints nothing. +- **PusherIcon** — Only the colourways shown in their brand guidelines are permitted. +- **PushoverIcon** — Forbids recolouring. +- **QoveryIcon** — The mark keeps the same purple in Qovery's white lockup for dark backgrounds, so there is no reversed variant. +- **QuickbooksIcon** — Intuit forbids altering the mark. +- **RIcon** — R Foundation licenses the mark CC-BY-SA 4.0 / GPL-2 — attribution required, changes must be indicated. +- **RaindropIcon** — Full-colour mark, no reversed variant published. +- **ReadwiseIcon** — The serif R and its highlight block are knocked out to the opposite colour, so they carry their own fill- utilities. Do not restore the mix-blend-mode: multiply wrapper Readwise's dark file carries: it turns the knocked-out white to the backdrop colour on anything but a white page. +- **RecraftIcon** — The plated mark carries its own background: recraft.ai serves it to prefers-color-scheme light and dark alike — not a theme pair. +- **RedditIcon** — Reddit publishes no reversed variant; the icon must always appear in Orangered when in colour. +- **RenderIcon** — Official Render Brand Kit contains only Black and White logomark folders and the SVGs use pure black / pure white. +- **ResendIcon** — Brand guidelines forbid multi-color use or altering the mark, so these are the only two published fills. +- **RssIcon** — Never rotate or flip the mark. +- **RubyIcon** — CC BY-SA 2.5; the kit's LICENSE asks that the mark not represent anything other than the Ruby language. +- **RunPodIcon** — Brand forbids recolouring, so both values are its own published cube-icon variants. +- **RustIcon** — rust-lang.org ships only rust-logo-blk.svg (pure black). +- **S3Icon** — AWS ships no dark variant for service icons. +- **SageIcon** — Sage sets its logo black on light surfaces and Sage green only on dark ones. +- **SalesforceIcon** — Salesforce reserves the white/reversed cloud for its own blue backgrounds, so the blue mark stands in both themes. +- **SegmentIcon** — Segment ships the mark in one flat green and publishes no reversed variant. +- **SendflakeIcon** — Snowflake Blue is the only approved logo color; the sole alternate is a white reverse reserved for full-bleed Snowflake Blue. +- **SendgridIcon** — Twilio's trademark guidelines forbid recolouring the mark, so it stays multicolour in both themes. +- **ServiceNowIcon** — Trademark guidelines require the mark in the graphic form provided. +- **ShopifyIcon** — The "S" stays white regardless of background; no gradients, shadows or recolouring. +- **ShortcutIcon** — The mark "is used across various colors but never changes its visual structure." +- **ShutterstockIcon** — Brand rule: the logo is only ever black or white. +- **SigNozIcon** — SigNoz ships no reversed variant; the tile mark is used unchanged on light and dark. +- **Slack** — Fixed full-colour mark, no per-theme variant. +- **SmartsheetIcon** — Those are two of the approved logo colorways; the guide forbids any other recolouring. +- **SnowflakeIcon** — Snowflake Blue is the only approved logo color; the sole alternate is a white reverse reserved for full-bleed Snowflake Blue. +- **SpeechifyIcon** — The blue logomark is reserved for white backgrounds; every other background takes the black or white monochrome version. +- **SplitwiseIcon** — Splitwise's logos carry a single green and no reversed variant, so the same colour is used on light and dark. +- **SpotifyIcon** — Spotify permits the green icon only on black or white backgrounds and requires the white monochrome colourway on any other dark background, so the pair is fixed rather than caller-set. +- **SquareIcon** — Square ships only black and white logo files and states "Do not change the color", so no tinted variant is allowed. +- **StraleIcon** — Strale's own logo component fills with currentColor, so the mark is meant to take the theme's foreground. +- **StravaIcon** — Strava's guidelines forbid modifying or altering its logos, and the orange Echelon is the mark Strava itself uses on light and dark alike. +- **StripeIcon** — Stripe's Marks Usage Terms forbid altering the marks, so this ships verbatim in both themes rather than as a recoloured pair. +- **SupabaseIcon** — Forbids modifying or recolouring the mark. +- **SurrealdbIcon** — Same gradient mark on light and dark; monochrome variants are for subtle placements only. +- **SvelteIcon** — Its guidelines count the official colour scheme as part of the mark — do not recolour. +- **TaskadeIcon** — Brand forbids recolouring, so only those two published variants are used. +- **TelegramIcon** — The shaded-plane drawing is Telegram's retired Logo_old. +- **TerraIcon** — The outlines are part of the artwork and stay black in both themes; the blue T carries the mark on dark backgrounds. +- **TheirStackIcon** — The mark ships black-only, but the same brand rules forbid placing it on low-contrast backgrounds; on a dark surface black is 1.68:1, so it is tinted to the brand's own white. +- **ThreadsIcon** — The pack ships the mark in black and white only, so it must never be tinted. +- **TogglIcon** — Toggl requires the mark be used as is, unmodified. +- **TomorrowIoIcon** — Their stylesheet reverses only the wordmark on dark headers (path.logo-letter{fill:#fff}); the mark itself stays logo blue in both themes. +- **TrelloIcon** — Atlassian: never compose your own versions or deconstruct official assets. +- **TripadvisorIcon** — Dark backgrounds take Tripadvisor's separate outlined Ollie, never an inverted or recoloured one. +- **TwilioIcon** — Twilio reserves its corporate logo for permitted use and forbids recreating or modifying it. +- **TwitchIcon** — Trademark guidelines forbid recolouring. +- **TwitterIcon** — The component draws the X glyph, not the legacy bird. +- **TypeformIcon** — Default brand colours are "Paper (white) and Ink (black)". +- **UltravoxIcon** — Gradient mark; ultravox.ai links that same asset for both prefers-color-scheme light and dark, so there is no per-theme variant. +- **VectaraIcon** — #7E00FF → #07FEEE iridescent sweep, sampled from the logo mark on vectara.com (no brand kit is published). Vectara reserves its trademarks: do not recolour. +- **VercelIcon** — Vercel ships only light-theme (black) and dark-theme (white) triangle marks and explicitly forbids modifying or recoloring the trademarks. +- **VismaIcon** — Positive black on light, negative white on dark; the logo may not be given any other colour. +- **VueIcon** — Vue's dark-background variant is separate outlined artwork rather than a recolour, so the two-tone mark is used in both themes. +- **WebflowIcon** — Mark ships in blue, black or white only. +- **WhatsappBusinessIcon** — "You shouldn't modify any colors in our logos." +- **WooCommerceIcon** — Automattic requires the mark in its exact, most up-to-date form, so only their two published colorways are used, never a recolour. +- **WordpressIcon** — Every official logotype vector is BaseGray #32373C, shipped alongside a White/transparent version for dark backgrounds. +- **XataIcon** — Brand forbids recolouring, and the full-colour symbol is the same purple in light and dark modes. +- **XeroIcon** — Single-colour mark: white wordmark on the blue badge in both themes. +- **YamlIcon** — YAML publishes no reversed variant, and its black letters would be invisible on the dark surface. +- **YelpIcon** — Yelp forbids altering the logos and requires the ® to accompany the mark at all times. +- **YoutubeIcon** — "The triangle in the full-color red icon must always be white." +- **ZendeskIcon** — Zendesk's brand guidelines specify the logo in Licorice #11110D and Coconut #FFFFFF only and forbid unapproved color variations. +- **ZeroTierIcon** — The tile is identical on light and dark; only the wordmark inverts. +- **ZitadelIcon** — The gradient chevrons stay #FF8F00→#FE00FF in both variants. +- **ZohoIcon** — The four squares carry their own brand hexes (#E42527/#089949/#226DB4/#F9B21D) on light; Zoho's reversed lock-up is entirely white, so they invert with the wordmark on dark. +- **ZoomIcon** — The logo "may only be used in Bloom (#0B5CFF), White, or Black", with White reserved for dark backgrounds and Black requiring prior brand approval. +- **ZuploIcon** — Brand guidelines forbid recolouring the mark; pink is the official variant on both light and dark surfaces. + +## Concept icons + +Not brands. These inherit `currentColor` on purpose and must not be given a pair. + +`AgentInstructionsIcon`, `AiAgentIcon`, `ApiKeyAuthIcon`, `AssetDatabaseIcon`, `AssetDucklakeIcon`, `AssetGenericIcon`, `AssetResIcon`, `AssetS3Icon`, `BarsStaggered`, `BasicHttpAuthIcon`, `BcryptIcon`, `CACertificate`, `CustomAiIcon`, `DbIcon`, `FormInputIcon`, `FunnelCog`, `GpgKeyIcon`, `HttpIcon`, `JsonSchemaIcon`, `LdapIcon`, `Mail`, `OauthIcon`, `PaintbrushOff`, `QRCodeIcon`, `QuestionInputIcon`, `RecordIcon`, `RestIcon`, `SchedulePollIcon`, `SignatureAuthIcon`, `SparklesOffIcon`, `WebdavIcon`, `WindmillAiIcon`, `WindmillIcon`, `WindmillIcon2` + +## Coverage + +- brand icons: **314**, of which **310** carry a recorded source +- per-theme pairs applied: **136** (5 of them by inversion or a two-SVG swap, see above) +- concept icons: **34** +- effectively invisible on light: **1** (AbstractApiIcon) +- effectively invisible on dark: **2** (PaychexIcon, TripadvisorIcon) diff --git a/frontend/src/lib/components/icons/BambooHrIcon.svelte b/frontend/src/lib/components/icons/BambooHrIcon.svelte index efbeec2ece..534b48cbe6 100644 --- a/frontend/src/lib/components/icons/BambooHrIcon.svelte +++ b/frontend/src/lib/components/icons/BambooHrIcon.svelte @@ -1,12 +1,27 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/BaremetricsIcon.svelte b/frontend/src/lib/components/icons/BaremetricsIcon.svelte index be550a88fc..a05a84c7c9 100644 --- a/frontend/src/lib/components/icons/BaremetricsIcon.svelte +++ b/frontend/src/lib/components/icons/BaremetricsIcon.svelte @@ -1,12 +1,15 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BaserowIcon.svelte b/frontend/src/lib/components/icons/BaserowIcon.svelte new file mode 100644 index 0000000000..5d51c8cfa5 --- /dev/null +++ b/frontend/src/lib/components/icons/BaserowIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte b/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte new file mode 100644 index 0000000000..2a570cfb3e --- /dev/null +++ b/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/BasisTheoryIcon.svelte b/frontend/src/lib/components/icons/BasisTheoryIcon.svelte new file mode 100644 index 0000000000..6f2dfc2d4e --- /dev/null +++ b/frontend/src/lib/components/icons/BasisTheoryIcon.svelte @@ -0,0 +1,26 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/BcryptIcon.svelte b/frontend/src/lib/components/icons/BcryptIcon.svelte index bf9bf9b6a2..be79ba1418 100644 --- a/frontend/src/lib/components/icons/BcryptIcon.svelte +++ b/frontend/src/lib/components/icons/BcryptIcon.svelte @@ -1,15 +1,39 @@ - - - - + + + + diff --git a/frontend/src/lib/components/icons/BeamerIcon.svelte b/frontend/src/lib/components/icons/BeamerIcon.svelte new file mode 100644 index 0000000000..696eac11f4 --- /dev/null +++ b/frontend/src/lib/components/icons/BeamerIcon.svelte @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BigQueryIcon.svelte b/frontend/src/lib/components/icons/BigQueryIcon.svelte index 10d099f75d..43958d302e 100644 --- a/frontend/src/lib/components/icons/BigQueryIcon.svelte +++ b/frontend/src/lib/components/icons/BigQueryIcon.svelte @@ -1,42 +1,36 @@ + -Icon_24px_BigQuery_Color + + + + + + + + diff --git a/frontend/src/lib/components/icons/BitbucketIcon.svelte b/frontend/src/lib/components/icons/BitbucketIcon.svelte index 9b33e6bb10..ca368edc70 100644 --- a/frontend/src/lib/components/icons/BitbucketIcon.svelte +++ b/frontend/src/lib/components/icons/BitbucketIcon.svelte @@ -1,24 +1,23 @@ - - - - - - - - Bitbucket-blue - - - - - - - \ No newline at end of file + + + + diff --git a/frontend/src/lib/components/icons/BitlyIcon.svelte b/frontend/src/lib/components/icons/BitlyIcon.svelte index 3cc3b43122..a023981cec 100644 --- a/frontend/src/lib/components/icons/BitlyIcon.svelte +++ b/frontend/src/lib/components/icons/BitlyIcon.svelte @@ -1,12 +1,21 @@ + - - + + diff --git a/frontend/src/lib/components/icons/BloggerIcon.svelte b/frontend/src/lib/components/icons/BloggerIcon.svelte index ab1d2d262f..13ec49a1c0 100644 --- a/frontend/src/lib/components/icons/BloggerIcon.svelte +++ b/frontend/src/lib/components/icons/BloggerIcon.svelte @@ -1,12 +1,24 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/BlueskyIcon.svelte b/frontend/src/lib/components/icons/BlueskyIcon.svelte index 1f8545e325..68dea0f3d2 100644 --- a/frontend/src/lib/components/icons/BlueskyIcon.svelte +++ b/frontend/src/lib/components/icons/BlueskyIcon.svelte @@ -1,12 +1,25 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BotifyIcon.svelte b/frontend/src/lib/components/icons/BotifyIcon.svelte new file mode 100644 index 0000000000..375e128ac4 --- /dev/null +++ b/frontend/src/lib/components/icons/BotifyIcon.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/BoxIcon.svelte b/frontend/src/lib/components/icons/BoxIcon.svelte index a6a5d5dc07..980d8f5757 100644 --- a/frontend/src/lib/components/icons/BoxIcon.svelte +++ b/frontend/src/lib/components/icons/BoxIcon.svelte @@ -1,12 +1,23 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BrandLetterIcon.svelte b/frontend/src/lib/components/icons/BrandLetterIcon.svelte new file mode 100644 index 0000000000..a9e9fa2966 --- /dev/null +++ b/frontend/src/lib/components/icons/BrandLetterIcon.svelte @@ -0,0 +1,58 @@ + + + + + + {letter} + diff --git a/frontend/src/lib/components/icons/BrevoIcon.svelte b/frontend/src/lib/components/icons/BrevoIcon.svelte index 2907c346f7..971fb5d9f3 100644 --- a/frontend/src/lib/components/icons/BrevoIcon.svelte +++ b/frontend/src/lib/components/icons/BrevoIcon.svelte @@ -1,12 +1,20 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/BrexIcon.svelte b/frontend/src/lib/components/icons/BrexIcon.svelte index 5f9fbf8de1..526b2fe536 100644 --- a/frontend/src/lib/components/icons/BrexIcon.svelte +++ b/frontend/src/lib/components/icons/BrexIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BrowserlessIcon.svelte b/frontend/src/lib/components/icons/BrowserlessIcon.svelte index 768d51145c..9dcf785adf 100644 --- a/frontend/src/lib/components/icons/BrowserlessIcon.svelte +++ b/frontend/src/lib/components/icons/BrowserlessIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BubbleIcon.svelte b/frontend/src/lib/components/icons/BubbleIcon.svelte index fdfb049e70..15277d5436 100644 --- a/frontend/src/lib/components/icons/BubbleIcon.svelte +++ b/frontend/src/lib/components/icons/BubbleIcon.svelte @@ -1,16 +1,24 @@ + - - - - - + + + + diff --git a/frontend/src/lib/components/icons/BuildkiteIcon.svelte b/frontend/src/lib/components/icons/BuildkiteIcon.svelte index 282d782e31..e510c61449 100644 --- a/frontend/src/lib/components/icons/BuildkiteIcon.svelte +++ b/frontend/src/lib/components/icons/BuildkiteIcon.svelte @@ -1,12 +1,16 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/BunIcon.svelte b/frontend/src/lib/components/icons/BunIcon.svelte index ef845d4834..792ad10b3c 100644 --- a/frontend/src/lib/components/icons/BunIcon.svelte +++ b/frontend/src/lib/components/icons/BunIcon.svelte @@ -1,12 +1,13 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + diff --git a/frontend/src/lib/components/icons/CACertificate.svelte b/frontend/src/lib/components/icons/CACertificate.svelte index c73aa5d3ca..d6a6a6cdde 100644 --- a/frontend/src/lib/components/icons/CACertificate.svelte +++ b/frontend/src/lib/components/icons/CACertificate.svelte @@ -1,12 +1,17 @@ - - \ No newline at end of file + + diff --git a/frontend/src/lib/components/icons/CSharpIcon.svelte b/frontend/src/lib/components/icons/CSharpIcon.svelte index 703c692036..c9c06d77cb 100644 --- a/frontend/src/lib/components/icons/CSharpIcon.svelte +++ b/frontend/src/lib/components/icons/CSharpIcon.svelte @@ -1,18 +1,41 @@ - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CalcomIcon.svelte b/frontend/src/lib/components/icons/CalcomIcon.svelte index 382b8e7a56..5dea23818d 100644 --- a/frontend/src/lib/components/icons/CalcomIcon.svelte +++ b/frontend/src/lib/components/icons/CalcomIcon.svelte @@ -1,13 +1,16 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() - - + + + diff --git a/frontend/src/lib/components/icons/CampaynIcon.svelte b/frontend/src/lib/components/icons/CampaynIcon.svelte new file mode 100644 index 0000000000..c343cb186f --- /dev/null +++ b/frontend/src/lib/components/icons/CampaynIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CertopusIcon.svelte b/frontend/src/lib/components/icons/CertopusIcon.svelte new file mode 100644 index 0000000000..227528bae2 --- /dev/null +++ b/frontend/src/lib/components/icons/CertopusIcon.svelte @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ChromaIcon.svelte b/frontend/src/lib/components/icons/ChromaIcon.svelte new file mode 100644 index 0000000000..b42a6b58e4 --- /dev/null +++ b/frontend/src/lib/components/icons/ChromaIcon.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CircleCiIcon.svelte b/frontend/src/lib/components/icons/CircleCiIcon.svelte index 92402ac6cd..59c97c4e37 100644 --- a/frontend/src/lib/components/icons/CircleCiIcon.svelte +++ b/frontend/src/lib/components/icons/CircleCiIcon.svelte @@ -1,12 +1,23 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CiscoIcon.svelte b/frontend/src/lib/components/icons/CiscoIcon.svelte index d2d4b14bd8..aa3ed22d0f 100644 --- a/frontend/src/lib/components/icons/CiscoIcon.svelte +++ b/frontend/src/lib/components/icons/CiscoIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/ClaudeIcon.svelte b/frontend/src/lib/components/icons/ClaudeIcon.svelte index 5c5cdeffe5..44ec5723c2 100644 --- a/frontend/src/lib/components/icons/ClaudeIcon.svelte +++ b/frontend/src/lib/components/icons/ClaudeIcon.svelte @@ -1,12 +1,13 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + - + diff --git a/frontend/src/lib/components/icons/ClerkIcon.svelte b/frontend/src/lib/components/icons/ClerkIcon.svelte index 21baeae38d..9649794578 100644 --- a/frontend/src/lib/components/icons/ClerkIcon.svelte +++ b/frontend/src/lib/components/icons/ClerkIcon.svelte @@ -1,12 +1,24 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/ClickhouseIcon.svelte b/frontend/src/lib/components/icons/ClickhouseIcon.svelte index 6d3870762a..a1ea211f36 100644 --- a/frontend/src/lib/components/icons/ClickhouseIcon.svelte +++ b/frontend/src/lib/components/icons/ClickhouseIcon.svelte @@ -1,27 +1,36 @@ + - \ No newline at end of file + + + + diff --git a/frontend/src/lib/components/icons/ClickupIcon.svelte b/frontend/src/lib/components/icons/ClickupIcon.svelte index 22e160931a..e84ddc0633 100644 --- a/frontend/src/lib/components/icons/ClickupIcon.svelte +++ b/frontend/src/lib/components/icons/ClickupIcon.svelte @@ -1,10 +1,11 @@ + - - + + - + diff --git a/frontend/src/lib/components/icons/CloseIcon.svelte b/frontend/src/lib/components/icons/CloseIcon.svelte index 73e6f414f9..e61b7ada6c 100644 --- a/frontend/src/lib/components/icons/CloseIcon.svelte +++ b/frontend/src/lib/components/icons/CloseIcon.svelte @@ -1,42 +1,37 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/frontend/src/lib/components/icons/CloudflareIcon.svelte b/frontend/src/lib/components/icons/CloudflareIcon.svelte index 6ade506d8a..7fed848d2d 100644 --- a/frontend/src/lib/components/icons/CloudflareIcon.svelte +++ b/frontend/src/lib/components/icons/CloudflareIcon.svelte @@ -1,12 +1,13 @@ + - diff --git a/frontend/src/lib/components/icons/CloudinaryIcon.svelte b/frontend/src/lib/components/icons/CloudinaryIcon.svelte index a61f3bbe7c..9dc633dccd 100644 --- a/frontend/src/lib/components/icons/CloudinaryIcon.svelte +++ b/frontend/src/lib/components/icons/CloudinaryIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CockroachDbIcon.svelte b/frontend/src/lib/components/icons/CockroachDbIcon.svelte index d88f7c52de..2953c152f5 100644 --- a/frontend/src/lib/components/icons/CockroachDbIcon.svelte +++ b/frontend/src/lib/components/icons/CockroachDbIcon.svelte @@ -1,12 +1,24 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CodaIcon.svelte b/frontend/src/lib/components/icons/CodaIcon.svelte index d8486224d9..9a58fb8998 100644 --- a/frontend/src/lib/components/icons/CodaIcon.svelte +++ b/frontend/src/lib/components/icons/CodaIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/CodatIcon.svelte b/frontend/src/lib/components/icons/CodatIcon.svelte new file mode 100644 index 0000000000..732a4a878e --- /dev/null +++ b/frontend/src/lib/components/icons/CodatIcon.svelte @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CohereIcon.svelte b/frontend/src/lib/components/icons/CohereIcon.svelte index c0a4aea8ee..dba01ef6ed 100644 --- a/frontend/src/lib/components/icons/CohereIcon.svelte +++ b/frontend/src/lib/components/icons/CohereIcon.svelte @@ -1,21 +1,35 @@ + - - - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte index 770b60f40a..67ff2998c8 100644 --- a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte +++ b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CoinbaseIcon.svelte b/frontend/src/lib/components/icons/CoinbaseIcon.svelte index b58a45fff0..5d61e83df7 100644 --- a/frontend/src/lib/components/icons/CoinbaseIcon.svelte +++ b/frontend/src/lib/components/icons/CoinbaseIcon.svelte @@ -1,12 +1,23 @@ + - - + + diff --git a/frontend/src/lib/components/icons/ComapeoIcon.svelte b/frontend/src/lib/components/icons/ComapeoIcon.svelte new file mode 100644 index 0000000000..062c45e087 --- /dev/null +++ b/frontend/src/lib/components/icons/ComapeoIcon.svelte @@ -0,0 +1,25 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ConfluenceIcon.svelte b/frontend/src/lib/components/icons/ConfluenceIcon.svelte index 60dbcef880..a892e912f2 100644 --- a/frontend/src/lib/components/icons/ConfluenceIcon.svelte +++ b/frontend/src/lib/components/icons/ConfluenceIcon.svelte @@ -1,12 +1,17 @@ + - - + + + diff --git a/frontend/src/lib/components/icons/ContentfulIcon.svelte b/frontend/src/lib/components/icons/ContentfulIcon.svelte index 2af55d48fc..38f466d92d 100644 --- a/frontend/src/lib/components/icons/ContentfulIcon.svelte +++ b/frontend/src/lib/components/icons/ContentfulIcon.svelte @@ -1,12 +1,32 @@ + - - + + + + + + diff --git a/frontend/src/lib/components/icons/ContiguityIcon.svelte b/frontend/src/lib/components/icons/ContiguityIcon.svelte new file mode 100644 index 0000000000..30de7f9c13 --- /dev/null +++ b/frontend/src/lib/components/icons/ContiguityIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/ConvertKitIcon.svelte b/frontend/src/lib/components/icons/ConvertKitIcon.svelte index 2fbcf0e53f..0506f62f43 100644 --- a/frontend/src/lib/components/icons/ConvertKitIcon.svelte +++ b/frontend/src/lib/components/icons/ConvertKitIcon.svelte @@ -1,12 +1,24 @@ - - + + + + + diff --git a/frontend/src/lib/components/icons/CoupaIcon.svelte b/frontend/src/lib/components/icons/CoupaIcon.svelte index 4d4058fdac..a8c780bdbc 100644 --- a/frontend/src/lib/components/icons/CoupaIcon.svelte +++ b/frontend/src/lib/components/icons/CoupaIcon.svelte @@ -1,19 +1,22 @@ + diff --git a/frontend/src/lib/components/icons/CustomAiIcon.svelte b/frontend/src/lib/components/icons/CustomAiIcon.svelte new file mode 100644 index 0000000000..a646753b7c --- /dev/null +++ b/frontend/src/lib/components/icons/CustomAiIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/DatabricksIcon.svelte b/frontend/src/lib/components/icons/DatabricksIcon.svelte index e767337442..e06471cdd4 100644 --- a/frontend/src/lib/components/icons/DatabricksIcon.svelte +++ b/frontend/src/lib/components/icons/DatabricksIcon.svelte @@ -1,12 +1,13 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + + + diff --git a/frontend/src/lib/components/icons/DatoCmsIcon.svelte b/frontend/src/lib/components/icons/DatoCmsIcon.svelte index d462832e17..e2e4b2be0e 100644 --- a/frontend/src/lib/components/icons/DatoCmsIcon.svelte +++ b/frontend/src/lib/components/icons/DatoCmsIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/DbIcon.svelte b/frontend/src/lib/components/icons/DbIcon.svelte index fe74ca9adb..44227ad43a 100644 --- a/frontend/src/lib/components/icons/DbIcon.svelte +++ b/frontend/src/lib/components/icons/DbIcon.svelte @@ -1,17 +1,17 @@ diff --git a/frontend/src/lib/components/icons/DbtIcon.svelte b/frontend/src/lib/components/icons/DbtIcon.svelte index b647cb215e..a90f82b90f 100644 --- a/frontend/src/lib/components/icons/DbtIcon.svelte +++ b/frontend/src/lib/components/icons/DbtIcon.svelte @@ -7,21 +7,18 @@ let { height = 24, width = 24 }: Props = $props() + - - - - - - - - + diff --git a/frontend/src/lib/components/icons/DeelIcon.svelte b/frontend/src/lib/components/icons/DeelIcon.svelte index 0b93a4a98f..e36b64fe3f 100644 --- a/frontend/src/lib/components/icons/DeelIcon.svelte +++ b/frontend/src/lib/components/icons/DeelIcon.svelte @@ -1,12 +1,25 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/DeepInfraIcon.svelte b/frontend/src/lib/components/icons/DeepInfraIcon.svelte new file mode 100644 index 0000000000..3750b5ff42 --- /dev/null +++ b/frontend/src/lib/components/icons/DeepInfraIcon.svelte @@ -0,0 +1,27 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/DeepLIcon.svelte b/frontend/src/lib/components/icons/DeepLIcon.svelte index 028b061dc0..58b2a14833 100644 --- a/frontend/src/lib/components/icons/DeepLIcon.svelte +++ b/frontend/src/lib/components/icons/DeepLIcon.svelte @@ -1,12 +1,40 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/DeepSeekIcon.svelte b/frontend/src/lib/components/icons/DeepSeekIcon.svelte index d0ba0c6423..560c5704bb 100644 --- a/frontend/src/lib/components/icons/DeepSeekIcon.svelte +++ b/frontend/src/lib/components/icons/DeepSeekIcon.svelte @@ -7,7 +7,15 @@ let { height = '24px', width = '24px' }: Props = $props() - + + diff --git a/frontend/src/lib/components/icons/DenoIcon.svelte b/frontend/src/lib/components/icons/DenoIcon.svelte index 86dca42410..776144b34f 100644 --- a/frontend/src/lib/components/icons/DenoIcon.svelte +++ b/frontend/src/lib/components/icons/DenoIcon.svelte @@ -1,41 +1,44 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte index 206e395175..6daeb458d0 100644 --- a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte +++ b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/DiscordIcon.svelte b/frontend/src/lib/components/icons/DiscordIcon.svelte index 384008b773..ca92f30ed4 100644 --- a/frontend/src/lib/components/icons/DiscordIcon.svelte +++ b/frontend/src/lib/components/icons/DiscordIcon.svelte @@ -1,21 +1,22 @@ + + + diff --git a/frontend/src/lib/components/icons/DiscourseIcon.svelte b/frontend/src/lib/components/icons/DiscourseIcon.svelte index fd1e53f9a3..dd590cee4a 100644 --- a/frontend/src/lib/components/icons/DiscourseIcon.svelte +++ b/frontend/src/lib/components/icons/DiscourseIcon.svelte @@ -1,12 +1,38 @@ - - + + + diff --git a/frontend/src/lib/components/icons/DocSpringIcon.svelte b/frontend/src/lib/components/icons/DocSpringIcon.svelte new file mode 100644 index 0000000000..f67ba12c70 --- /dev/null +++ b/frontend/src/lib/components/icons/DocSpringIcon.svelte @@ -0,0 +1,55 @@ + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/DockerIcon.svelte b/frontend/src/lib/components/icons/DockerIcon.svelte index d7ab3f498c..f5a5ac11a5 100644 --- a/frontend/src/lib/components/icons/DockerIcon.svelte +++ b/frontend/src/lib/components/icons/DockerIcon.svelte @@ -1,27 +1,22 @@ + diff --git a/frontend/src/lib/components/icons/DocusignIcon.svelte b/frontend/src/lib/components/icons/DocusignIcon.svelte index 91d96bac36..5fe8c42f82 100644 --- a/frontend/src/lib/components/icons/DocusignIcon.svelte +++ b/frontend/src/lib/components/icons/DocusignIcon.svelte @@ -1,12 +1,30 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/DropboxIcon.svelte b/frontend/src/lib/components/icons/DropboxIcon.svelte index 3facd1dda2..51821fdd3e 100644 --- a/frontend/src/lib/components/icons/DropboxIcon.svelte +++ b/frontend/src/lib/components/icons/DropboxIcon.svelte @@ -1,12 +1,15 @@ - - + + + diff --git a/frontend/src/lib/components/icons/DuckDbIcon.svelte b/frontend/src/lib/components/icons/DuckDbIcon.svelte index 1fa2e55b2c..793827c981 100644 --- a/frontend/src/lib/components/icons/DuckDbIcon.svelte +++ b/frontend/src/lib/components/icons/DuckDbIcon.svelte @@ -1,23 +1,32 @@ - + + diff --git a/frontend/src/lib/components/icons/DucklakeIcon.svelte b/frontend/src/lib/components/icons/DucklakeIcon.svelte index 488260ea1a..5665734ff5 100644 --- a/frontend/src/lib/components/icons/DucklakeIcon.svelte +++ b/frontend/src/lib/components/icons/DucklakeIcon.svelte @@ -1,4 +1,5 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/DynatraceIcon.svelte b/frontend/src/lib/components/icons/DynatraceIcon.svelte index e8d57c58ce..df0ad401a4 100644 --- a/frontend/src/lib/components/icons/DynatraceIcon.svelte +++ b/frontend/src/lib/components/icons/DynatraceIcon.svelte @@ -1,31 +1,53 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/EdgeDbIcon.svelte b/frontend/src/lib/components/icons/EdgeDbIcon.svelte index cc31f328d4..19cb62e2b3 100644 --- a/frontend/src/lib/components/icons/EdgeDbIcon.svelte +++ b/frontend/src/lib/components/icons/EdgeDbIcon.svelte @@ -1,3 +1,6 @@ + - + - diff --git a/frontend/src/lib/components/icons/EnodeIcon.svelte b/frontend/src/lib/components/icons/EnodeIcon.svelte new file mode 100644 index 0000000000..91a5f59094 --- /dev/null +++ b/frontend/src/lib/components/icons/EnodeIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/EventbriteIcon.svelte b/frontend/src/lib/components/icons/EventbriteIcon.svelte index f851a933f3..c98db1071e 100644 --- a/frontend/src/lib/components/icons/EventbriteIcon.svelte +++ b/frontend/src/lib/components/icons/EventbriteIcon.svelte @@ -1,15 +1,16 @@ - - - - - + + + diff --git a/frontend/src/lib/components/icons/ExaIcon.svelte b/frontend/src/lib/components/icons/ExaIcon.svelte new file mode 100644 index 0000000000..eef3057e92 --- /dev/null +++ b/frontend/src/lib/components/icons/ExaIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/FaunadbIcon.svelte b/frontend/src/lib/components/icons/FaunadbIcon.svelte index 62c4dde4f2..7bb3f371e3 100644 --- a/frontend/src/lib/components/icons/FaunadbIcon.svelte +++ b/frontend/src/lib/components/icons/FaunadbIcon.svelte @@ -1,13 +1,15 @@ + - \ No newline at end of file diff --git a/frontend/src/lib/components/icons/FigmaIcon.svelte b/frontend/src/lib/components/icons/FigmaIcon.svelte index e31fe0a71d..bc39256ab2 100644 --- a/frontend/src/lib/components/icons/FigmaIcon.svelte +++ b/frontend/src/lib/components/icons/FigmaIcon.svelte @@ -1,12 +1,20 @@ + - - + + + + + + diff --git a/frontend/src/lib/components/icons/FirebaseIcon.svelte b/frontend/src/lib/components/icons/FirebaseIcon.svelte index 6d1ce00468..fd85fc72d2 100644 --- a/frontend/src/lib/components/icons/FirebaseIcon.svelte +++ b/frontend/src/lib/components/icons/FirebaseIcon.svelte @@ -1,10 +1,11 @@ + - - - - - - - - - - diff --git a/frontend/src/lib/components/icons/FlyIcon.svelte b/frontend/src/lib/components/icons/FlyIcon.svelte index 02118212b2..d48b62556e 100644 --- a/frontend/src/lib/components/icons/FlyIcon.svelte +++ b/frontend/src/lib/components/icons/FlyIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/FormInputIcon.svelte b/frontend/src/lib/components/icons/FormInputIcon.svelte new file mode 100644 index 0000000000..1f1dcbc242 --- /dev/null +++ b/frontend/src/lib/components/icons/FormInputIcon.svelte @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/frontend/src/lib/components/icons/FormstackIcon.svelte b/frontend/src/lib/components/icons/FormstackIcon.svelte new file mode 100644 index 0000000000..e0da265e6a --- /dev/null +++ b/frontend/src/lib/components/icons/FormstackIcon.svelte @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/FoxentryIcon.svelte b/frontend/src/lib/components/icons/FoxentryIcon.svelte new file mode 100644 index 0000000000..88f6b71054 --- /dev/null +++ b/frontend/src/lib/components/icons/FoxentryIcon.svelte @@ -0,0 +1,20 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/FreshdeskIcon.svelte b/frontend/src/lib/components/icons/FreshdeskIcon.svelte index 72f2d7b1fa..bbeb87d668 100644 --- a/frontend/src/lib/components/icons/FreshdeskIcon.svelte +++ b/frontend/src/lib/components/icons/FreshdeskIcon.svelte @@ -1,12 +1,19 @@ + - + diff --git a/frontend/src/lib/components/icons/FrontAppIcon.svelte b/frontend/src/lib/components/icons/FrontAppIcon.svelte index dfba63c8b7..aba4bccae4 100644 --- a/frontend/src/lib/components/icons/FrontAppIcon.svelte +++ b/frontend/src/lib/components/icons/FrontAppIcon.svelte @@ -1,12 +1,16 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/FunkwhaleIcon.svelte b/frontend/src/lib/components/icons/FunkwhaleIcon.svelte index 45a1821beb..0fb36804f6 100644 --- a/frontend/src/lib/components/icons/FunkwhaleIcon.svelte +++ b/frontend/src/lib/components/icons/FunkwhaleIcon.svelte @@ -1,73 +1,45 @@ + image/svg+xml + - - - - - - - - - - - - diff --git a/frontend/src/lib/components/icons/GCloudIcon.svelte b/frontend/src/lib/components/icons/GCloudIcon.svelte deleted file mode 100644 index 6fba8faa05..0000000000 --- a/frontend/src/lib/components/icons/GCloudIcon.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/frontend/src/lib/components/icons/GSheetsIcon.svelte b/frontend/src/lib/components/icons/GSheetsIcon.svelte index 419f9d1b26..b23010fd90 100644 --- a/frontend/src/lib/components/icons/GSheetsIcon.svelte +++ b/frontend/src/lib/components/icons/GSheetsIcon.svelte @@ -1,21 +1,69 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GcalIcon.svelte b/frontend/src/lib/components/icons/GcalIcon.svelte index 6b8f90b086..3a46c28c3e 100644 --- a/frontend/src/lib/components/icons/GcalIcon.svelte +++ b/frontend/src/lib/components/icons/GcalIcon.svelte @@ -1,21 +1,103 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GdocsIcon.svelte b/frontend/src/lib/components/icons/GdocsIcon.svelte index 2e202cc2c8..c25121c2d3 100644 --- a/frontend/src/lib/components/icons/GdocsIcon.svelte +++ b/frontend/src/lib/components/icons/GdocsIcon.svelte @@ -1,14 +1,77 @@ - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GdriveIcon.svelte b/frontend/src/lib/components/icons/GdriveIcon.svelte index eff87a3645..70c5466cc0 100644 --- a/frontend/src/lib/components/icons/GdriveIcon.svelte +++ b/frontend/src/lib/components/icons/GdriveIcon.svelte @@ -1,21 +1,71 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GhostCmsIcon.svelte b/frontend/src/lib/components/icons/GhostCmsIcon.svelte index 018960d9b7..fcebf9f38f 100644 --- a/frontend/src/lib/components/icons/GhostCmsIcon.svelte +++ b/frontend/src/lib/components/icons/GhostCmsIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/GiphyIcon.svelte b/frontend/src/lib/components/icons/GiphyIcon.svelte index 521a358840..fc075eeac5 100644 --- a/frontend/src/lib/components/icons/GiphyIcon.svelte +++ b/frontend/src/lib/components/icons/GiphyIcon.svelte @@ -1,12 +1,17 @@ - - + + + + + + + diff --git a/frontend/src/lib/components/icons/GitBookIcon.svelte b/frontend/src/lib/components/icons/GitBookIcon.svelte index b310c868f7..c3d1c1cf52 100644 --- a/frontend/src/lib/components/icons/GitBookIcon.svelte +++ b/frontend/src/lib/components/icons/GitBookIcon.svelte @@ -1,12 +1,24 @@ - - + + + diff --git a/frontend/src/lib/components/icons/GitIcon.svelte b/frontend/src/lib/components/icons/GitIcon.svelte index 08f7631a91..c6dd709daa 100644 --- a/frontend/src/lib/components/icons/GitIcon.svelte +++ b/frontend/src/lib/components/icons/GitIcon.svelte @@ -1,3 +1,4 @@ + diff --git a/frontend/src/lib/components/icons/GithubIcon.svelte b/frontend/src/lib/components/icons/GithubIcon.svelte index 0b47c0d281..0bb14688cd 100644 --- a/frontend/src/lib/components/icons/GithubIcon.svelte +++ b/frontend/src/lib/components/icons/GithubIcon.svelte @@ -1,9 +1,10 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + + + diff --git a/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte b/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte new file mode 100644 index 0000000000..44bcd61411 --- /dev/null +++ b/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/src/lib/components/icons/GmailIcon.svelte b/frontend/src/lib/components/icons/GmailIcon.svelte index 2a3281adef..cbcad5a889 100644 --- a/frontend/src/lib/components/icons/GmailIcon.svelte +++ b/frontend/src/lib/components/icons/GmailIcon.svelte @@ -1,21 +1,19 @@ - + + + + diff --git a/frontend/src/lib/components/icons/GoogleAiIcon.svelte b/frontend/src/lib/components/icons/GoogleAiIcon.svelte index af0e153f7b..9cae17abd8 100644 --- a/frontend/src/lib/components/icons/GoogleAiIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleAiIcon.svelte @@ -7,19 +7,21 @@ let { height = '24px', width = '24px' }: Props = $props() + - - - + + + + - - + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte index 5fb4e21797..61455feb1d 100644 --- a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte @@ -1,14 +1,20 @@ + diff --git a/frontend/src/lib/components/icons/GoogleDriveIcon.svelte b/frontend/src/lib/components/icons/GoogleDriveIcon.svelte index 0cccffa372..943f4c2cb0 100644 --- a/frontend/src/lib/components/icons/GoogleDriveIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleDriveIcon.svelte @@ -7,29 +7,68 @@ let { height = '24px', width = '24px' }: Props = $props() - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleFormsIcon.svelte b/frontend/src/lib/components/icons/GoogleFormsIcon.svelte index de25179dc4..3c43dab1f9 100644 --- a/frontend/src/lib/components/icons/GoogleFormsIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleFormsIcon.svelte @@ -1,31 +1,64 @@ + - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleIcon.svelte b/frontend/src/lib/components/icons/GoogleIcon.svelte index bd53eb1bab..da75fb7a3b 100644 --- a/frontend/src/lib/components/icons/GoogleIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleIcon.svelte @@ -1,36 +1,37 @@ + - - - - + diff --git a/frontend/src/lib/components/icons/GorgiasIcon.svelte b/frontend/src/lib/components/icons/GorgiasIcon.svelte new file mode 100644 index 0000000000..290ca62806 --- /dev/null +++ b/frontend/src/lib/components/icons/GorgiasIcon.svelte @@ -0,0 +1,23 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/GpgKeyIcon.svelte b/frontend/src/lib/components/icons/GpgKeyIcon.svelte new file mode 100644 index 0000000000..86d11487ae --- /dev/null +++ b/frontend/src/lib/components/icons/GpgKeyIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GraphqlIcon.svelte b/frontend/src/lib/components/icons/GraphqlIcon.svelte index 292bdbad20..245cdc7b37 100644 --- a/frontend/src/lib/components/icons/GraphqlIcon.svelte +++ b/frontend/src/lib/components/icons/GraphqlIcon.svelte @@ -1,13 +1,15 @@ +
-

+ {#snippet titleExtra()} + + {/snippet} +
{#if resourceTypeViewerObj.description} -
- -
+ {/if} {#if resourceTypeViewerObj.isFileset} @@ -1147,9 +1165,14 @@ - - {removeMarkdown(truncate(description ?? '', 30))} - +
+ + {removeMarkdown(truncate(description ?? '', 200))} + +
@@ -1367,19 +1390,31 @@ - - {removeMarkdown(truncate(description ?? '', 200))} - + +
+ + {removeMarkdown(truncate(description ?? '', 200))} + +
- + {#if !canWrite} - - Shared globally - - This resource type is from the 'admins' workspace shared with all - workspaces - - + +
+ + Shared globally + + This resource type is from the 'admins' workspace shared with all + workspaces + + +
{:else if $userStore?.is_admin || $userStore?.is_super_admin}